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

Open
#2,700 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

v1 v2
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).
  • createFetchWithInit is a pure passthrough: it merges user requestInit and calls the host fetch (defaults to global fetch, i.e. undici on Node).
  • Neither the transport nor the wrapper sets redirect: "manual" nor validates the final host/IP, so undici's default redirect: "follow" is in effect for every GET stream and every authenticated/unauthenticated POST. A 302/303 (or 301/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 answers 307 → 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 to isLoopbackHost, or
  • Make redirect behavior configurable (redirect: "manual" + explicit Location resolution 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.