Skip to content

fix(whatsapp): allow Twilio's media CDN as a validated redirect target (#1932) - #1937

Merged
vybe merged 8 commits into
devfrom
vybe/issue-1932
Aug 3, 2026
Merged

vybe merged 8 commits into
devfrom
vybe/issue-1932

Conversation

@vybe

@vybe vybe commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #1932

What was broken

Twilio never serves inbound WhatsApp media from api.twilio.com directly — it 302s every attachment to its media CDN (mms.twiliocdn.com). The SSRF allowlist only permitted *.twilio.com, so download_file refused the redirect and returned None.

Every inbound WhatsApp attachment has failed since #463 (2026-04-23) — roughly three months on a feature that looked shipped. The pre-existing WARNING for the refusal surfaced nothing in that entire window (which is why it is now ERROR).

The fix — a two-tier allowlist, deliberately narrower than the AC asked for

The AC said "widen the allowlist". Widening the single existing constant would also have widened the credentialed hop — the request that carries the tenant's AccountSid:AuthToken as HTTP Basic auth. Three independent reviewers (/autoplan strategy, /autoplan security, and the Gemini second voice) called that ~3× wider than the bug. So the constant splits:

Constant Value Gates
_TWILIO_MEDIA_SOURCE_HOST_SUFFIXES .twilio.com webhook MediaUrl{N} parse gate + credentialed hop 1
_TWILIO_MEDIA_ALLOWED_HOST_SUFFIXES .twilio.com, .twiliocdn.com validated 30x redirect targets only (unauthenticated)

AC #2 is satisfied literally — the AC-named constant _TWILIO_MEDIA_ALLOWED_HOST_SUFFIXES still receives .twiliocdn.com. The split only means the credentialed blast radius stays exactly as narrow as it was before this PR. Verified narrow by grep: exactly three call sites — :310 (parse) and :724 (hop 1) use the narrow predicate; only :759 (redirect target) uses the wide one. test_allowlist_constants_are_the_documented_tiers pins SOURCE ⊆ ALLOWED.

Also in the diff, each a direct consequence of making this path reachable for the first time:

  • follow_redirects=False stays — every hop is re-validated before it is issued, nothing is blind-followed. Single-follow became a bounded budget (_MAX_MEDIA_REDIRECTS = 3) so a future Twilio 302→302 chain degrades to "still works" instead of silently reproducing this exact bug.
  • The redirect hop carries no auth. The signed CDN URL has its own credentials; forwarding the tenant AuthToken to a CDN host would widen the credential's reach.
  • Transport size cap (16 MB) — Telegram (20 MB) and Slack (10 MB) both cap; WhatsApp's read was unbounded, and this fix makes that unbounded read reachable for the first time.
  • Exception-log hygienetype(e).__name__ only. Some httpx exceptions (UnsupportedProtocol, InvalidURL) embed the URL, and the signed CDN URL is a live ~4-hour bearer capability to the media.

⚠️ AC #1 is NOT met by this PR — read this before verifying

Two reasons, and the second one matters more:

(a) No live-sender verification was possible in this run. It needs a real Twilio account with HTTP Basic Authentication for media enabled, a publicly reachable webhook URL, and a provisioned WhatsApp sender.

(b) The AC's premise is partly wrong. services/upload_service.py:45-49 UNSUPPORTED_MIMES rejects application/pdf and audio/* for every channel, enforced channel-agnostically at upload_service.py:177. So of the AC's three attachment types, only the image can actually land as bytes.

A PDF or a voice note will now be fetched successfully by this fix and then surface as "unsupported format" — that is a separate, deliberate policy gate, not a regression from this PR. Expanding scope into UNSUPPORTED_MIMES was explicitly rejected as out of fence.

How a human should verify on a deployed instance:

  1. Send the agent an image. It should reach the workspace, and the backend log should contain no Refusing off-domain media redirect warning.
  2. If you also send a PDF or a voice note: reading "unsupported format" rather than "download failed" is the expected, improved outcome. That is the fix working. Please do not file it as a regression.

*.amazonaws.com was rejected — and it is worse than it looks

Accounts without media auth enabled get redirected to s3-external-1.amazonaws.com. That host is path-style (/<any-bucket>/<key>), so allowlisting it — even as a single exact hostname — admits arbitrary attacker-controlled content under an allowlisted name. That is a bypass primitive, not a widening.

The supported configuration is therefore media-auth-enabled accounts. An account without it will still fail — with the Refusing off-domain media redirect to host=s3-external-1.amazonaws.com log line as the diagnostic, and a new operator-facing troubleshooting table in the user docs that names exactly this case and how to fix it in the Twilio Console.

This is test-pinned, not prose: test_s3_redirect_refused.

Env override: NO — recorded decision (AC #4)

Rationale, recorded as a comment at the constant so it survives without this PR body:

  • Trinity has zero env-driven outbound-fetch / SSRF allowlists. (EXTRA_CORS_ORIGINS is inbound CORS, not an outbound fetch gate; telegram_media.ALLOWED_DOWNLOAD_HOST and url_validation's github.com lock are both hard-coded; base_image_allowlist is an admin DB setting with an audit trail.)
  • This gate protects a fetch carrying a tenant's credential, while env is a platform-scope control. An env knob would let one operator-level actor redirect every tenant's AuthToken.
  • If an escape hatch is ever genuinely needed, the correct shape is the admin-DB-setting-with-audit pattern (agent_service/helpers.py::validate_base_image) — never env.

Anti-vacuity evidence (this is what makes the suite trustworthy)

download_file returns None on every failure path, so a negative test passes even against a completely unpatched seam. Every negative test therefore asserts the recorded call log / hop count, not just is None. Four deliberate breaks were introduced and all four were detected (tree restored clean after each):

Break Result
A — fixture: httpx.Headers → plain dict 7 red. Decisively: test_non_200_after_redirect_returns_none and test_redirect_budget_exhausted went red purely on hop count — captured output shows assert result is None passing, then assert len(calls) == 2assert 1 == 2. Proof the suite is not carried by the all-paths-return-None trap.
B — product: revert the widening 12 red across both files
C — credential: re-attach auth= to the redirect hop exactly 2 red
D — ordering: move the allowlist check after the fetch 7 red — the security ordering is pinned, not incidental

A TestInboundMediaHarnessIntegrity class fails loudly if the DB seam or the httpx.Headers fake ever regresses.

/cso CLEAN with adversarial evidence: a 37-vector corpus against the widened tier with a urlparsehttpx.URL cross-check found no bypass and no exploitable differential (the single disagreement — a leading-space URL — is unreachable from a parsed HTTP header and fails closed). Multi-Location join measured on real httpx.Headers: attacker-first → denied; twiliocdn-first → httpx still resolves host='mms.twiliocdn.com', so no attacker host is ever contacted.

A second-voice claim was tested and refuted, twice, by two different workers: the alleged hop-1 cookie leak to the CDN does not occur — with a hop-1 Set-Cookie: Domain=.twilio.com, hop 2 carries authorization=None and cookie=None (httpx enforces RFC 6265 domain matching).

AC #3 — the #463 spoof tests

The six #463 spoof tests are byte-identical to origin/dev (verified by per-function diff — all six IDENTICAL), node-ids unchanged. _is_twilio_media_url's name and signature are preserved. _host_matches_suffixes takes suffixes as a required positional — no permissive default that a future call site could silently inherit.

CDN-shaped spoof vectors added on top (mms.twiliocdn.com@evil.com, mms.twiliocdn.com.evil.com, and the scheme/userinfo family).


Accepted residuals — named so none of them reads as an oversight

  • No private-IP / DNS-rebinding resolution check. The allowlist is name-based; a hostname under an allowlisted suffix that resolves to a private address is not caught.
  • Port is unconstrained in the matcher — and that now spans a second registrable domain.
  • Worst-case unauthenticated fetches per attachment go 2 → 4 (the bounded budget). All targets are allowlisted, and _process_update is a background task, not a request hold.
  • Files > 16 MB now read "download failed" instead of "exceeds 10 MB limit." The transport cap sits above upload_service's policy caps on purpose so the policy layer keeps producing its honest message; 16 MB is itself above Twilio's own 16 MB WhatsApp media ceiling, so this is the pathological case only.
  • Inherited, not introduced: _host_matches_suffixes keeps the pre-existing host == s.lstrip(".") or host.endswith(s) shape, so the widened tier also admits bare twiliocdn.com. It is the line the two-tier split rests on.

Pre-merge operator check (2 minutes)

Confirmation, not a blocker — the bounded budget already covers a chain. Please run against a real account and paste the output:

curl -sI -u "$ACCOUNT_SID:$AUTH_TOKEN" \
  "https://api.twilio.com/2010-04-01/Accounts/$SID/Messages/$MSG/Media/$ME"

Then follow the Location once. This confirms the redirect target host and that hop 2 answers 200.


Corrections to other issues (recorded here as text; no comments were posted)

#1933 relationship

Unblocked — but only for media-auth-enabled accounts. download_file's signature and its None-on-failure contract are byte-stable (verified by diff), so #1933 can build on it unchanged.

Correction (review round 2). The original wording here said "_maybe_transcribe_voice runs at message_router.py:406, before the upload path, so the transcription route does benefit from this fix." That is wrong for WhatsApp. _maybe_transcribe_voice opens with if channel != "telegram": return message and calls telegram_media.process_voice(bot_token, raw_msg["voice"]) — a Telegram-Bot-API fetch that never touches adapter.download_file. There is no WhatsApp transcription route today, so a WhatsApp voice note is now fetched successfully and then rejected as "— unsupported format", full stop. #1933 therefore has to build that route; it does not inherit one. The claim is corrected in feature-flows/whatsapp-integration.md in this branch.

NEW security finding — follow-up recommendation (deliberately not filed)

Slack's download_file has no SSRF allowlist at all. slack_service.py:739 fetches an inbound-event URL with follow_redirects=True (:754) — no allowlist, no scheme check. Same bug class as this issue, different channel.

Deliberately not widened into this PR. The eventual shape is a shared channel_media_fetch(url, allowlist, auth, max_bytes) covering WhatsApp / Telegram / Slack uniformly.

Other follow-ups (listed, not filed)

  1. Private-IP / DNS-rebinding resolution check + a true streaming size bound for the WhatsApp fetch (the unconstrained-port item folds in here).
  2. The UNSUPPORTED_MIMES residual — if PDFs should reach agent workspaces, that is a deliberate policy change, not a bug fix.
  3. Repo-level: fastapi>=0.115.0 is a floor, not a pin (see Verification below).

Verification — stated honestly

/verify-local ran with --skip-agent. This was forced by the host: the operator's live dev stack owns the global trinity-agent-network, so the full agent stage hard-refuses. This is a host-environment constraint, not a passing agent stage. The skipped stages (3: import agent_server inside the built base image; 5: real agent /health) cover no surface this PR touches — the diff contains no docker/base-image/ file and no new backend module.

WAVE-4 result:

Stage Result
Build + import smoke pass (18s)
Boot + health pass (13s)
Integration 70 passed, 13 skipped, 2 deselected
Unit (full suite) 1 failed, 6054 passed, 18 skipped, 1 xfailed

The single red is proven pre-existing, not assumed. tests/unit/test_1069_voip_call_path_param.py::TestVoipCallPathParam::test_flat_path_params_are_agent_name_not_name was run from a pristine git archive origin/dev @ 8e924526 extracted into a temp tree, using the same verify venv via the harness's own invocation → identical ImportError: cannot import name 'get_flat_dependant'. The file is byte-identical across this branch and dev.

Repo-level root cause: fastapi>=0.115.0 is a floor, not a pin, so any fresh venv now resolves 0.141.1, where get_flat_dependant no longer exists. At the prod pin fastapi==0.115.6 the file is 4 passed. This will red for every contributor building a clean environment and deserves its own issue — it must not be attributed to this PR.

Targeted suite, re-derived first-hand at ship time (explicit collected counts; no -m unit anywhere — that flag silently deselects: measured 65 collected / 42 deselected / 23 selected):

pytest tests/unit/test_whatsapp_inbound_media.py \
       tests/test_whatsapp_adapter.py \
       tests/unit/test_whatsapp_outbound_media.py -q
→ 148 collected, 148 passed, 0 deselected   (23 + 83 + 42)

pytest tests/unit/test_whatsapp_inbound_media.py tests/test_whatsapp_adapter.py -q -p no:randomly
→ 106 passed   (seed-independent)

Notes for the reviewer

  • The HMAC citation in the new code comment is symbol-anchored on purpose. Line numbers in transports/twilio_webhook.py drift; the comment names TwilioWebhookTransport.handle_webhook and RequestValidator(...).validate(...) instead. Please do not "restore" line numbers there.
  • The user-doc troubleshooting table is the operator-facing complement to the type(e).__name__ log narrowing — the narrowed log is deliberately less informative, and the table is what makes it actionable.
  • Uncommitted by design: the .claude/agents/test-runner.md catalog sync is intentionally left uncommitted (it lives in the private trinity-dev submodule) and needs a separate commit there. It is not part of this PR.
  • Merge order: this PR touches neither docs/memory/learnings.md nor tests/registry.json, so it has no conflict with the sibling PRs in this batch and can merge in any order relative to them.

🤖 Generated with Claude Code


Review round 2 (2026-08-03) — merge + reviewer follow-ups

Rebased onto dev and applied the reviewer's pre-merge item. Head is now 33c28d64; PR is MERGEABLE again.

Merged origin/dev (c55ff804). One conflict, in docs/memory/feature-flows.md — both sides prepended a row to the Recent Updates table (#1932 here, #1931 on dev). Both rows kept, #1932 above #1931. whatsapp_adapter.py and both test files were untouched by the merge (verified by empty git diff HEAD -- <paths>), and the #1932 architecture.md line survived the auto-merge intact.

F1 applied — both SOURCE-tier gates now log at ERROR (33c28d64). The reviewer's framing is right and worth restating: #1932's root cause was never the allowlist, it was a fail-closed gate reporting a 100% outage at a level nobody reads. Raising only the redirect gate left the stricter half — the one the two-tier split newly made most likely to break on the next vendor change — guarded at WARNING.

Not left as prose, because the level is a one-word revert with a three-month observed detection latency. New TestSourceTierRejectionsAreVisible pins both, and is mutation-proven: reverting either logger.error REDs the pair on assert {30} == {40}, and both go green on restore.

Both stale doc claims fixed. feature-flows/whatsapp-integration.md:17 and requirements/public-access.md:309 described inbound media as "images/audio/PDFs"; upload_service.UNSUPPORTED_MIMES rejects application/pdf, audio/, video/ and archives channel-agnostically, so only images (plus text/CSV/JSON) reach a workspace. Chasing that down is what surfaced the _maybe_transcribe_voice error corrected above.

Vendor evidence folded into the user doc. Media auth has been mandatory on every main account created since 2023-07-31 (Twilio changelog, linked in the doc), so the S3 residual reaches only pre-July-2023 grandfathered accounts. The prerequisite now opens with "most accounts already have this and need no action" instead of "opt-in and off by default" — materially less alarming, and accurate.

Verification

Check Result
test_whatsapp_inbound_media.py + test_whatsapp_adapter.py (-p no:randomly) 108 passed (106 + 2 new)
Same two, random ordering 108 passed (seed-independent)
+ test_whatsapp_outbound_media.py 150 passed
Full tests/unit (-m "not slow", -p no:randomly) 5897 passed / 18 skipped / 0 failed
Mutation: revert either SOURCE level → WARNING 2 red on {30} != {40}; green on restore

The 8 tests/unit/test_1771*.py property files are excluded from that count — they fail collection with ModuleNotFoundError: No module named 'hypothesis' on this host's interpreter (system Python 3.14, no venv). No 1771 file is touched by this branch. Note test_1069_voip_call_path_param.py passed in this run, so the fastapi floor-drift red reported in the original body is environment-dependent, not a property of the branch.

Still open — both yours, neither needs a code change

Eugene Vyborov and others added 6 commits August 1, 2026 23:05
#1932)

Inbound WhatsApp media has never worked. Twilio serves message media from
mms.twiliocdn.com, reached via a 302 from api.twilio.com, but the SSRF
allowlist accepted only the .twilio.com suffix — so every attachment was
refused at the redirect gate and download_file returned None (since #463).

Widen only the gate that had the bug. The allowlist is now two tiers:

  _TWILIO_MEDIA_SOURCE_HOST_SUFFIXES  (*.twilio.com) — webhook MediaUrl{N}
      parse gate + hop 1, the hop that carries the tenant's Basic auth
  _TWILIO_MEDIA_ALLOWED_HOST_SUFFIXES (+ *.twiliocdn.com) — validated
      redirect targets only, always unauthenticated

The credentialed hop stays exactly as narrow as before, so the widening
doesn't depend on the webhook HMAC gate holding forever. s3-external-1
.amazonaws.com (the target for accounts without media auth) stays out: it
is path-style, so allowlisting it admits arbitrary buckets under an
allowlisted host.

Also: follow_redirects=False stays and every hop is still re-validated, but
the single manual follow becomes a bounded budget (_MAX_MEDIA_REDIRECTS=3)
so a future 302→302 chain degrades to "still works"; a transport size cap
(_WA_MEDIA_DOWNLOAD_MAX_BYTES, parity with Telegram/Slack); the off-domain
refusal logs at ERROR; and the generic handler logs the exception type only
(some httpx exceptions embed the URL, and the signed CDN URL is a ~4h
bearer capability).

download_file's signature and its None-on-failure contract are unchanged
(#1933 depends on them).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1932)

New tests/unit/test_whatsapp_inbound_media.py — all download_file coverage
lives here, NOT in tests/test_whatsapp_adapter.py whose module-level
`db = MagicMock()` makes the credentials guard pass vacuously.

download_file returns None on every failure path and its bare
`except Exception` swallows AssertionError, so `result is None` alone is a
vacuous assertion. Every test here asserts the recorded (url, auth) call
list — a broken harness (unpatched db seam, capital-L Location key, assert
inside the fake) shows up as a wrong hop count instead of a green pass.

TDD evidence: against the un-widened constant 6 of the 23 fail, including
the AC #5 headline test_redirect_to_cdn_returns_bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1932)

Extends TestTwilioMediaUrlAllowlist in place. The six #463 spoof tests keep
byte-identical bodies and node-ids — the only removed line in this file is
the class docstring, which claimed "only *.twilio.com hosts allowed" and is
now false.

Adds: mms./media.twiliocdn.com accepted, bare apex, case-insensitivity; and
the CDN-shaped spoofs — eviltwiliocdn.com, twiliocdn.com.evil.com, non-https,
userinfo @, trailing-dot FQDN, punycode lookalike, scheme-relative. Pins D2
(S3 refused, path-style rationale in the docstring) and D3 (the source
predicate excludes the CDN, and inherits every spoof guard). Pins both named
constants, incl. source ⊆ allowed.

Also pins the parse gate at the narrow tier: a twiliocdn MediaUrl0 off the
wire yields msg.files == [].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- architecture.md: the catalog entry claimed a single `*.twilio.com` gate;
  name both tiers in one clause.
- feature-flows/whatsapp-integration.md: rewrite "SSRF defense on media
  downloads" — the actual two-hop chain, the two-tier allowlist table, the
  bounded follow budget, the no-env-override decision and why, the S3
  exclusion and why, and the transport size cap. Status line notes #1932.
- requirements/public-access.md: ":338" asserted "*.twilio.com only, no
  redirects to other hosts" — after this change BOTH clauses are false, and
  it is a live security claim, so it is corrected rather than left standing.
  ":309" goes stale the same way.
- user-docs/integrations/whatsapp-integration.md: the mitigation the
  no-env-override decision actually leans on. Prerequisites gains the
  opt-in Twilio Console setting inbound media requires; Troubleshooting
  gains "Inbound images arrive as — download failed" (with the exact log
  line and what each host means) and distinguishes it from the separate
  "unsupported format" policy rejection.

docs/security-reports/ is untouched — historical point-in-time report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/sync-feature-flows: the only changed code file is
src/backend/adapters/whatsapp_adapter.py, which maps to the existing
whatsapp-integration.md flow (already rewritten in the previous commit).
No new flow doc — this is a bug fix inside an existing adapter, not a new
feature. Adds the Recent Updates row, matching the fix(...) rows that
already point at existing flow docs (#1445, #1444, #903).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…line (#1932)

/review follow-ups on the #1932 SSRF fix. No behaviour change.

1. The two-tier constant comment cited `transports/twilio_webhook.py:123-128`
   and `:140-142` for the HMAC gate its security reasoning leans on. Both were
   accurate at 8e92452, but that file is live and the citation is exactly the
   kind a future reader must actually follow to re-verify the argument. Anchored
   on symbols instead (`TwilioWebhookTransport.handle_webhook`,
   `RequestValidator(...).validate(...)`, `raw_event = dict(params)`), which
   survives line drift.

2. The user-doc troubleshooting entry enumerated three of the media log lines an
   operator can hit and missed two — including the generic handler, which is now
   the LEAST self-explanatory path: narrowing it to `type(e).__name__` (so a
   signed CDN URL embedded in an httpx message can never reach the log) collapses
   DNS, TLS and connection-refused into a bare `ConnectError`. Replaced the prose
   with a table covering all five, and said plainly why the type name is all
   there is, so nobody reads it as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review August 2, 2026 11:56

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran /review + /validate-pr + /cso --diff against 8946bf70.

Verdict: 0 critical, 0 blocking security findings. Not approving yet — only because AC #1's operator check hasn't run and AC #9 is still open. Both are yours to close; neither needs a code change. Details below, plus one two-word fix I'd take before merge and some vendor evidence that materially de-risks AC #1.

The diff is genuinely clean and I'd rather say so than manufacture objections. The two-tier split is the right call: the credentialed hop's blast radius is byte-for-byte what it was, and only the unauthenticated redirect-target tier widened.


What I verified independently (rather than trusting the PR body)

Anti-vacuity — reproduced first-hand. Since download_file returns None on every path, I re-ran two of your deliberate breaks rather than taking the table on trust:

Break Your claim My result
B — revert the widening 12 red across both files 12 red
C — re-attach auth= to the redirect hop exactly 2 red exactly 2 red

Tree restored clean after each (git diff --stat empty). The hop-count assertion discipline does what you say it does.

35-vector urlparsehttpx.URL differential corpus → 0 exploitable bypasses. I scored by parser disagreement (matcher reads host X, socket goes to host Y), since that's the only place a real bypass can hide. Three vectors disagree; all three fail closed — httpx raises InvalidURL or yields an empty host before any connection, and the raise lands inside your tryreturn None:

Vector urlparse host httpx Outcome
https://mms.twiliocdn\t.com/x mms.twiliocdn.com (allowed) InvalidURL no request
https://mms.twiliocdn.com/x (full-width m) mms.twiliocdn.com (allowed) InvalidURL no request
␣https://mms.twiliocdn.com/x mms.twiliocdn.com (allowed) empty host no request

You named the leading-space case. The tab and full-width cases are two you didn't — both land in the same safe direction, and all three are unreachable from a parsed Location header anyway.

Correctly refused: prefix-not-suffix, suffix-append, userinfo (@ and %40), backslash authority, CR/LF/NUL, Unicode dot variants (U+3002 / U+FF0E / U+FF61 in both positions), Cyrillic homoglyph (IDNA → mms.xn--twilicdn-rbh.com), Turkish dotted-İ, trailing-dot FQDN, http://, IP literals incl. link-local metadata and [::1], query/fragment decoys, and both S3 forms.

Multi-Location re-measured on real httpx.Headers: attacker-first → denied; CDN-first → httpx still resolves mms.twiliocdn.com, trailing attacker URL degrades to path bytes. No attacker host is ever contacted. Matches your finding.

The newly-live downstream. This is the angle I'd flag as most under-examined, and it holds up. The fix resurrects a path dead since #463, so nothing after download_file has been exercised in prod for three months. Traced it:

  • upload_service.py:190actual_size = len(data), declared size explicitly advisory. This one matters: whatsapp_adapter.py:327 hardcodes size=0, so a declared-size-trusting sink would have had no effective size limit. It doesn't.
  • sanitize_filename() on every branch; [^\w.\-()] strips separators, so a malformed MediaContentType can't traverse via the synthesized media_{i}.{ext}.
  • Magic-byte MIME cross-check rejects declared/detected mismatches outside the image↔image and text↔text families.

AC #1 — better than "unverifiable"

You flagged this as resting on one empirical vendor claim. It's actually vendor-documented:

"Requests to fetch your media will redirect you to a secure URL that is only valid for 4 hours… The domain of the unsecured media URL is s3-external-1.amazonaws.com and secured media URL is mms.twiliocdn.com."
Twilio: Protect Media Access with HTTP Basic Authentication

One line confirming both the fix and the S3 exclusion. And the July 2023 changelog adds the part I'd fold into the docs:

"Newly-created main accounts will have HTTP Basic Authentication enabled without the option to disable it." (effective 2023-07-31)

So media-auth isn't just "the supported configuration" — it's mandatory for every account created in the last three years. The S3 residual only reaches accounts grandfathered before July 2023. That's a stronger position than the PR body currently claims, and worth stating that way in the user doc.

Also worth noting: a 2016 report of Twilio's chain landing on media.twiliocdn.com.s3-external-1.amazonaws.com in two redirects — real-world evidence that your bounded budget (rather than a single follow) was the right call. That host is correctly refused by the matcher (prefix, not suffix); I tested it.

Still run the curl — the docs establish Twilio's behaviour, not Trinity's end-to-end delivery. Cheaper alternative: send a PDF. If the placeholder flips from — download failed to — unsupported format, the redirect chain provably worked and bytes were returned (upload_service.py:166 vs :174). Your PR body frames the PDF case defensively — it's actually the crispest cheap end-to-end signal available.


One thing I'd fix before merge

The fix leaves its own root cause half-repaired. #1932's root cause wasn't the allowlist — it was that a fail-closed security gate logged its rejection at WARNING, hiding a 100% outage for three months. This PR raises the redirect gate to logger.error (:764), but both SOURCE-tier gates stay at WARNING:

:314   if not _is_twilio_media_source_url(media_url):
:315       logger.warning("[WHATSAPP] Rejecting non-Twilio media URL at parse time: %s", ...)

:728   if not _is_twilio_media_source_url(file.url):
:729       logger.warning("[WHATSAPP] Refusing to download non-Twilio media URL (host=%s)", ...)

Before the split these shared one constant and couldn't diverge. The two-tier design makes MediaUrl{N} strictly stricter than the redirect target — a new asymmetry, guarded by the quieter level. If Twilio ever puts a CDN-form URL in MediaUrl{N} (exactly the class of vendor change that caused #1932), every attachment fails again at WARNING. Empirical detection latency for that signature: three months.

8946bf70's message says it covers "every media log line" — these two are the exceptions. Two-word change, no behaviour change.

AC #9 is still open

#1315 is CLOSED with 2 comments (/claim + the bot); no correction posted. The AC says "a comment on the closed issue is enough" — the correction currently lives only in a PR body that nobody reading #1315 will encounter, which is the information loss the AC existed to prevent. Same for #468 item 3d.

On the Slack finding

Confirmed at slack_service.py:739. Agree it's out of scope here, and I'd keep the severity honest — the URL comes from an HMAC-verified Slack event and httpx strips Authorization cross-origin, so the bot token doesn't travel. But the trend is the argument for filing it:

Audit Cited as Status
cso-2026-06-21.md:159 slack_service.py:639 VERIFIED
cso-2026-07-13.md:31 slack_service.py:702 PERSISTENT
today slack_service.py:739 PERSISTENT — 3rd cycle

Six weeks, three audits, no tracker entry — and the citation is drifting as the file grows. A finding that lives only in dated report files is losing its own address.

One footnote I found bleakly instructive: the 2026-06-21 report cited the WhatsApp adapter as the good example Slack should copy — "unlike the Telegram and WhatsApp adapters that validate host." At that moment this allowlist was rejecting 100% of legitimate media. "Allowlist present" scored as correct without anyone checking it admitted the legitimate host.


Minor

  • Stale claim, two places. feature-flows/whatsapp-integration.md:17 and requirements/public-access.md:309 both still summarise inbound as "images/audio/PDFs". You corrected the SSRF clause on :309 but left the media-types clause your own analysis contradicts (upload_service.py:45-49 rejects audio/ and application/pdf).
  • 16 MB cap doesn't bound memorylen(resp.content) runs post-read. Your comment at :92-96 says this outright so it's honest; noting only that the constant's name reads like a DoS control. Filed follow-up is the right home.
  • Port unconstrained — confirmed https://mms.twiliocdn.com:8443/ is accepted. Named as a residual; needs an attacker to already hold a *.twiliocdn.com name.
  • Confirmed --skip-agent was correct here: no docker/base-image/ file and no new backend module in the diff. And test_1069_voip_call_path_param is pre-existing fastapi>=0.115.0 floor drift — should not be attributed to this PR.

To convert this to an approval: the AC #1 check (or the PDF placeholder-flip signal) and AC #9's comment on #1315. The F1 log-level change is a strong suggestion, not a gate.

Process surface is complete — all four memory surfaces plus user docs, closing keyword present, full label set, 23/23 CI green across six pytest seeds, and a named regression test whose non-vacuity I confirmed myself.

🤖 Review assisted by Claude Code

trinity-ability and others added 2 commits August 3, 2026 10:14
# Conflicts:
#	docs/memory/feature-flows.md
…iew)

#1932's root cause was never the allowlist — it was that a fail-closed gate
logged a 100% inbound-media outage at WARNING, where it sat unnoticed for
three months. The fix raised only the redirect gate to ERROR; both SOURCE-tier
gates (webhook `MediaUrl{N}` parse, credentialed hop 1) stayed at WARNING.

Before the two-tier split these shared one constant and could not diverge. The
split makes SOURCE strictly *stricter* than the redirect tier, so it is now the
asymmetry most likely to break on the next vendor change — the exact #1932
signature — and it was the half still guarded at the quieter level.

Both are now ERROR, pinned by `TestSourceTierRejectionsAreVisible` (mutation-
proven: reverting either level REDs the pair on `{30} != {40}`), since the
level is a one-word revert with a three-month observed detection latency.

Also corrects two stale doc claims flagged in review. `feature-flows/
whatsapp-integration.md` and `requirements/public-access.md` both described
inbound media as "images/audio/PDFs", which `upload_service.UNSUPPORTED_MIMES`
contradicts — it rejects `application/pdf`, `audio/`, `video/` and archives for
every channel, so only images (plus text/CSV/JSON) reach a workspace.

A third claim was wrong in the same place and is corrected here: WhatsApp has
no voice-transcription route at all. `message_router._maybe_transcribe_voice`
returns early for `channel != "telegram"` and calls `telegram_media
.process_voice` — it never touches `adapter.download_file`, so a WhatsApp voice
note is fetched and then rejected as "unsupported format", full stop.

User doc: media auth has been mandatory on accounts created since 2023-07-31
(per Twilio's changelog), so the S3 residual reaches only pre-July-2023
grandfathered accounts — most operators need no action.

Refs #1932

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@trinity-ability trinity-ability left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/validate-pr: PASS.

  • Base dev ✅ · 8 files ✅ · Fixes #1932 closing keyword present, issue auto-promotes ✅
  • Security scan clean. The api.twilio.com@evil.com / mms.twiliocdn.com@evil.com strings are userinfo-spoof test vectors, and _host_matches_suffixes reads parsed.hostname, which resolves them to evil.com — correctly rejected ✅
  • No new top-level backend module, no new os.getenv()
  • Docs: requirements + architecture + 2 feature flows + user doc ✅
  • Test adequacy: redirect-chain coverage, CDN-shaped spoof vectors, two-tier allowlist pins — non-happy-path, named to #1932

Design call worth recording: splitting SOURCE (credentialed hop 1, stays .twilio.com) from ALLOWED (unauthenticated redirect targets, adds .twiliocdn.com) widens only the gate that had the bug, so the tenant AuthToken's blast radius is unchanged. The redirect budget, the transport size cap, and dropping the exception body (the signed CDN URL is a ~4h bearer capability) are all correct.

Approving from trinity-ability — GitHub does not permit self-approval and the author is vybe.

@vybe
vybe merged commit 577d68c into dev Aug 3, 2026
29 of 31 checks passed
vybe pushed a commit that referenced this pull request Aug 3, 2026
dev has since taken #1913/#1937/#1947/#1949/#1899. Seven conflicts, resolved so
that no side's change is lost:

SOURCE
- static_checks.py  the one real semantic conflict. ent#128 (#1899) flipped the
                    per-check swallow from _skip to _fail so a crashed check is
                    counted by _counts; ent#89 kept _skip and added logging.
                    Taking this branch's side verbatim would have silently
                    reverted the HARD-count fix. Merged: _fail from #1899 +
                    logger.error(exc_info=True) from ent#89, which is strictly
                    more diagnostic than the logger.warning it replaces. The
                    docstring directly above already asserts "a check that could
                    not evaluate is not a check that passed".
- template_service  three hunks, all adjacent additions: both import blocks kept
                    (template_schedules got its own statement — the two sides
                    shared a closing paren), both new functions kept, and both
                    pre-literal computations kept at each of the two call sites.
- crud.py           import list, both symbols kept.

DOCS
- architecture.md   two hunks where BOTH sides had edited the same three bullets
                    (template_service / fork_to_own / crud). Not a pick — each
                    line was 3-way merged at word granularity against the merge
                    base; no edit pairs overlapped, so both sides' text survives
                    verbatim. dev's bullet order preserved, ent#89's new
                    template_schedules.py bullet appended.
- feature-flows.md  all rows kept, table stays reverse-chronological.
- learnings.md      both sets of entries kept.
- registry.json     both entry lists kept. The conflict opened after a bare '{'
                    and closed before a bare '}', so each side was an object
                    BODY -- a naive concatenation produced invalid JSON. Re-added
                    the '},{' separator. 109 -> 112 entries, none dropped.

Verified: zero markers tree-wide, registry.json parses (112 entries), all three
touched modules compile, and every deletion vs origin/dev is one of ent#89's own
intended replacements (crud docstring three->four, the cron helper replaced by
the shared validator, T-018 wired into the dispatch map).
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.

3 participants