Summary
gateway/slack/attachments.py downloads Slack files with follow_redirects=True
and no host allowlist, so the bot token is sent to whatever host url_private
names. Discord's downloader (gateway/discord/attachments.py) rejects non-Discord
hosts before the request. Slack should have the same check.
Scope correction from the original report: the bot token does not leak across
a redirect. httpx strips the Authorization header whenever a redirect leaves the
origin (_client.py::_redirect_headers, present in the pinned httpx>=0.27); the
only exception is a same-host http→https upgrade, and url_private is already
https. The original "observe the Bearer header is re-sent to the redirect target"
step does not reproduce, and the original "Done when" was already satisfied by the
library. What is missing is the host check itself.
This is defense in depth, not a live exploit — see the threat model below.
Expected vs actual behavior
Expected: reject a url_private whose host is not a known Slack file host
before sending the bearer token, and re-check the host on every redirect hop.
Actual: download_file (L106–114) sends Authorization: Bearer {token} to
whatever host url_private names, with follow_redirects=True and no allowlist.
Steps to reproduce
- Build a
SlackInboundFile whose url_private points at a non-Slack host.
- Call
download_file(file, token).
- The request goes out. Nothing rejects the host.
There is no user-facing repro — the threat model below explains why.
Can you reproduce it consistently?
Yes
How often does it occur?
Under specific conditions (only reachable if url_private is ever attacker-influenced,
which it is not today)
Operating system
Other (server/gateway)
Additional context
Threat model — why this is hardening, not an incident.
The Slack gateway runs over Socket Mode (gateway/slack/socket_mode_worker.py).
There is no HTTP Slack events route in gateway/http/, so url_private arrives
only over Slack's authenticated WebSocket and is minted by Slack, not by the
message sender. An external attacker cannot set it as the code stands.
The check is worth adding anyway because it guards the invariant if an HTTP Events
API route is added later, if the files.info fallback noted in
gateway/slack/events.py L118 starts supplying the URL, or if httpx changes its
redirect handling. It also closes a second-order gap that stripping Authorization
does not: a redirect chain currently lets the gateway issue outbound GETs to
arbitrary hosts, and the response body is decoded and inlined into the model turn
(extract_text → _budgeted_section).
Implementation note — do not just copy Discord.
Discord sets follow_redirects=False. Slack cannot: url_private legitimately
307/308-redirects to the file CDN, so disabling redirects breaks every download.
The Slack fix needs a manual hop loop — follow_redirects=False, then re-request
the Location only when its host passes the allowlist, bounded to a few hops.
- Add
SLACK_ATTACHMENT_HOST_SUFFIXES to config/constants/slack.py, mirroring
DISCORD_ATTACHMENT_HOST_SUFFIXES in config/constants/discord.py.
- Reuse the shape of
is_allowed_attachment_url (https-only, exact host or
dot-suffix match).
- Confirm the current documented Slack file hosts before pinning the tuple;
files.slack.com is the one in use today.
- Keep the existing 10s timeout and the 256 KiB
_MAX_FILE_BYTES cap.
Tests:
- non-allowlisted initial host → no request issued,
None returned
- allowlisted host that redirects to a non-allowlisted host → chain stops,
None
- allowlisted host redirecting within the allowlist → bytes returned, cap still enforced
- hop limit → a redirect loop terminates
Done when: download_file will not send the bot token to a host outside the
Slack file-host allowlist, on the initial request or on any redirect hop.
Summary
gateway/slack/attachments.pydownloads Slack files withfollow_redirects=Trueand no host allowlist, so the bot token is sent to whatever host
url_privatenames. Discord's downloader (
gateway/discord/attachments.py) rejects non-Discordhosts before the request. Slack should have the same check.
Scope correction from the original report: the bot token does not leak across
a redirect. httpx strips the
Authorizationheader whenever a redirect leaves theorigin (
_client.py::_redirect_headers, present in the pinnedhttpx>=0.27); theonly exception is a same-host http→https upgrade, and
url_privateis alreadyhttps. The original "observe the Bearer header is re-sent to the redirect target"
step does not reproduce, and the original "Done when" was already satisfied by the
library. What is missing is the host check itself.
This is defense in depth, not a live exploit — see the threat model below.
Expected vs actual behavior
Expected: reject a
url_privatewhose host is not a known Slack file hostbefore sending the bearer token, and re-check the host on every redirect hop.
Actual:
download_file(L106–114) sendsAuthorization: Bearer {token}towhatever host
url_privatenames, withfollow_redirects=Trueand no allowlist.Steps to reproduce
SlackInboundFilewhoseurl_privatepoints at a non-Slack host.download_file(file, token).There is no user-facing repro — the threat model below explains why.
Can you reproduce it consistently?
Yes
How often does it occur?
Under specific conditions (only reachable if
url_privateis ever attacker-influenced,which it is not today)
Operating system
Other (server/gateway)
Additional context
Threat model — why this is hardening, not an incident.
The Slack gateway runs over Socket Mode (
gateway/slack/socket_mode_worker.py).There is no HTTP Slack events route in
gateway/http/, sourl_privatearrivesonly over Slack's authenticated WebSocket and is minted by Slack, not by the
message sender. An external attacker cannot set it as the code stands.
The check is worth adding anyway because it guards the invariant if an HTTP Events
API route is added later, if the
files.infofallback noted ingateway/slack/events.pyL118 starts supplying the URL, or if httpx changes itsredirect handling. It also closes a second-order gap that stripping
Authorizationdoes not: a redirect chain currently lets the gateway issue outbound GETs to
arbitrary hosts, and the response body is decoded and inlined into the model turn
(
extract_text→_budgeted_section).Implementation note — do not just copy Discord.
Discord sets
follow_redirects=False. Slack cannot:url_privatelegitimately307/308-redirects to the file CDN, so disabling redirects breaks every download.
The Slack fix needs a manual hop loop —
follow_redirects=False, then re-requestthe
Locationonly when its host passes the allowlist, bounded to a few hops.SLACK_ATTACHMENT_HOST_SUFFIXEStoconfig/constants/slack.py, mirroringDISCORD_ATTACHMENT_HOST_SUFFIXESinconfig/constants/discord.py.is_allowed_attachment_url(https-only, exact host ordot-suffix match).
files.slack.comis the one in use today._MAX_FILE_BYTEScap.Tests:
NonereturnedNoneDone when:
download_filewill not send the bot token to a host outside theSlack file-host allowlist, on the initial request or on any redirect hop.