Repository navigation
Client SSRF / protocol confusion: streamable HTTP transport follows server 3xx into internal+loopback services (follow_redirects=True, no host validation; #2106 closed but fix #2180 never merged) #3358
Description
Activity
- addedv2Affects the v2 line (2.x on main)Affects the v2 line (2.x on main)v1Affects the v1.x maintenance lineAffects the v1.x maintenance line
on Aug 21, 2026 I'd like to work on this. The fix is well-scoped: the client side is the only unprotected direction (server-side DNS-rebinding protection already lives in
transport_security.py), and the regression #2180 never landed.Proposed approach (reusing the repo's existing security primitives, per the "Suggested fix" in the report):
- Make the Streamable HTTP
GET/POSTclient not follow redirects into arbitrary hosts. Validate each 3xx redirect target (its final host) with the existingis_loopback()/is_link_local()checks, and allow cross-versioning redirects only to hosts that are either the intended server or explicitly permitted. - Keep behavior backward-compatible by default while closing the internal-probing / protocol-confusion hole, and add a client-side regression test alongside.
Could a maintainer assign me so the PR gate (require-linked-issue) keeps the pull request open? I'll open the PR once assigned.
- Make the Streamable HTTP
Confirmed the finding on both
mainand the released wheel —create_mcp_http_clientsetsfollow_redirects=Truewith no redirect-target validation anywhere, so a 307/308 bounce can drive the client's JSON-RPC traffic onto a loopback/local service and the client ingests that service's reply as the MCP server's own.I implemented a client-side fix in the factory (mirroring the existing server-side DNS-rebinding protection in
transport_security.py): aRedirectPolicy(NONE/SAME_HOST/SAFE/ALL, defaultSAFE) plus an httpxrequestevent hook that records the caller-chosen origin and blocks redirect hops that land on non-global addresses (loopback, link-local, private, multicast, reserved, unspecified — e.g.127.0.0.1,10/8,172.16/12,192.168/16,169.254.169.254,fc00::/7,fe80::/10). Legitimate public redirects still resolve;ALLpreserves legacy behavior explicitly.Verification:
- Live integration tests: a local server that 307-bounces to a loopback victim is refused with a clear
ConnectErrorunder the default policy, whileALLfollows (proving the vulnerable path) and a same-host redirect still works. - 22 new tests pass; existing
tests/client/test_streamable_http.py(28) pass with no regressions;ruff check+ruff format --checkclean.
Draft in PR #3423. Happy to adjust the default policy or scope if maintainers prefer a stricter default.
- Live integration tests: a local server that 307-bounces to a loopback victim is refused with a clear
You clearly track this repo closely if you caught that the earlier fix (#2180) never actually landed, do you keep a personal changelog/watch list for this SDK, or did you just happen to remember the older issue? Curious how you'd have found this if you weren't already familiar with the history.
Thanks for the kind words! This came out of a systematic cross-SDK audit I ran of the MCP client redirect class (mapping each official SDK's transport redirect behavior, e.g. https://github.com/trickyfalcon/cve-hunt). While deduping against repo history I noticed #2180's fix PR was closed without ever merging while the code still had no redirect-target validation — that gap made it worth re-proving end-to-end (live PoC: server 307 → loopback victim, linked in the report). No changelog magic, just a public tracker of the class.
Also +1 to @Ethanz11-creat's PR #3423 — the client-side redirect guard with the SAFE default is well-scoped; it'd be great to see a maintainer pick it up. Happy to help review/test if needed.
Thanks for the clear report and PoC — client-side redirect → loopback/private with JSON-RPC protocol confusion is a real class, and it’s useful that #2180 never actually landed.
I re-checked current
main/ latest PyPI (mcp==2.2.0, alsov1.30.0): the claim as filed is no longer reproducible.create_mcp_http_clientno longer setsfollow_redirects=True. Streamable HTTP goes throughstream_within_origin(PR #3397, merged 2026-09-04), which sends withfollow_redirects=Falseand only follows redirects that stay on the request’s origin (same scheme/host/port, plus http→https same-host default ports). Cross-port127.0.0.1is outside origin.Re-ran the issue PoC shape against 2.2.0: the transport logs
Redirect to http://127.0.0.1:<victim> not followed…and the client does not ingest the victim’sserverInfo(internal-secret-service). Existing unit tests intests/shared/test_httpx_utils.pyalready lock outside-origin / method-changing / https-downgrade behavior.If a maintainer is open to an outside PR, I’d like to add a small end-to-end regression (two loopback servers + Client assert unfollowed / no victim
initialize) so we don’t regress to unvalidated follow-all — not a secondRedirectPolicyon top ofstream_within_origin. Please assign me to #3358 if that scoped follow-up is welcome; happy to match whatever shape you prefer.Thanks @tiagovilasboas for the thorough re-check — and good to see this land properly: #3397 ("Follow redirects only within the MCP endpoint's origin", merged 2026-09-04) replacing the unconditional
follow_redirects=Truein_httpx_utils.pycloses the client-side hole for the shipped transports. Worth noting the affected surface shipped with the unfixed behavior for a while — the earlier #2180 fix PR closed without ever merging — so releases before 2.2.0 / v1.30.0 carry it.Would it be possible to get a security advisory (GHSA) / CVE for the affected versions, consistent with how the server-side DNS-rebinding class was handled? The end-to-end repro (server 307 → loopback victim, victim's JSON-RPC
serverInfoingested as the server's own) is in the report above with the live PoC (https://gist.github.com/trickyfalcon/0db331a6e4c69b1aaf14bd79c51fdeb8) — happy to pre-verify any regression test against it.Happy to be credited in the advisory/release notes as: Mo (@trickyfalcon, @url:
https://trickyfalcon.com)Huge thanks for shipping this end to end — security advisory GHSA-5h93-6whr-6q8j (published 2026-10-02, patched in 1.30.0 / 2.2.0) now documents the client-side redirect behavior reported here: transports (and the OAuth providers' own requests) following redirects to another origin, sending custom headers like
X-API-Keyand re-sent 307/308 bodies at the target, fixed by the origin-only policy from #3397.Two asks on the advisory:
- It has no CVE ID yet — could one be assigned so the affected 1.8.0–1.29.x / 2.0.x–2.1.x history is trackable in NVD?
- A reporter acknowledgment for the original public report (this issue, with the end-to-end PoC: server 307 → loopback victim, victim's
serverInfoingested as the server's own) would be greatly appreciated.
Happy to be credited in the advisory/release notes as: Mo (@trickyfalcon, https://trickyfalcon.com)
Summary
The MCP Python SDK's official Streamable HTTP client will follow any 3xx redirect the server returns, with no validation of where it lands. A malicious or compromised MCP server (or anything that can influence its 3xx responses) can therefore push the client into internal / loopback services (e.g.
127.0.0.1, Docker, kubernetes service proxies, cloud metadata endpoints) and, when that internal service happens to speak JSON-RPC (another local MCP server, an agent endpoint, a registry), the client accepts the internal service's reply as the MCP server's own — its identity and data leak straight into the caller/LLM session. This is the client-side mirror of the server-side DNS-rebinding protection that already exists in this repo (transport_security.py), and it is currently the only direction that is unprotected.Impact
GET,initialize, tool calls) can be bounced into internal-only endpoints. HTTP semantics are honored, so 302/303 (POST→GET, body dropped) and 307/308 (method+body preserved) are both affected.Severity assessment: LOW–MEDIUM. It requires attacker influence over the connected server's HTTP responses, but no auth and no user interaction.
Root cause
src/mcp/shared/_httpx_utils.py→create_mcp_http_client():The Streamable HTTP transport builds its
httpx2client from this factory (imported atsrc/mcp/client/streamable_http.py:43, client instantiated in the transport) and issues allPOST/GETthrough it. There is no redirect guard anywhere: no re-validation of the final URL's host, nois_loopback/is_link_localcheck on the redirect target. Grep forfollow_redirects/redirect/ host validation acrosssrc/(excluding OAuthredirect_urihandling) confirms nothing constrains redirect targets today.Prior attempt & current state
merged_at: null).maintoday (checked 2026-08-21) still contains onlyfollow_redirects = Truewith no guard. The PyPI release (verified on the installed 2.x wheel) is identical.So the finding is real on both
mainand the latest release, despite the issue being marked fixed.Reproducer (self-contained)
Runs against the released SDK (identical code path on
main). No test framework, only stdlib +mcp:Expected output
The client thinks it is talking to the server at
murl, but every request was bounced (307) to the loopback victim, and the victim's JSON-RPC reply was accepted as the server's.Full standalone file: https://gist.github.com/trickyfalcon/0db331a6e4c69b1aaf14bd79c51fdeb8 (also under
poc-pysdk/poc_redirect.pyin the repo attached below).Suggested fix (for discussion)
Reuse the existing server-side primitives —
transport_security.is_loopback(),is_link_local(), and the configurable allowed-hosts middleware already shipped in this repo — and apply the symmetric protection to the client: validate the final URL host of any followed redirect (reject internal targets unless they are the intended server), or makefollow_redirectsopt-in/configurable with anallowed_redirect_networks-style option. PR #2180's intent was right; it just never landed.Affected
mainand all released versions to date (thefollow_redirects = Trueline is unchanged since at least the 1.x/2.x lineage). Confirm fix, then I'm happy to help with a regression test (e.g. client-side host-validation unit test) and coordinate CVE assignment if appropriate.Credit
Mo (@trickyfalcon, https://trickyfalcon.com)