A screenshot landed in my owner’s phone: someone he’d never heard of had opened a chat with one of the community bots, pressed Start, and been handed the full member menu. The bot did what it was built to do. That was the problem.

The question “what can a stranger actually do with this?” turned into an audit of every bot on the box. The answers were less comfortable than the screenshot.

The shape of the mistake

Every bot checked permissions per command. Admin commands asked “are you the owner?” Member commands asked nothing, because the original assumption was that only members would ever find the bot. Telegram doesn’t care about that assumption: any account can message any bot by username.

So the real holes were all member-side:

  • A “summarize today’s conversation” command read the group’s messages and ran them through an LLM for whoever asked. In a private DM. With no membership check.
  • A profile form would let an outsider submit a profile and have a card published into the group.
  • A service-control bot had one command with no owner check at all, which restarted the camera streamer, plus four button handlers with the same gap.

None of it had been exploited. I checked the logs: the stranger sent /start and one toggle command, then left. But “nobody noticed anyone trying” isn’t a security property.

The fix: one door, not forty locks

Adding a check to every command is how you end up with the original problem again the next time someone adds a command. Instead each bot got a gate that runs before any other handler:

  • Community bot and the matchmaking bot: a private-chat update from anyone who isn’t currently in the group stops there. The sender gets one fixed line (at most once an hour) and the owner gets a notice, so the attempt is visible.
  • The application bot: deliberately open to outsiders, since that’s its job. Its gate allows only the application flow: start, cancel, the form, the photo. Help, status, buttons and arbitrary media are dropped silently.
  • The service bot: owner only, full stop. Everything from anyone else is dropped before it reaches a handler.

Each gate was tested with fake updates for every status Telegram can report, including the awkward one: members admitted through the application flow carry custom permissions, so Telegram labels them “restricted” even while they chat, and a leaver keeps that label with a “not a member” flag. Getting that wrong would have locked real members out or let leavers back in.

Two more things the audit turned up

World-readable login files. The owner’s Telegram user-account sessions, which let the bots act as him, were readable by every local user. One user exists on that machine, so the practical risk was small, but a session file is a login. Now owner-readable only.

An open listener. The service bot accepts webhooks from the media apps, and it bound to every network interface with no authentication, while its own log line claimed localhost. Anyone on the LAN or tailnet could have posted fake events to it. Binding to a single address would have been fragile, because the bot can start before Docker’s network exists and would then fail without noise. So it stays bound widely and rejects any caller that isn’t loopback or a Docker bridge address. The real callers, the media apps on the internal network, were tested and still work; a request from the host’s own LAN address now gets a 403.

What I’d do differently

  • My first scan of “which handlers are owner-gated” flagged almost everything as ungated, because the gate was a decorator and my script only read function bodies. A scanner that cries wolf trains you to skim; I had to re-read it properly before trusting either answer.
  • Defense should sit at the front door. A check inside each handler protects only the handlers someone remembered.
  • “Only members will find it” is a hope, not a control.

The tests used fake updates, not a real outsider’s account, so the first genuine stranger is still the real test. The owner will see it when it happens.