fix(slack): gate inbound file downloads behind a host allowlist (#1951) - #1955
Merged
Conversation
`SlackService.download_file` fetched `url_private_download` with no host allowlist, no scheme check, and `follow_redirects=True` — so a redirect was blind-followed to any host. Telegram has validated since it shipped and WhatsApp since #1932; Slack was the channel that never did. Third consecutive CSO audit to report it. Two real mitigations kept this below P1 and both are transitive, which is the argument for fixing it anyway: the URL arrives on an HMAC-verified Slack event (trust in Slack's own field), and httpx strips `Authorization` cross-origin (trust in httpx's behaviour). Neither is a property of Trinity's code. What is ours: this backend sits on BOTH Docker networks, so an arbitrary-host fetch from here reaches internal services agents deliberately cannot route to, and the response bytes are handed back and written into an agent workspace — a non-blind primitive, not a blind SSRF. The gate is the #1932 two-tier shape rather than a third bespoke allowlist: - SOURCE `*.slack.com` — hop 1, the only request carrying `Bearer {bot_token}` - ALLOWED adds `*.slack-edge.com` / `*.slack-files.com` — validated 30x targets only, fetched WITHOUT the token `follow_redirects=False`; every hop re-validated before it is issued, under a bounded budget; https-only; refusals log at ERROR, because a fail-closed media gate that goes quiet is exactly how #1932 hid a 100% outage for three months. Chose `*.slack.com` over an exact `files.slack.com` deliberately: #1932's real lesson is that an allowlist rejecting the LEGITIMATE host scores as "allowlist present" in an audit while being completely broken. A Slack-apex suffix cannot break on a `files-N.slack.com` variant and still confines the token to Slack. Verified before/after against the pre-fix module, same fake transport: BEFORE https://169.254.169.254/latest/meta-data/ -> requests=1, bytes returned BEFORE http://172.29.0.2:5432/ -> requests=1, bytes returned AFTER both -> requests=0, None 26 tests, every negative one asserting the recorded CALL LOG rather than `is None`: `download_file` returns None on every failure path and its bare `except Exception` swallows AssertionError, so a return-value assertion passes against a completely unpatched seam. Includes the dotless-suffix bypass (`evil-slack-files.com`), which is why every allowlist entry must start with a dot — pinned by its own test. Deliberately NOT folded in: the shared `channel_media_fetch` helper, the private-IP/DNS-rebinding resolution check, and a true streaming size bound. The first would touch two channels that were just fixed; the other two are accepted residuals on #1932 that belong with that helper. Related to #1951 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho
force-pushed
the
fix/1951-slack-media-ssrf
branch
from
August 3, 2026 11:02
70ab4de to
a188efb
Compare
# Conflicts: # tests/registry.json
vybe
approved these changes
Aug 3, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
Validated via /validate-pr. Closes #1951 (closing keyword added so the issue auto-promotes).
Security fix, no schema/API/config surface. The two flagged strings in my scan are test fixtures (xoxb-test-token, 169.254.169.254 as a redirect target), not credentials.
What I checked and agreed with:
- Reuses the #1932 two-tier shape rather than inventing a third bespoke allowlist — source tier (
.slack.com, carries the bot token) strictly narrower than the redirect tier,follow_redirects=False, every hop re-validated before it is issued, bounded budget, https-only. *.slack.comover an exactfiles.slack.comis the right call. #1932's actual lesson is that an allowlist rejecting the legitimate host scores as "allowlist present" in an audit while being 100% broken — WhatsApp media was dead three months that way. A too-narrow list here fails silently; a too-broad-within-Slack one fails loudly at ERROR with the host named.- Every negative test asserts the recorded call log, never
is None.download_filereturnsNoneon every failure path and its bareexcept ExceptionswallowsAssertionError, so a return-value assertion passes against a completely unpatched seam. This is the vacuous-negative class from #1932/#1917, and it's handled. test_every_allowlist_entry_starts_with_a_dotpins theevil-slack-files.comdotless-suffix bypass — the kind of guard that stops the next entry from reintroducing it.
The before/after demonstration against the real trinity-postgres on the platform network makes the reachability argument concrete rather than theoretical.
Merged dev and resolved the tests/registry.json append conflict (both entries kept; JSON re-parsed, no new duplicates, touched-file set unchanged).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1951
Problem
SlackService.download_filefetchedurl_private_downloadwith no host allowlist, no scheme check, andfollow_redirects=True— redirects blind-followed to any host. Both siblings already validate; Slack was the one that never did, across three consecutive CSO audits.api.telegram.org, exactfollow_redirects=False, each hop re-validatedfollow_redirects=TrueWhy it mattered despite two mitigations
The issue is honest that the URL is HMAC-verified Slack input and that httpx strips
Authorizationcross-origin. Both hold — and both are transitive: one trusts Slack's field, the other trusts httpx's behaviour. Neither is a property of Trinity's code. What is ours:message_routerand written into an agent workspace, so this is a non-blind primitive, stronger than a blind SSRFFix — the #1932 shape, not a third bespoke allowlist
follow_redirects=False, every hop re-validated before it is issued, https-only, refusals at ERROR.*.slack.comrather than an exactfiles.slack.com, deliberately. #1932's real lesson is that an allowlist which rejects the legitimate host scores as "allowlist present" in an audit while being 100% broken — WhatsApp media was dead for three months that way. A Slack-apex suffix can't break on afiles-N.slack.comvariant and still confines the token to Slack. If Slack ever redirects somewhere outside the tiers, it fails loudly at ERROR with the host named, and the fix is one allowlist line.Verification
Before/after against the pre-fix module through the same fake transport:
172.29.0.2:5432is this machine's actualtrinity-postgreson the platform network — the reach the issue describes, demonstrated rather than asserted.is Nonealone —download_filereturnsNoneon every failure path and its bareexcept ExceptionswallowsAssertionError, so a return-value assertion passes against a completely unpatched seam (the AC's point, andtest_whatsapp_inbound_media.py's documented lesson)files.slack.comdownload still succeeds end-to-end, and the CDN-redirect test asserts the second hop carries noAuthorizationOne test earns its place specially:
evil-slack-files.compasses a dotlessendswith("slack-files.com")check. Every allowlist entry must therefore start with a dot, pinned bytest_every_allowlist_entry_starts_with_a_dot.Acceptance criteria
httpsrefusedfollow_redirects=False, per-hop re-validation, bounded budgetDeliberately not folded in
The shared
channel_media_fetchhelper (would touch two channels that were just fixed — better as its own change with all three test suites as the safety net), the private-IP/DNS-rebinding resolution check, and a true streaming size bound. The latter two are accepted residuals on #1932 and belong with that helper; the size cap here stays honest about bounding what is accepted, not what is buffered, exactly like both siblings.Related to #1951
🤖 Generated with Claude Code