modelcontextprotocol / modelcontextprotocol/typescript-sdk
Client follows server 3xx into internal/loopback services: redirect SSRF / protocol confusion (bare fetch, no host validation) — same class as python-sdk #3358
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
Summary
Same class as https://github.com/modelcontextprotocol/python-sdk/issues/3358, applying to the TypeScript reference SDK: the client follows any 3xx redirect the server returns, with no validation of where it lands. A malicious or compromised MCP server (or anything able to influence its 3xx responses) can redirect the client into internal/loopback services (127.0.0.1, Docker, containers) and cloud metadata endpoints. When the redirect target speaks JSON-RPC or SSE — realistically, another local MCP server — its reply is accepted by the client as the MCP server's own (protocol confusion / data leak into any consumer of the session, e.g. an LLM).
I reported the identical gap in the Python SDK yesterday (referenced above). The maintainers there had already attempted a fix once (issue #2106 → PR #2180) that was closed without ever being merged, so the bug is still live. I did not find any open issue covering this surface in the TypeScript SDK either.
Root cause
- The streamable HTTP client transport issues requests through
createFetchWithInit()(packages/core-internal/src/shared/transport.ts). createFetchWithInitis a pure passthrough: it merges userrequestInitand calls the hostfetch(defaults to globalfetch, i.e. undici on Node).- Neither the transport nor the wrapper sets
redirect: "manual"nor validates the final host/IP, so undici's defaultredirect: "follow"is in effect for every GET stream and every authenticated/unauthenticated POST. A302/303(or301/307/308) reply at any point (initial GET,initialize, OAuth-resolved URL, session resume) sends the request onward anywhere, including loopback/link-local.
There is no equivalent of the Python SDK's transport_security.py host checks on the client side (the isLoopbackHost guard that exists covers the OAuth token endpoint only — not the transport's own requests).
Reproducer (self-contained)
Run: npm install @modelcontextprotocol/sdk then node ts_redirect.mjs (Node >= 20).
victim= a 127.0.0.1 HTTP service pretending to be an internal JSON-RPC endpoint (serverInfo.name = "internal-secret-service-ts").mcp= a mock MCP server whose every POST answers307 → http://127.0.0.1:<victim>/.- Client:
new Client(...)+StreamableHTTPClientTransport(mcpUrl)→connect()→getServerVersion().
Standalone file: https://gist.github.com/trickyfalcon/f758f72e77b9a9f365df8d7c0655d2c6
Observed output:
mcp url: http://127.0.0.1:53935/mcp
[mcp] POST /mcp -> 307 to loopback victim
[victim] POST / body={"method":"initialize",...} <- redirected POST lands on the internal service
[mcp] POST /mcp -> 307 to loopback victim
[victim] POST / body={"method":"notifications/initialized",...}
RESULT getServerVersion(): {"name":"internal-secret-service-ts","version":"9.9"}
=> PROOF: client believes the MCP server is internal-secret-service-ts
(it is actually the loopback victim answering through the 307)
Every transport request is redirected into the internal service, and the SDK adopts that service's serverInfo as the server it is talking to.
Impact
- SSRF: a server-controlled redirect makes the client (running inside an agent/desktop harness, which usually has broader network trust than the server) touch internal-only endpoints: Docker API, other local services, AWS/GCP metadata (
169.254.169.254/metadata.google.internal). - Protocol confusion / data leak: if the redirect target responds with JSON-RPC or SSE, that content is swallowed directly into the MCP session — internal responses flow to whatever consumes the client (e.g. an LLM) presented as the server's own message.
- Severity is LOW–MEDIUM by default; escalate in hosted agent deployments where the client can reach metadata/loopback the server cannot.
Suggested fix
Mirror the (already-existing) server-side hardening with a client-side counterpart, e.g. in createFetchWithInit/the transport:
- Validate the redirect target before following: reject
127.0.0.0/8, link-local, and private ranges unless explicitly allowed, similar toisLoopbackHost, or - Make redirect behavior configurable (
redirect: "manual"+ explicitLocationresolution with an allow-list hook), and/or - Reuse the token-endpoint host checks for transport requests.
Disclosure
Reported by Mo (@trickyfalcon) on 2026-08-22. Happy to be credited as: Mo (@trickyfalcon, https://trickyfalcon.com)
Related: Python SDK https://github.com/modelcontextprotocol/python-sdk/issues/3358 (identical class, that SDK).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read packages/core-internal/src/shared/transport.ts, starting at createFetchWithInit and the Streamable HTTP client transport. Run the ts_redirect.mjs reproducer to observe the redirect into the loopback victim; done means transport redirects are handled according to an explicit safe policy and the victim response is no longer accepted as the MCP server response.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- networking, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100