You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Messenger adapter's Attachment.fetch_data() GETs whatever URL arrived in the webhook payload.url, and it follows redirects. Some Messenger attachment types (fallback, link shares) carry URLs the end user controls, so this is a server-side request forgery (SSRF) weakness. Any handler that calls fetch_data(), directly or through to_ai_messages, can be made to fetch internal or arbitrary hosts. Port upstream hardening 153bd964: fetch only from Meta's CDN hosts, re-check every redirect hop, and cap size and time. This is the minimal, Messenger-local fix. #239 later moves it onto the shared downloader from #204.
Upstream changes
153bd964 fix(messenger): guard attachment downloads (#856) — chat@4.39.0 — Messenger downloadAttachment now delegates to a new fetch.tsdownload(). It permits only fbsbx.com / fbcdn.net (exact host or a subdomain, case-insensitive), requires https, re-validates redirects, uses a 25 MB cap and a 30 s timeout, and wraps failures as NetworkError("messenger", ...). attachment.url stays unchanged, so external URLs are still displayed but never fetched.
Current Python behavior
src/chat_sdk/adapters/messenger/adapter.py:795-818_extract_attachments builds fetch_data for anypayload.url and stores it in fetch_metadata={"url": url}.
adapter.py:833-866rehydrate_attachment rebuilds the closure from fetch_metadata["url"], again with no validation. A tampered queue or debounce entry in state therefore reaches the same code path.
adapter.py:890-908_download_attachment calls session.get(url) on the shared session (adapter.py:213-218). It has no scheme or host check, aiohttp's default allow_redirects=True applies, the body is read unbounded (await response.read()), and there is no timeout.
Existing tests use non-Meta hosts and will need new fixtures: tests/test_messenger_webhook.py:940 (test_attachment_has_fetch_data_callable), :954 (test_attachment_download_uses_session, which uses https://example.com/img.jpg), and :1024 (test_rehydrate_rebuilds_fetch_data_after_queue_roundtrip).
Scope
Add _MESSENGER_MEDIA_HOSTS = ("fbsbx.com", "fbcdn.net") and _is_trusted_messenger_media_url(url) -> bool in messenger/adapter.py. The check requires scheme https, no explicit port (or port 443), and no userinfo. The hostname must equal an allowed host or end with . plus that host, compared case-insensitively. Reject trailing-dot hosts, IP literals and hosts that fail to parse.
Rewrite _download_attachment(url):
validate the URL before any network I/O;
run a manual redirect loop (allow_redirects=False, at most 5 hops) that resolves Location against the current URL and re-validates every hop;
apply aiohttp.ClientTimeout(total=30);
reject a Content-Length above 25 MB, then read in chunks with a running total and abort past 25 MB;
treat non-2xx as NetworkError("messenger", f"Failed to fetch file: {status} {reason}");
wrap any other exception as NetworkError("messenger", "Failed to download Messenger attachment", original_error=...).
Error message for a refused URL or hop: "Refusing to fetch an untrusted attachment URL", matching upstream. No network call is made.
Keep validation inside the download closure, not at parse time, so the rehydrate_attachment path is covered and attachment.url keeps the original value.
Update the existing Messenger download tests to use https://cdn.fbsbx.com/... URLs.
Update the Messenger row(s) in docs/UPSTREAM_SYNC.md. Add Messenger to the rehydrate_attachment URL allowlist row (docs/UPSTREAM_SYNC.md:666) and note that upstream now validates too.
aiohttp follows redirects by default. Pass allow_redirects=False explicitly and handle 301/302/303/307/308 yourself. A missing or malformed Location must raise NetworkError, not KeyError or ValueError.
Use urllib.parse.urlsplit and .hostname, which lowercases and strips brackets. Compare with == or endswith("." + host), never a bare endswith(host), because suffix attacks like fbcdn.net.attacker.example must fail. Decide explicitly how to treat hostname.endswith(".") (recommended: reject, as upstream does).
Keep aiohttp lazily imported inside methods (existing pattern at adapter.py:215).
Everything here is async: the chunked read loop is async for chunk in response.content.iter_chunked(...). Do not introduce blocking reads.
Do not change the fetch_metadata shape ({"url": ...}), because persisted queue entries depend on it.
Tests
Upstream: packages/adapter-messenger/src/fetch.test.ts (new) and index.test.ts. Neither is fidelity-mapped in scripts/verify_test_fidelity.py MAPPING (adapter tests are not mapped). Port into tests/test_messenger_webhook.py, or a new tests/test_messenger_fetch.py:
describe("Messenger attachment fetch"):
"downloads from Meta CDN URL %s": parametrize over cdn.fbsbx.com, lookaside.fbsbx.com, scontent.xx.fbcdn.net, and the mixed-case SContent.XX.FBCDN.NET.
"rejects untrusted attachment URL %s": parametrize over example.com, a suffix-attack host, a trailing-dot host, http://, an IPv4 literal and a decimal-integer host. Assert that the transport is never called.
"rejects redirects away from Meta CDN hosts": the transport is called exactly once.
"normalizes transport failures"
"rejects unsuccessful responses"
"uses Messenger network errors"
index.test.ts: "downloads attachment successfully" (updated to an fbsbx URL) and "rejects external fallback downloads before the network". The second asserts that attachment.url is preserved and the session is not hit.
Python-specific tests:
a redirect between allowed hosts (lookaside.fbsbx.com → scontent.*.fbcdn.net) succeeds, mirroring shared "follows redirects between allowlisted hosts";
a Content-Length over the cap is rejected before reading the body;
a streamed body over the cap is aborted;
a rehydrate_attachment closure built from a non-Meta fetch_metadata["url"] raises NetworkError without I/O.
Mock the session with an AsyncMock-based context manager, and use no real sleeps. Replace, rather than duplicate, test_attachment_download_uses_session.
Acceptance criteria
fetch_data() on a non-fbsbx.com/fbcdn.net URL raises NetworkError with zero network calls, both for freshly parsed and for rehydrated attachments.
Redirects are re-validated per hop, and there are at most 5 of them.
The 25 MB cap and 30 s total timeout are enforced.
Full validation command from CLAUDE.md passes (ruff check, ruff format --check, audit_test_quality, verify_test_fidelity, pytest).
docs/UPSTREAM_SYNC.md is updated (Messenger allowlist row, plus the "no DNS/private-IP check until SH1" note).
CHANGELOG entry under an "Unreleased (4.41 wave)" heading, marked security.
Consumer-visible change is called out: Messenger fetch_data() for fallback or link-share attachments on non-Meta hosts now raises instead of downloading.
Dependencies
None — can start immediately. Not an index blocker for anything, but it should land before #239, which replaces this inline guard with the #204 downloader.
Metadata
Effort: S (<200 LOC)
Consumer impact: none for Slack/Teams users. Low for Messenger users: external fallback URLs are no longer downloadable, though attachment.url is still present.
Summary
The Messenger adapter's
Attachment.fetch_data()GETs whatever URL arrived in the webhookpayload.url, and it follows redirects. Some Messenger attachment types (fallback, link shares) carry URLs the end user controls, so this is a server-side request forgery (SSRF) weakness. Any handler that callsfetch_data(), directly or throughto_ai_messages, can be made to fetch internal or arbitrary hosts. Port upstream hardening153bd964: fetch only from Meta's CDN hosts, re-check every redirect hop, and cap size and time. This is the minimal, Messenger-local fix. #239 later moves it onto the shared downloader from #204.Upstream changes
153bd964fix(messenger): guard attachment downloads (#856) — chat@4.39.0 — MessengerdownloadAttachmentnow delegates to a newfetch.tsdownload(). It permits onlyfbsbx.com/fbcdn.net(exact host or a subdomain, case-insensitive), requires https, re-validates redirects, uses a 25 MB cap and a 30 s timeout, and wraps failures asNetworkError("messenger", ...).attachment.urlstays unchanged, so external URLs are still displayed but never fetched.Current Python behavior
src/chat_sdk/adapters/messenger/adapter.py:795-818_extract_attachmentsbuildsfetch_datafor anypayload.urland stores it infetch_metadata={"url": url}.adapter.py:833-866rehydrate_attachmentrebuilds the closure fromfetch_metadata["url"], again with no validation. A tampered queue or debounce entry in state therefore reaches the same code path.adapter.py:890-908_download_attachmentcallssession.get(url)on the shared session (adapter.py:213-218). It has no scheme or host check, aiohttp's defaultallow_redirects=Trueapplies, the body is read unbounded (await response.read()), and there is no timeout.grep -rn 'fbsbx\|fbcdn' src/chat_sdk/adapters/messengerfinds nothing.tests/test_messenger_webhook.py:940(test_attachment_has_fetch_data_callable),:954(test_attachment_download_uses_session, which useshttps://example.com/img.jpg), and:1024(test_rehydrate_rebuilds_fetch_data_after_queue_roundtrip).Scope
_MESSENGER_MEDIA_HOSTS = ("fbsbx.com", "fbcdn.net")and_is_trusted_messenger_media_url(url) -> boolinmessenger/adapter.py. The check requires schemehttps, no explicit port (or port 443), and no userinfo. The hostname must equal an allowed host or end with.plus that host, compared case-insensitively. Reject trailing-dot hosts, IP literals and hosts that fail to parse._download_attachment(url):allow_redirects=False, at most 5 hops) that resolvesLocationagainst the current URL and re-validates every hop;aiohttp.ClientTimeout(total=30);Content-Lengthabove 25 MB, then read in chunks with a running total and abort past 25 MB;NetworkError("messenger", f"Failed to fetch file: {status} {reason}");NetworkError("messenger", "Failed to download Messenger attachment", original_error=...)."Refusing to fetch an untrusted attachment URL", matching upstream. No network call is made.rehydrate_attachmentpath is covered andattachment.urlkeeps the original value.https://cdn.fbsbx.com/...URLs.docs/UPSTREAM_SYNC.md. Add Messenger to therehydrate_attachmentURL allowlist row (docs/UPSTREAM_SYNC.md:666) and note that upstream now validates too.Out of scope
mark_as_read/mark_seen: [4.41/W4] WhatsApp & Messenger: mark_as_read, native replies, code fences, guarded downloads #239.get_userstays Add MessengerAdapter.get_user (sibling-adapter consistency) #132.Porting notes
allow_redirects=Falseexplicitly and handle 301/302/303/307/308 yourself. A missing or malformedLocationmust raiseNetworkError, notKeyErrororValueError.urllib.parse.urlsplitand.hostname, which lowercases and strips brackets. Compare with==orendswith("." + host), never a bareendswith(host), because suffix attacks likefbcdn.net.attacker.examplemust fail. Decide explicitly how to treathostname.endswith(".")(recommended: reject, as upstream does).aiohttplazily imported inside methods (existing pattern atadapter.py:215).async for chunk in response.content.iter_chunked(...). Do not introduce blocking reads.fetch_metadatashape ({"url": ...}), because persisted queue entries depend on it.Tests
Upstream:
packages/adapter-messenger/src/fetch.test.ts(new) andindex.test.ts. Neither is fidelity-mapped inscripts/verify_test_fidelity.pyMAPPING (adapter tests are not mapped). Port intotests/test_messenger_webhook.py, or a newtests/test_messenger_fetch.py:describe("Messenger attachment fetch"):"downloads from Meta CDN URL %s": parametrize overcdn.fbsbx.com,lookaside.fbsbx.com,scontent.xx.fbcdn.net, and the mixed-caseSContent.XX.FBCDN.NET."rejects untrusted attachment URL %s": parametrize overexample.com, a suffix-attack host, a trailing-dot host,http://, an IPv4 literal and a decimal-integer host. Assert that the transport is never called."rejects redirects away from Meta CDN hosts": the transport is called exactly once."normalizes transport failures""rejects unsuccessful responses""uses Messenger network errors"index.test.ts:"downloads attachment successfully"(updated to an fbsbx URL) and"rejects external fallback downloads before the network". The second asserts thatattachment.urlis preserved and the session is not hit.Python-specific tests:
lookaside.fbsbx.com→scontent.*.fbcdn.net) succeeds, mirroring shared"follows redirects between allowlisted hosts";Content-Lengthover the cap is rejected before reading the body;rehydrate_attachmentclosure built from a non-Metafetch_metadata["url"]raisesNetworkErrorwithout I/O.Mock the session with an
AsyncMock-based context manager, and use no real sleeps. Replace, rather than duplicate,test_attachment_download_uses_session.Acceptance criteria
fetch_data()on a non-fbsbx.com/fbcdn.netURL raisesNetworkErrorwith zero network calls, both for freshly parsed and for rehydrated attachments.docs/UPSTREAM_SYNC.mdis updated (Messenger allowlist row, plus the "no DNS/private-IP check until SH1" note).fetch_data()for fallback or link-share attachments on non-Meta hosts now raises instead of downloading.Dependencies
None — can start immediately. Not an index blocker for anything, but it should land before #239, which replaces this inline guard with the #204 downloader.
Metadata
attachment.urlis still present.sync/4.41-ms1Part of #184.