Skip to content

/fetch proxy leaks Authorization/Cookie headers across cross-origin redirects (regression from the SSRF hardening in v1.0.0)

Moderate
cliffhall published GHSA-f6mc-jg3x-vq53 Oct 21, 2026

Package

npm @modelcontextprotocol/inspector (npm)

Affected versions

>= 1.0.0, <= 1.0.2

Patched versions

1.0.3
npm @modelcontextprotocol/inspector-server (npm)
>= 1.0.0, <= 1.0.2
1.0.3

Description

Summary

The /fetch proxy in server/src/index.ts forwards the caller-supplied request headers (including Authorization and Cookie) unchanged across HTTP redirects, including redirects that cross to a different origin. A malicious or compromised remote MCP server that the user connects to can return a 3xx redirect and cause the Inspector proxy to send the user's bearer token / session cookie to an attacker-controlled host.

This is a regression introduced by the SSRF hardening in commit d40c1c94 ("fix(server): guard the /fetch proxy against SSRF to link-local/metadata (#53)", #1717), shipped in v1.0.0. Before that change the route used node-fetch's default redirect: "follow", which strips authorization, www-authenticate, cookie, and cookie2 on cross-domain (or protocol-downgrading) redirects. The new hand-rolled redirect loop re-validates each hop against the SSRF guard but reuses the same headers object on every hop and never strips those sensitive headers.

Affected code

server/src/index.ts, safeProxyFetch() (around lines 951-988 at HEAD ac3c1a12):

async function safeProxyFetch(initialUrl, init) {
  let currentUrl = new URL(initialUrl);
  let method = init?.method ?? "GET";
  let body = init?.body;
  const headers = init?.headers ?? {};        // captured once
  for (let hop = 0; hop <= MAX_PROXY_REDIRECTS; hop++) {
    await assertSafeProxyTarget(currentUrl);
    const response = await fetch(currentUrl.toString(), {
      method, headers, body, redirect: "manual" // same headers reused every hop
    });
    if (response.status >= 300 && response.status < 400) {
      const location = response.headers.get("location");
      if (!location) return response;
      currentUrl = new URL(location, currentUrl);
      if ([301, 302, 303].includes(response.status)) { method = "GET"; body = undefined; }
      continue;                                 // no cross-origin header stripping
    }
    return response;
  }
  throw new ProxyTargetError("Too many redirects");
}

The /fetch proxy is used by the client to reach OAuth discovery/token endpoints and other remote resources, and those requests legitimately carry Authorization (and sometimes cookies). The endpoint URLs and their redirect responses are influenced by the remote MCP server the user is inspecting.

Impact

Confidentiality breach: OAuth bearer tokens and session cookies attached to a proxied request can be exfiltrated to an arbitrary attacker-controlled origin via a single redirect. This is the same credential-exfiltration class the SSRF commit was closing (metadata endpoint), reopened through a different vector. The SSRF address guard does not help here because the attacker's exfiltration host is an ordinary public address, not a blocked link-local/metadata one.

Reproduction

Standalone repro that copies the current safeProxyFetch redirect loop verbatim and runs it against the pre-commit behavior side by side. Host A (localhost) 302-redirects cross-domain to Host B (127.0.0.1), which echoes the headers it received. Both are loopback so the SSRF guard allows them; localhost vs 127.0.0.1 are different hostnames so this is a cross-domain hop per node-fetch's rules.

POST-COMMIT (manual loop):        {"sawAuth":"Bearer SECRET-TOKEN","sawCookie":"session=abc123"}
PRE-COMMIT  (node-fetch follow):  {"sawAuth":null,"sawCookie":null}

The post-commit loop delivers the bearer token and cookie to the cross-domain host; the previous plain fetch(url, ...) (default redirect: "follow") stripped both. In a real deployment the "Host B" would be an attacker-controlled server named as (or reached via a redirect from) an endpoint the inspected MCP server advertises.

node-fetch's stripping is at node_modules/node-fetch/src/index.js:209-213 (guarded by isDomainOrSubdomain / isSameProtocol), which the manual loop bypasses.

Suggested fix

When following a redirect whose target is not same-origin (or downgrades protocol), drop the sensitive headers before the next hop, mirroring node-fetch:

currentUrl = new URL(location, currentUrl);
const prevUrl = /* the URL just fetched */;
if (!isSameOriginOrSubdomain(prevUrl, currentUrl) || prevUrl.protocol !== currentUrl.protocol) {
  for (const name of ["authorization", "www-authenticate", "cookie", "cookie2"]) {
    delete headers[name];               // case-insensitive delete if headers may be mixed-case
  }
}

Header keys arrive from init.headers as a plain object, so the delete should be case-insensitive (match node-fetch, which lowercases). Reusing node-fetch's own isDomainOrSubdomain/isSameProtocol semantics keeps behavior consistent with the pre-regression path.

Notes

Reported privately per SECURITY.md. Apologies if any detail is slightly off or the CVSS is miscalibrated. I reproduced this locally and reasoned through the logic with the help of Claude Code, but I am a freshman still learning security review, so please correct anything I got wrong. Just trying to help keep the project safe.

Severity

Moderate

CVE ID

No known CVE

Weaknesses

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.