Repository navigation
fix(telegram): require webhook verification by default, dedupe repeated updates (#224) - #243
Conversation
…ed updates (#224) Port upstream c4a359e7 (vercel/chat#858, chat@4.39), 1d2b78d9 (#799, chat@4.38) and the bot-identity scope helper from 7a1150ce (#813): - webhook mode requires secret_token unless allow_unverified_webhooks / TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS=true; constructor (mode=webhook) and initialize (auto resolving to webhook) raise ValidationError, and handle_webhook returns 401 before reading the body - claim each integer update_id via set_if_not_exists (telegram:webhook-update:{sha256(bot_user_id)}:{update_id}, 24h) before dispatch; duplicate -> 200, state/identity failure -> 503, no dispatch - _ensure_bot_identity shares one in-flight getMe (shielded) and retries after a failure - secret_token resolves with ?? semantics BREAKING (security): Telegram webhook deployments without a secret now fail to start or return 401. Closes #224 Part of #184
|
Warning Review limit reachedNext included review available in 38 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (8)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…out, harden non-object body log, isolate env in tests (#224) - update_id claiming now mirrors Number.isInteger: 7.0 / 7e0 normalise to int and share the ...:7 key (previously dispatched unclaimed). - allow_unverified_webhooks must be a bool; a string like "false" raises ValidationError instead of bool()-coercing to a fail-open opt-out. - process_update failure log uses the already-guarded raw update_id so an authenticated non-object body (e.g. [1]) no longer raises AttributeError. - New verification/dedupe tests clear TELEGRAM_* env so an exported opt-out/secret cannot flip their outcome. - UPSTREAM_SYNC row + CHANGELOG updated accordingly.
# Conflicts: # CHANGELOG.md
|
Merge gate: CI green on 714911d (Lint & Type Check, test (3.12), test (3.13), Analyze (actions), Analyze (python), CodeQL; CodeRabbit status pass/rate-limited); local Codex review (gpt-6-astra, xhigh, --base origin/main) on 714911d: clean — "No actionable regressions found relative to the specified merge base. Validation passed: 5,445 tests passed, 13 skipped; targeted lint and formatting checks passed, and type checking reported zero errors."; 6 astra rounds total (1 fresh round after merging main at b4e83d1 with #222/#205/#231 — clean merge, no conflicts; prior 5 rounds converged on 04f9db1); no bot review content (CodeRabbit rate-limited only, no review threads). Local full validation green (ruff, format, audit 0 hard failures, strict fidelity @ chat@4.31.0, pytest 5445 passed/13 skipped). Merging with --admin (Protect Main requires a code-owner approval). |
Summary
Telegram webhook mode used to fail open. With no secret token it logged one warning and then dispatched every update,
callback_querybutton actions included. This PR ports upstream's fail-closed default andupdate_idredelivery dedupe.What changed
TelegramAdapterConfig.allow_unverified_webhooks: bool | None: new field. It resolves fromTELEGRAM_ALLOW_UNVERIFIED_WEBHOOKSonly for the exact string"true". An explicit config value, includingFalse, wins over the env var. A non-boolvalue such as"false"raisesValidationError. Thesecret_tokendocstring now says the secret is required in webhook mode.ValidationErrorformode="webhook"when there is neither a secret nor the opt-out.initialize()raises the same error whenmode="auto"resolves to webhook.handle_webhookreturns 401"Webhook verification required"as its first statement, before it reads the body."Telegram webhook verification is explicitly disabled"and is still logged once._chatcheck passes, each integralupdate_idis claimed withset_if_not_exists("telegram:webhook-update:{sha256(bot_user_id)}:{update_id}", True, 86_400_000).Number.isInteger: an integral JSON float (7.0,7e0) is normalised to7and shares that key.booland fractional floats) is dispatched without a claim, as upstream does._ensure_bot_identity()runs one sharedgetMeas anasyncio.Task.asyncio.shield, so cancelling one webhook doesn't cancel the lookup for the others.getMeis retried on the next webhook.initializecalls it and keeps its warn-on-failure behaviour.secret_tokennow resolves with??semantics (is not None). An explicit""no longer silently falls back to the env secret.docs/SECURITY.md: replaced the "silent skip" limitation with the new required-by-default behaviour.docs/UPSTREAM_SYNC.md: new row that records the Python-surface adaptations and the one deliberate divergence.CHANGELOG.md: added an "Unreleased (4.41 wave)" entry flagged BREAKING (security).Upstream mapping
c4a359e7fix(telegram): require webhook verification by default (vercel/chat#858, chat@4.39.0)initializeerrors, early 401, warning text, claim for every accepted update1d2b78d9Deduplicate repeated Telegram webhook updates (#799, chat@4.38.0)update_idclaim with 24h TTL; duplicate → 200, failure → 5037a1150ceAdd Vercel Connect support to Telegram (#813, chat@4.38.0)ensureBotIdentityand thesha256(bot_user_id)scope; the async token resolver is #189Tests ported (
tests/test_telegram_webhook.py)createTelegramAdapter: requires verification in webhook mode, allows explicit unverified webhook mode, allows polling mode without webhook verification.constructor env var resolution: resolvesallowUnverifiedWebhooksfrom the env var, rejectsTELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS=falsein webhook mode.TelegramAdapter:bot token resolver, scope assertions only, with static tokens: stable bot-identity scope (telegram:webhook-update:{sha256("999")}:1, TTL86_400_000), the scope stays the same across token rotation and instances, and identity resolution is retried on a later webhook.Python-specific tests:
"true"opts out (checked with"1","True","TRUE","yes"," true"and"").Falsewins over env"true".boolallow_unverified_webhooks("false","true",0,1) raisesValidationError.secret_token=""doesn't pick up the env secret.update_idvaluesTrue,False,"1",1.5andNoneare dispatched without a claim.update_idvalues7,7.0and7e0share one claim key, so there is one dispatch.[1]) gets 200 and doesn't raise.getMe.initializein polling mode needs no verification.All state and
telegram_fetchmocks areAsyncMock. The tests don't sleep; they use onlyasyncio.sleep(0)yields and events. The new verification and dedupe test classes clearTELEGRAM_*env vars, so developer shell exports can't flip the results. I checked that theboolguard,asyncio.shield, integral-float, non-bool opt-out and non-object-body tests fail when their guards are removed.Tests that break by design, now updated:
test_telegram_api.py::test_webhook_warns_no_verificationis replaced bytest_webhook_rejects_before_reading_body_without_verification. It expects 401, checks the body is never read, and checks there is no dispatch and no claim. It clears the env fallbacks.mode="webhook", allow_unverified_webhooks=True, as upstreamreplay-telegram.test.tsdoes. It also mocksgetMe(previously it hit the network) and the async claim.handle_webhookcallers useallow_unverified_webhooks=Trueor a mocked claim and scope.packages/adapter-telegram/src/index.test.tsis not fidelity-mapped (#78), so the strict fidelity count is unchanged.Divergences
Python-surface adaptations, recorded in
docs/UPSTREAM_SYNC.md:getMeis a shieldedasyncio.Task.update_idclaiming mirrorsNumber.isInteger.boolis excluded (Python'sboolis anintsubclass), and integral floats are normalised toint.One deliberate divergence: a non-
boolallow_unverified_webhooksraisesValidationError. Upstream's TSbooleantype rules such values out at compile time, andbool("false")would otherwise silently fail open.The earlier
orfallback forsecret_tokenwas itself a divergence; this PR removes it.Out of scope, as the issue says:
_ensure_bot_identity)set_if_not_existsreclaiming expired rows ([4.41/ST1] Postgres state: reclaim expired set_if_not_exists rows, migration-managed schemas #240), which cross-instance dedupe on Postgres depends onConsumer impact
BREAKING (security) for Telegram webhook users without a secret.
mode="webhook"fails at construction.mode="auto"with a registered webhook fails ininitialize().Chat._ensure_initializedpropagates that error and retries it on every webhook, so nothing is processed until the config is fixed; this is the intended fail-closed behaviour. The escape hatch isallow_unverified_webhooks=TrueorTELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS=true. Polling users aren't affected. There's no impact on Slack or Teams.Validation
Passing: ruff check, ruff format --check, audit (0 hard failures), strict fidelity at
chat@4.31.0(732/732), and every Telegram, fixture-replay and critical test (533 passed; they also pass withTELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS=trueexported). The full pytest run gives 5182 passed, 13 skipped and 3 failed. The 3 failures are Teams tests that don't involve Telegram (see Review).Review
Addressed in 0c9032a. Every new test fails against the pre-fix adapter.
Fixed
update_idnot claimed (reported by both reviewers; adapter.py dedupe guard). In JS,Number.isInteger(7.0)is true and${7.0}is"7". The new_integral_update_idhelper normalises7.0and7e0toint, so they share the...:7claim key.boolvalues and fractional floats are still not claimed.1.0moved out of the not-claimed parametrize list (replaced by1.5). New test:test_integral_float_update_ids_share_the_integer_claim_key, which sends7,7.0and7e0: 1 dispatch, 3 claims on the same key. The UPSTREAM_SYNC row wording is corrected.allow_unverified_webhooks="false"fails open (bool()coercion). A non-boolvalue now raisesValidationError. This is recorded as a deliberate Python divergence in UPSTREAM_SYNC and the CHANGELOG. New test:test_non_bool_allow_unverified_webhooks_is_rejected[false|true|0|1].[1]) raisedAttributeErrorfrom theupdate.get(...)call in theprocess_updatefailure log. The log now uses the already-guarded rawupdate_id. New test:test_authenticated_non_object_body_is_acknowledged_without_raising.test_webhook_rejects_before_reading_body_without_verificationnow clearsTELEGRAM_ALLOW_UNVERIFIED_WEBHOOKSandTELEGRAM_WEBHOOK_SECRET_TOKEN. Both new dedupe test classes have an autouse fixture that clearsTELEGRAM_*. The 16 slash-routing tests that are sensitive toTELEGRAM_WEBHOOK_SECRET_TOKENfail the same way onmainand are left alone, to stay in scope.Declined: none.
CI failures not caused by this PR:
test (3.12)andtest (3.13)fail on the same 3 Teams tests, before and after the review commit:TestOutboundServiceUrlRouting::test_edit_message_retargets_real_activities_client:fake_update() got an unexpected keyword argument 'service_url'TestCreateStreamer::test_creates_streamer_for_valid_dm_activityandtest_returns_none_when_create_stream_raises:'App' object has no attribute 'activity_sender'The cause is
microsoft-teams-apps2.1.0 API drift. That version is already pinned inmain'suv.lock, which this PR does not touch. As far as I can find, no open issue tracks this yet.Update: resolved by #251 (Teams SDK capped
<2.1), now merged into this branch; the full suite is green (see Merge gate).Merge gate
Final HEAD:
04f9db1. The branch is merged withorigin/mainat639885a, which includes #251, #235, #234 and #245. #245's log-hygiene work doesn't touch Telegram, and the Telegram logs this PR adds carry metadata only (updateId,error,botUserId,userName).gpt-6-astra review: 5 rounds. Final verdict (round 5, on
04f9db1):Fixed
dc6a5d1: thegetMeusername was lost on re-initialize._ensure_bot_identityreturns early once the scope is cached, so a secondinitialize()(for exampleChat.shutdown()thenChat.initialize()) re-appliedChat.user_nameand never restored the Telegram username. After that,@real_botmentions and/ping@real_botstopped routing. The resolved username is now cached and re-applied. UpstreamensureBotIdentityinchat@4.41.1has the same bug, so the fix is recorded as a deliberate Python divergence indocs/UPSTREAM_SYNC.md. New test:test_reinitialize_keeps_the_get_me_username_over_the_chat_name. It fails without the fix.dc6a5d1:allow_unverified_webhooksshifted positional config arguments. It was inserted as the first dataclass field, soTelegramAdapterConfig(None, "token")bound"token"toapi_base_url. It is now the last field. New test:test_positional_config_arguments_keep_their_pre_opt_out_binding. It fails without the fix.24e74ff:asyncio.ensure_futurefor the sharedgetMetask broke the repo rule in AGENTS.md. It now usesasyncio.get_running_loop().create_task(...), in the adapter and in the identity-waiter tests.24e74ff: clean. Round 4 on the merge of main,7e4e41c: clean. Round 5 on the second merge of main,04f9db1: clean. Neither merge changed Telegram code; the only conflicts were in the CHANGELOG, resolved to one wave heading with all bullets kept.Rebutted: none.
Bot comments: CodeRabbit posted only a "Review limit reached" notice, which it updates on each push. It left no review content and no inline comments. There are no gemini-code-assist comments and no review threads to address.
CI on
04f9db1: all green. Lint & Type Check, test (3.12), test (3.13), Analyze (actions), Analyze (python) and CodeQL pass. The CodeRabbit status also passes, with the note "rate limited". Local full validation is green: ruff check, ruff format --check, the audit (0 hard failures), strict fidelity atchat@4.31.0, and pytest (5289 passed, 13 skipped).Closes #224
Part of #184