Skip to content

fix(telegram): authenticate webhook deliveries with the Telegram secret token - #13175

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
huuhungn:fix/telegram-webhook-secret-token-13172
Sep 11, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
huuhungn:fix/telegram-webhook-secret-token-13172

Conversation

@anhtahaylove

Copy link
Copy Markdown
Contributor

Fixes #13172.

Problem

POST /api/telegram/update serves two callers, and only one of them was authenticated:

  • Mini App ({ initData, message }) — verifies the Telegram initData HMAC. Safe.
  • Bot webhook (a Telegram update) — took message.chat.id straight from the body. The only gate was isTelegramEnabled(), which merely checks that a bot token is configured on our side. Nothing proved the request came from Telegram.

chat.id flows into proxyChat(), which mints a real API key for that id and spends upstream model quota. A caller who knows the URL can therefore create unbounded keys and burn quota. They don't get the replies back (those go to the spoofed chat id), so this is resource abuse and key-table growth, not data exfiltration.

setWebhook also never registered a secret_token, so the header needed for verification was never being sent by Telegram in the first place.

Fix

Use Telegram's own mechanism: the secret_token given to setWebhook is echoed back on every delivery as X-Telegram-Bot-Api-Secret-Token.

  1. TELEGRAM_WEBHOOK_SECRET config getter (src/lib/telegram/config.ts).
  2. setWebhook registers secret_token when configured (src/lib/telegram/botApi.ts).
  3. The webhook branch verifies the header before anything reaches proxyChat():
    • secret not configured → 503 (fail closed — an open webhook is what causes the abuse)
    • missing/wrong secret → 401
    • correct → proceeds as before

Comparison uses timingSafeEqual with an up-front length check, mirroring webhookSecretMatches's counterpart in src/app/api/a2a/tasks/route.ts, so the shared-prefix length doesn't leak through response timing.

The Mini App path is untouched and does not require the secret.

Incidental schema fix

The zod body schema typed message as an optional string (the Mini App shape), but a real Telegram update sends message as an object. .passthrough() preserves unknown keys but still type-checks known ones, so realistic webhook deliveries were rejected with 400 before reaching any routing. message now accepts either shape; each branch already narrows the shape it needs (typeof body.message === "string" for Mini App, extractChatMessage() for webhook). This surfaced while writing the test — a body that mirrors a genuine Telegram update.

Verification

tests/unit/telegram-webhook-secret-13172.test.ts — 4/4 pass with the fix, 4/4 fail without it (verified by stashing the source changes):

  • no secret header → 401, nothing proxied
  • wrong secret → 401
  • correct secret → 200
  • comparator: equal values match, length mismatch returns false instead of throwing

npx tsc --noEmit clean.

Operator note — breaking for existing webhook deployments

Anyone already running the webhook must set TELEGRAM_WEBHOOK_SECRET and re-run setWebhook so Telegram starts sending the header; until then deliveries return 503. This is deliberate: silently serving an unauthenticated webhook is the vulnerability. Documented in .env.example and docs/reference/ENVIRONMENT.md.

Note check:docs-counts reports 3 pre-existing strict drifts (171 → 172 migrations) on this base; unrelated to this change and fixed by #13160.

@anhtahaylove

Copy link
Copy Markdown
Contributor Author

Flagging for review priority: of the 14 open PRs in this audit (queue overview), this is the only one with a security impact rather than a resource-management one.

An unauthenticated caller could POST a synthetic Telegram update with any chat.id; that id reaches proxyChat(), which mints a real API key and spends upstream quota. Replies go to the spoofed chat id, so it is resource abuse and unbounded key creation rather than data exfiltration — but it needs no credentials at all.

Note this is intentionally breaking for existing webhook deployments: operators must set TELEGRAM_WEBHOOK_SECRET and re-run setWebhook, otherwise deliveries return 503. Failing closed seemed clearly right over continuing to serve an open endpoint, but say the word if you'd prefer a deprecation window instead.

@diegosouzapw
diegosouzapw merged commit a1b9b02 into diegosouzapw:release/v3.8.51 Sep 11, 2026
8 of 16 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…et token (diegosouzapw#13175)

Real abuse vector: the bot-webhook branch reached `proxyChat()` — which mints an API key and spends upstream quota — with nothing proving the caller was Telegram. Fail-closed 503 when the secret is unset is the right default.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: diegosouzapw#12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(auth): Telegram webhook path accepts unauthenticated updates (no secret_token check)

2 participants