Skip to the content.

chigwell/telegram-mcp — security scan

Repository: chigwell/telegram-mcp Commit scanned: 02f93bf Scan date: 2026-09-08 Disclosure status: public — clean scan, nothing filed

Summary

Severity Count
Critical 1
High 18
Medium 17
Low 0
Info 2

Total findings: 38 (0 real after curation)

Top findings

There are none. This is the thirty-second scan in the series to come back clean, and the shape of the noise is the interesting part. One Critical and fifteen of the eighteen High findings are Trivy reading a single dependency file that no way of installing this project ever touches. The rest are the usual placeholder secrets, mutable GitHub Actions tags, and two rules objecting to code that is doing the right thing.

The project

An MCP server that hands a Claude, Cursor, or Codex agent the keys to a Telegram account: 80-plus tools spanning messages, chats, groups, admin operations, contacts, media, and voice transcription, over Telethon. It is a real one — 1.6k stars, Apache-2.0, 37 merged pull requests from 26 distinct authors in the last sixty days, and a codebase that has clearly been through security review already. The defensive detail is not incidental; it is everywhere, which is why the clean result is worth reading rather than the raw count.

The one finding worth stating: a lockfile that isn’t an install path

Trivy produced twenty of the thirty-eight findings — the lone Critical and fifteen High — and every single one is an advisory against poetry.lock: h11 0.14.0, mcp 1.6.0, starlette 0.46.2, rsa 4.2, pyasn1, and so on. Read on its own, that is an alarming report, and it includes a genuinely scary-sounding line: “DNS Rebinding Protection Disabled by Default in Model Context Protocol.” For an unauthenticated MCP server that speaks HTTP, that is exactly the advisory you would not want to be real.

It is not real here, for a reason a scanner cannot see: poetry.lock is not an install path for this project. The evidence is unanimous.

So the stale lockfile is a fourth manifest that sits in the tree, is generated by nothing, and is read by no installer — except Trivy, which has no notion of “is this file an install path” and dutifully reports its contents as if someone ran it. The correct verdict on all twenty is not reachable via any documented install path, and the correct action is hygiene: delete poetry.lock so it stops misleading both scanners and any contributor who assumes a committed lockfile means something.

The MCP DNS-rebinding advisory deserves the extra sentence, because it is the one a reader would most want checked rather than asserted. The version this project actually ships — mcp 1.29.0 — has the protection on by default. I ran it: FastMCP(...).settings.transport_security comes back with enable_dns_rebinding_protection=True and allowed_hosts pinned to 127.0.0.1:*, localhost:*, [::1]:*, and that object is passed into the streamable-HTTP session manager. A browser page trying to rebind a domain to the loopback port sends Host: attacker.example and is rejected before any tool runs. The project also wires MCP_ALLOWED_HOSTS/MCP_ALLOWED_ORIGINS into that same setting for the reverse-proxy case, and its docker-compose.yml binds 127.0.0.1:8765 only, with a comment that says the endpoint is unauthenticated and must stay on localhost. The advisory is fixed in the shipped SDK and the deployment does not contradict it. It reads as Critical-adjacent and is inert.

Patterns observed

This is a defended codebase, and the defenses are the kind a scanner cannot credit. The file-path tools (send_file, download_media, upload_file, …) are deny-all by default: they take their allowed roots from the MCP client’s Roots list, and if the client advertises an empty list, or list_roots fails in a way the code cannot safely recover, the tools are disabled rather than falling back to something permissive. Server-side CLI roots are honoured only behind an explicit TELEGRAM_ALLOW_SERVER_ROOTS_FALLBACK opt-in. Every path is resolve()d and checked against the roots — parent directory included — and an extension allowlist is applied on top. A download stages into a private temp directory, enforces a byte cap mid-transfer, and re-validates the resolved final path before os.replace. This is the confinement discipline this series usually finds missing; here it is the default and the escape hatch is the thing you have to turn on.

Prompt injection is treated as the primary threat, which is correct for a tool that feeds Telegram content to an LLM. Every tool result that carries user-controlled text runs through sanitize.py, which strips control and zero-width characters, collapses runaway newlines, and — the part that matters — leans on a structural JSON boundary so a chat message cannot be confused with a field name or a tool instruction. The module’s docstring reasons about the threat model rather than reaching for a keyword denylist, and explicitly rejects keyword detection as too brittle. The incoming-event-feed documentation carries the same warning: feed lines contain untrusted name fields.

The credential handling anticipates the exact class this series has filed elsewhere. The Telegram session string is the crown jewel, and it is never logged: the file logger is ERROR-level and writes API errors, not secrets; the session-pool lock derives a filename from a truncated one-way hash of the session rather than writing the session anywhere; the contact-alias and transcript stores are created 0700/0600 because they hold personal-chat text. There is even a PyPI-collision guard (install_guard.py) that refuses to run if the installed distribution metadata points at the unrelated telegram-mcp package that currently squats the name — a supply-chain footgun the README also warns about in prose.

What the other eighteen findings were

Notes on the tool

Disclosure timeline

Reproduce

git clone https://github.com/chigwell/telegram-mcp /tmp/scan-target
python scanner/run_scan.py --repo /tmp/scan-target \
  --reports-dir ./reports/chigwell-telegram-mcp --min-severity medium