Skip to content

Client follows server 3xx into internal/loopback services: redirect SSRF / protocol confusion (bare fetch, no host validation) — same class as python-sdk #3358 #2700

Description

@trickyfalcon

Summary

Same class as modelcontextprotocol/python-sdk#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 modelcontextprotocol/python-sdk#3358 (identical class, that SDK).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions