Repository navigation
fix(twilio): isolate locks and channels per conversation, restrict media downloads to the API origin (#235) - #246
Conversation
…dia downloads to the API origin (#235) Port upstream 28bc7768 (vercel/chat#849), the Twilio part of b7c9316b (vercel/chat#875) and d8103a10 (vercel/chat#831): - TwilioAdapter.lock_scope is now "thread"; twilio_channel_id validates and returns the full thread id, so recipients texting one bot number no longer share a lock, channel history or channel state. BREAKING for Twilio: channel_id now equals the thread id. - fetch_twilio_media takes api_url / api_base_url and refuses URLs whose (scheme, host, effective port) differ from the configured API origin before resolving credentials or sending a request. The adapter passes its api_url; the Python-only host allowlist stays in front. Part of #184.
|
Warning Review limit reachedNext included review available in 16 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 |
… api_url media breakage (#235) - test_media_origin_rejects_mismatched_and_malformed_urls: add both-sides-opaque cases and http-on-443, killing the dropped-None-guard, dropped-scheme and dropped-host-guard mutants of _url_origin/fetch_twilio_media. - test_non_twilio_api_url_refuses_media_on_every_origin pins the proxy api_url dead end (origin check + host allowlist). - CHANGELOG/UPSTREAM_SYNC: the origin check applies to webhook media too; mark it breaking for custom api_url configs and document the proxy dead end.
# Conflicts: # CHANGELOG.md
…media; upstream now origin-checks)
|
Merge gate: CI green on b53f420 (Lint & Type Check, test (3.12), test (3.13), CodeQL, Analyze (python), Analyze (actions); CodeRabbit pass/rate-limited); origin/main (8f959df, #251) is already an ancestor of HEAD, so no further merge was needed. Local re-validation on b53f420: ruff, format, audit (0 hard failures), fidelity --strict @ chat@4.31.0, pytest 5174 passed / 13 skipped. Local Codex review (gpt-6-astra, xhigh, --base origin/main) on b53f420: PASS: "No actionable regressions found. All 150 Twilio tests and 428 related core tests passed, with 3 skipped; targeted lint and formatting checks also passed." 2 astra rounds (both clean). Bot reviews: CodeRabbit posted only its rate-limit notice; no human or inline reviews outstanding. Merging with --admin (Protect Main requires a code-owner approval). |
Summary
Twilio conversations sharing one bot number are now isolated from each other, and authenticated media downloads are restricted to the configured Twilio API origin. Security fix, and breaking for Twilio:
channel_idvalues change.What changed
twilio/adapter.py):lock_scopechanges from"channel"to"thread". Before this, different recipients texting the same bot number serialized behind each other's handlers, and under the defaultdropstrategy the later one raisedLockError.twilio/thread.py):twilio_channel_id(thread_id)validates throughdecode_twilio_thread_idand returns the thread id unchanged.channel_id_from_thread_id,fetch_thread().channel_idandthread.channel.idall move fromtwilio:{sender}to the fulltwilio:{sender}:{recipient}id. Becausechannel_id == thread_id,persist_thread_historyno longer appends a second copy of each inbound message to a shared channel list.twilio/api.py):fetch_twilio_media(url, *, api_url=None, api_base_url=None, ...)compares(scheme, lowercased host, effective port)againstapi_url→api_base_url→DEFAULT_API_URL, usingis not Nonefallbacks. On a mismatch it raisesTwilioApiError("Twilio media URL must match the configured Twilio API origin", status=0)before any credential is resolved or request sent. The adapter's downloader passesapi_url=self._api_url, so every attachment download, both fresh webhookMediaUrlNand rehydrated, is constrained to that origin (upstreamindex.tspassesapiUrlfor both as well)._is_trusted_twilio_media_url) stays in front as defence in depth, as the issue recommended. Both checks must pass before Basic auth is sent.TwilioAdapterConfig.api_urlcomment notes that it is also the trusted media origin (upstream made the same docs change).Upstream mapping
28bc7768fix(twilio): isolate message locks by conversation (vercel/chat#849)lock_scope = "thread"+ concurrency regression testb7c9316bfix(chat): tighten conversation boundaries (vercel/chat#875), Twilio part onlytwilio_channel_idreturns the thread id. The rest of the commit is #195 / #194 / #229 / #230d8103a10fix(twilio): restrict authenticated media downloads (vercel/chat#831)fetch_twilio_media+ adapter wiringTests ported
Twilio test files are not fidelity-mapped, so these were ported by hand:
packages/adapter-twilio/src/index.test.ts"uses the full DM thread id as its channel id" →
TestThreadIds::test_uses_the_full_dm_thread_id_as_its_channel_id. The old channel assertion was removed fromtest_encodes_and_decodes_phone_and_channel_address_thread_ids."isolates concurrent recipients with thread-scoped locks" →
TestThreadIds::test_isolates_concurrent_recipients_with_thread_scoped_locks. It uses a realChatwithcreate_mock_state(), the direct port of upstreamcreateMockState. The first handler is held on anasyncio.Eventwith no sleeps. The test asserts:acquire_lock;A Python-specific addition asserts that persisted history is per conversation and that no shared
twilio:{sender}channel list is written."rejects rehydrated media from an untrusted origin" → the existing
test_media_downloader_refuses_untrusted_hostswas renamed toTestRehydrateAttachment::test_rejects_rehydrated_media_from_an_untrusted_originand parametrized to cover both layers. A non-Twilio host is refused by the allowlist (ValidationError). A Twilio-owned host that is not the API origin is refused by the origin check (TwilioApiError).packages/adapter-twilio/src/api/index.test.tsTestFetchTwilioMedia::test_fetches_media_from_the_configured_regional_api_originTestFetchTwilioMedia::test_rejects_media_outside_the_configured_api_origin. It covers upstream's 3 cases, usesAsyncMockcredential resolvers and transport, and asserts that none of them was awaited and thatstatus == 0.api_urlwiring: a regional origin is accepted andapi.twilio.comis refused;:443, a mixed-case host, andapi_urloverapi_base_url;channel_id_from_thread_idstill rejects malformed ids;lock_scope == "thread"andfetch_thread().channel_id.A mutation check against the pre-change source shows that the new tests fail on the old behavior. The second recipient raises
LockError, and the channel/api_url/metadata assertions fail.Verify first: redirect credential forwarding
api.twilio.commedia answers with a redirect to a pre-signed CDN URL. The adapter's shared-session aiohttp transport follows it. aiohttp drops theAuthorizationheader when the redirect changes origin, so Basic auth is not forwarded to the CDN. I checkedclient.pyin aiohttp 3.9.0 (our>=3.9floor) and in the locked 3.14.3. This PR only documents the finding (indocs/UPSTREAM_SYNC.md); redirect and byte-cap policy belongs to #204.Divergences
No new rows in the non-parity table.
rehydrate_attachmentallowlist row is updated to say so. With the origin check in place, the allowlist only adds a refusal whenapi_urlis a non-Twilio host.*.twiliocdn.comcan no longer be reached, because it is never the API origin.urlsplitdoes not match it exactly, so failing closed avoids a parser differential. The row records this.docs/UPSTREAM_SYNC.mdhas a new "Twilio conversation boundaries" subsection.Consumer impact
channel_id/thread.channel.idnow equal the thread id. Channel-scoped state and channel-history keys written under the oldtwilio:{sender}id are no longer read (they are orphaned, as upstream intends). Thread ids and thread history are unchanged, andchannel_nameis still the sender number. Concurrent conversations stop blocking or dropping each other.api_urlonly): every media URL, inbound webhook media included, must be on the configuredapi_urlorigin (defaulthttps://api.twilio.com). A regionalapi_urlrefuses inbound media onapi.twilio.com, as upstream does. With a non-Twilio orhttpapi_url(a proxy or mock), no media URL passes both layers, so attachment downloads always raise. The default config is unaffected.Validation
ruff check✅ ·ruff format --check✅ ·audit_test_quality.py✅ (0 hard failures) ·verify_test_fidelity.py --strict@ chat@4.31.0 ✅ 732/732 ·pytest✅ 5168 passed, 13 skipped (baseline 5147).Non-strict fidelity against chat@4.41.1: 705/823 matched, 118 missing. This PR doesn't change that, because the Twilio test files aren't mapped.
Environment note:
uv.lockis gitignored, and a freshuv syncnow resolvesmicrosoft-teams-*2.1.0. That version removedApp.activity_sender, which makes 3 Teams tests fail. The failures are unrelated to this PR and appear onmainwith a fresh resolve. The run above used the baselinemicrosoft-teams-*==2.0.13.4. Any CI run that resolves the Teams SDK fresh may hit the same failures; that needs a separate fix (a pin or a compat shim).Closes #235
Part of #184
Review
Fixed in c37de73:
media_origin is Noneguard (api.py, minor). Confirmed: removing the guard passed every test. Added both-sides-opaque cases totest_media_origin_rejects_mismatched_and_malformed_urls:("/2010-04-01/media/photo", "api.dublin.ie1.twilio.com"),("", "")and("/m", "/base"). The mutant now fails 3 tests._url_originmutants (tests/test_twilio_api.py, minor). Confirmed both. Addedhttp://api.twilio.com:443/m, so dropping the scheme from the origin tuple now fails 1 test. The host-less pairs above cover the deletedif not scheme or not hostguard, which now fails 3 tests.CHANGELOG.md, minor). Confirmed. WebhookMediaUrlNgoes through the same_make_media_downloader, so the check covers fresh inbound media as well as rehydrated media (parity with upstream). The CHANGELOG entry now says this, is marked breaking (Twilio, customapi_urlonly), and spells out the proxy/httpapi_urldead end. The UPSTREAM_SYNC row and the Consumer impact section above now say the same. The newTestRehydrateAttachment::test_non_twilio_api_url_refuses_media_on_every_originpins the dead end.Declined: none.
Validation after the fixes: ruff, format, audit (0 hard failures) and fidelity
--strict(732/732) all pass.pytestgives 5171 passed and 3 failed. All 3 failures are the Teams SDK 2.1.0 failures from the environment note above, and they also make CItest (3.12)/test (3.13)fail. They are outside this PR.Merge gate
Final HEAD:
b53f420. It mergesorigin/main, which brings in #251 (Teams SDK<2.1). The only conflict was inCHANGELOG.md, and it was resolved by keeping one## Unreleased (4.41 wave)heading with the bullets from both sides. The 3 Teams failures from the environment note above no longer occur: #251 caps the SDK at 2.0.16.gpt-6-astra review: 2 rounds.
dd6525e, the merge commit): "No actionable regressions found. Twilio and related chat, channel, thread, and history tests passed (570 passed, 3 skipped); targeted lint, formatting, type checking, and test-quality checks also passed."b53f420, final HEAD): "No actionable regressions found. All 150 Twilio tests and 428 related core tests passed, with 3 skipped; targeted lint and formatting checks also passed."Fixed in
b53f420. These are comment-only changes that I found in self-review, not reviewer findings. Behavior is unchanged.TwilioAdapterConfig.api_urlcomment (types.py): it said only rehydrated media is origin-checked. Fresh webhookMediaUrlNgoes through the same downloader, so the comment now names both._TRUSTED_MEDIA_HOSTScomment (adapter.py): it said "upstream fetches blindly", which stopped being true withd8103a10. It now describes the allowlist as a Python-only layer in front of upstream's exact-origin check.Rebutted: none.
Bot comments: CodeRabbit posted only its "Review limit reached" rate-limit notice, with no review content. gemini-code-assist did not post. There were no inline review comments and no reviews to address.
CI on
b53f420: all green. That coversLint & Type Check,test (3.12),test (3.13),CodeQL,Analyze (python)andAnalyze (actions). The CodeRabbit status is "pass (rate limited)".Local validation on
b53f420:ruff check✅ ·ruff format --check✅ ·audit_test_quality.py✅ (0 hard failures) ·verify_test_fidelity.py --strict@ chat@4.31.0 ✅ ·pytest✅ 5174 passed, 13 skipped (Teams SDK 2.0.16).