{"title":"Test MCP Clients for SSRF in Discovery, Redirects and DNS","excerpt":"A discovery client that checked the URL before fetching still returned fake cloud credentials once the server answered with a 302 or a rebinding DNS name. This guide builds the matrix that catches it: twelve spellings of 169.254.169.254, ranges taken from the IANA registries, a connector that checks every resolved address and dials the one it checked, and manual redirects re-validated on every hop. It runs on loopback stubs with a fake resolver, and shows what @modelcontextprotocol/client 2.4.0 already guards and what it leaves to you.","canonicalUrl":"https://automationtester.in/blog/ai-in-testing/test-mcp-ssrf-redirect-discovery-security","category":{"name":"AI in Testing","slug":"ai-in-testing"},"tags":["mcp","ssrf","oauth","security-testing","dns-rebinding","nodejs"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-11-02T13:30:00.000Z","dateModified":"2026-11-02T13:30:00.000Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/test-mcp-ssrf-redirect-discovery-security.webp","alt":"A dark schematic read left to right: a client panel sends a green request through a ringed checkpoint node up to a public server panel near the top. A dotted green line bounces from that server down to a second ringed checkpoint. From there the onward path toward a dashed orange enclosure holding two internal service panels turns into a dashed orange line that ends at a crossed-out circle before the enclosure, while a faint green line carries on from the public server to the right edge."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Every URL an MCP client fetches during OAuth discovery can be chosen by the server it is connecting to. Test the fetcher, not the URL check. Parse with the platform's URL parser, refuse every special-purpose range in the IANA registries, and resolve each name once, at connect time, checking every address and connecting to the one you checked. Turn off automatic redirects and send each hop back through the same checks. Put one deadline over the whole fetch and a byte cap on the body. Prove it with a stub server and a resolver you control, so no test packet goes anywhere real.</div>\n\n<p>A server-side MCP client validates discovery URLs before it fetches them. It parses the URL, resolves the host, compares the address against a private-range list and only then calls <code>fetch</code>. Code review passes it, and so does a unit test that feeds it <code>http://169.254.169.254/</code>. Then a server answers its authorization-server metadata request with a 302 to the cloud metadata address, and the client follows it:</p>\n\n<pre><code class=\"language-text\">client=naive\nmetadata IP, octal                       refused: blocked: 169.254.169.254 is private (0 ms)\n302 to http://169.254.169.254            200 66 bytes (2 ms)\n    dial 203.0.113.10:443\n    dial 169.254.169.254:80\n    METADATA STUB served /latest/meta-data/iam/security-credentials/\nrebinding name (public, then metadata)   200 66 bytes (2 ms)\n    dial 169.254.169.254:443\n    METADATA STUB served /ok</code></pre>\n\n<p>Those lines are an excerpt from the lab behind this article. The check was correct and the client was still exploitable: it ran on the first URL and the first DNS answer, and the request used neither. <a href=\"/blog/ai-in-testing/test-mcp-authorization-resource-audience-scopes\">Part 3</a> tested the protected resource metadata a server publishes; this part tests the client that reads it, and any MCP server whose tools fetch a URL. <a href=\"/blog/ai-in-testing/test-mcp-prompt-injection-data-exfiltration\">Part 5</a> covered what a model can be talked into. This attacker needs no model, only an HTTP server.</p>\n\n<div class=\"callout callout-warning\"><strong>Versions checked on 2026-10-26:</strong> MCP specification 2026-07-28 (current), compared with 2025-11-25 where they differ; <code>@modelcontextprotocol/client</code> 2.4.0 (published 2026-10-23); undici 8.12.3, imported explicitly because the lab's Node.js 26.0.0 (macOS arm64) bundles 8.0.2. Node.js 26.12.1, the newest release that day, was not used. The Client ID Metadata Document draft is at -02 (2026-07-06); MCP still links -00. The lab replaces DNS with a lookup table and the network with a dial table that maps each fake address to a stub on 127.0.0.1, and 203.0.113.0/24 plays the public internet.</div>\n\n<h2 id=\"mcpssrf-fetch-inventory\">The URLs a client fetches before it holds a token</h2>\n\n<p>The MCP security best-practices page lists three places a malicious server can plant a URL during discovery: the <code>resource_metadata</code> parameter of the <code>WWW-Authenticate</code> header, the <code>authorization_servers</code> entries in the protected resource metadata, and the <code>token_endpoint</code>, <code>authorization_endpoint</code> and other URLs in the authorization server metadata. The 2026-07-28 discovery rules multiply them: up to two well-known locations for the resource metadata, then three metadata locations for an issuer with a path. One authorization server can mean five GET requests before any user interaction, plus a POST to a token endpoint the attacker also named.</p>\n\n<table>\n<thead><tr><th>Fetch</th><th>Who picks the URL</th><th>What a test should feed it</th></tr></thead>\n<tbody>\n<tr><td>Protected resource metadata</td><td>The MCP server, through <code>resource_metadata</code></td><td>Private, link-local and encoded addresses; <code>http:</code>; a 302</td></tr>\n<tr><td>Authorization server metadata</td><td>The MCP server, through <code>authorization_servers</code></td><td>An issuer whose well-known URL redirects, or whose name resolves privately</td></tr>\n<tr><td>Token and registration endpoints</td><td>The authorization server's metadata</td><td>Internal hosts, because a POST there can change state</td></tr>\n<tr><td>Client ID Metadata Document</td><td>The client, fetched by the authorization server</td><td>The same rows, run against your authorization server</td></tr>\n<tr><td>A tool that takes a URL</td><td>The model, steered by whatever it read</td><td>The same rows, through the tool's handler</td></tr>\n</tbody>\n</table>\n\n<p>The 2026-07-28 revision added a rule that looks as if it helps: the client MUST reject authorization server metadata whose <code>issuer</code> is not identical to the issuer used to build the URL, per RFC 8414 section 3.3. The 2025-11-25 text had no such requirement. It decides whether the client may <em>use</em> a response, and by then the request has gone out and done whatever it does.</p>\n\n<h2 id=\"mcpssrf-sdk-baseline\">What the TypeScript client does on its own</h2>\n\n<p>In <code>@modelcontextprotocol/client</code> 2.4.0, discovery requests go through <code>fetchWithinOrigin</code>, which sends <code>redirect: 'manual'</code> and follows a redirect only when it stays on the same origin and keeps the method, up to five hops. That is the transports' default <code>redirectPolicy: 'same-origin'</code>. Set <code>'follow'</code> and redirects are left to the fetch implementation, which follows them to any origin.</p>\n\n<p>The client does not look at addresses. <code>auth.ts</code> at v2.4.0 has no range check, and the <code>resource_metadata</code> value is fetched as parsed, <code>http:</code> included. The one scheme check guards the token endpoint, with a loopback exemption; the registration endpoint gets a POST without one. The lab passed a recording fetch to the discovery functions, so nothing touched a network:</p>\n\n<pre><code class=\"language-text\">-- resource_metadata pointing at the metadata address\nresult: ZodError [\nfetch manual http://169.254.169.254/latest/meta-data/\n-- AS metadata that redirects cross-origin, then same-origin\nfetch manual https://auth.example.test/.well-known/oauth-authorization-server\nfetch manual https://auth.example.test/.well-known/openid-configuration\nfetch manual https://auth.example.test/oidc.json\nresult: issuer https://auth.example.test</code></pre>\n\n<p>The first request went out; only its response failed schema validation. In the second case the cross-origin 302 was not followed, the client moved to the next well-known location, and a same-origin redirect there was followed. The SDK closes cross-origin hops and leaves the first hop of every fetch to you. The seam is the <code>fetch</code> option of <code>StreamableHTTPClientTransport</code>, which it also hands to its OAuth requests, and the <code>fetchFn</code> parameter of the discovery functions. A guarded fetch goes there, in the shape of <code>fetch</code> itself: it must accept the request's <code>RequestInit</code>, so methods, headers and bodies survive, and return a <code>Response</code>. The <code>safeFetch</code> below returns <code>{ status, body }</code> to keep the matrix readable; return the final <code>Response</code> from its loop and forward <code>init</code> on each hop before you pass it to the transport.</p>\n\n<h2 id=\"mcpssrf-parse-not-regex\">Parse with the platform, then check the parsed address</h2>\n\n<p>The best-practices page warns against hand-written IP validation, because of octal, hex and IPv4-mapped IPv6 forms. The lab ran twelve spellings of the metadata address through a hand-written splitter (split on dots, <code>Number()</code> each part) and through <code>net.BlockList</code>, first on the raw host and then on the hostname that <code>new URL()</code> produced:</p>\n\n<pre><code class=\"language-text\">raw host                      URL hostname           hand(raw)        hand(parsed)     BlockList\n169.254.169.254            -&gt; 169.254.169.254        BLOCK            BLOCK            BLOCK\n2852039166                 -&gt; 169.254.169.254        not an IP, allow BLOCK            BLOCK\n0xa9.0xfe.0xa9.0xfe        -&gt; 169.254.169.254        BLOCK            BLOCK            BLOCK\n0251.0376.0251.0376        -&gt; 169.254.169.254        allow            BLOCK            BLOCK\n169.254.43518              -&gt; 169.254.169.254        not an IP, allow BLOCK            BLOCK\n%31%36%39.254.169.254      -&gt; 169.254.169.254        not an IP, allow BLOCK            BLOCK\n169.254.169.254.           -&gt; 169.254.169.254        not an IP, allow BLOCK            BLOCK\n[::ffff:169.254.169.254]   -&gt; [::ffff:a9fe:a9fe]     not an IP, allow not an IP, allow BLOCK\n[::ffff:a9fe:a9fe]         -&gt; [::ffff:a9fe:a9fe]     not an IP, allow not an IP, allow BLOCK\n[64:ff9b::a9fe:a9fe]       -&gt; [64:ff9b::a9fe:a9fe]   not an IP, allow not an IP, allow allow\n0                          -&gt; 0.0.0.0                not an IP, allow allow            allow\n①②⑦.0.0.1                  -&gt; 127.0.0.1              not an IP, allow BLOCK            BLOCK</code></pre>\n\n<p>The WHATWG parser behind <code>new URL()</code> rewrites decimal, octal, hex, short-form, percent-encoded, trailing-dot and circled-digit hosts into dotted decimal, so a check on <code>url.hostname</code> sees what the socket will see. The octal row is the dangerous one: <code>Number('0251')</code> is 251, so the splitter reads a public address where the parser reads 169.254.169.254. <code>BlockList</code> matches an IPv4 rule against the IPv4-mapped form, which the Node.js documentation shows for single addresses and the lab confirmed for subnets. The NAT64 and <code>0</code> rows pass even a correct parser, because the list is missing ranges.</p>\n\n<h2 id=\"mcpssrf-registry-ranges\">Refuse the registry, not a remembered list</h2>\n\n<p>The best-practices page names 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, <code>::1</code>, <code>fc00::/7</code> and <code>fe80::/10</code>. RFC 9728 section 7.7 only says clients should block \"internal IP address ranges\". The IANA special-purpose registries list more blocks that are not globally reachable, and some matter for a fetcher:</p>\n\n<ul>\n<li><strong>0.0.0.0/8, \"this network\".</strong> On the lab's macOS host, <code>net.connect</code> to 0.0.0.0 reached a listener bound to 127.0.0.1: <code>connect host=0.0.0.0 remote=127.0.0.1</code>. A loopback check that only knows 127/8 lets it through.</li>\n<li><strong>100.64.0.0/10, shared address space.</strong> The carrier-grade NAT block from RFC 6598, private to whichever network uses it.</li>\n<li><strong>64:ff9b::/96, the NAT64 well-known prefix.</strong> The registry marks it globally reachable. Its last 32 bits are an IPv4 address (RFC 6052), so <code>64:ff9b::a9fe:a9fe</code> spells 169.254.169.254. RFC 6052 section 3.1 tells translators to drop packets combining this prefix with a non-global IPv4 address, so whether it reaches anything depends on someone else's translator. Checking it costs one function.</li>\n</ul>\n\n<p>The CIMD draft has already moved this way. Revision -00, which MCP links, says authorization servers SHOULD avoid fetching private or loopback addresses. Revision -02 says they MUST NOT fetch a Client ID Metadata Document URL, or any URL inside one, that resolves to a special-use address as defined in RFC 6890, which is the registry above. It also says the server MUST NOT follow redirects when fetching the document. The lab's policy is built from the registry:</p>\n\n<pre><code class=\"language-js\">// policy.mjs: which destination addresses a discovery or tool fetch may reach.\nimport net from 'node:net';\n\n// IANA special-purpose registries (checked 2026-10-26): blocks not marked\n// globally reachable, plus multicast, broadcast and 6to4.\nconst V4 = [\n  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],\n  ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],\n  ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],\n  ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],\n];\nconst V6 = [\n  ['::', 128], ['::1', 128], ['64:ff9b:1::', 48], ['100::', 64], ['2001::', 23],\n  ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20], ['5f00::', 16],\n  ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],\n];\n\nexport function buildPolicy({ exempt = [] } = {}) {\n  const deny = new net.BlockList();\n  for (const [a, p] of V4) deny.addSubnet(a, p, 'ipv4');   // also matches ::ffff:a.b.c.d\n  for (const [a, p] of V6) deny.addSubnet(a, p, 'ipv6');\n  const allow = new net.BlockList();                        // explicit opt-outs only\n  for (const [a, p, t] of exempt) allow.addSubnet(a, p, t);\n\n  return function verdict(address) {\n    const family = net.isIP(address);\n    if (family === 0) return 'not an IP address';\n    const type = family === 6 ? 'ipv6' : 'ipv4';\n    if (allow.check(address, type)) return null;\n    if (deny.check(address, type)) return `${address} is in a special-purpose range`;\n    const v4 = family === 6 ? nat64Embedded(address) : null; // 64:ff9b::/96 carries an IPv4 address\n    if (v4 &amp;&amp; deny.check(v4, 'ipv4')) return `${address} embeds ${v4}`;\n    return null;                                              // null means allowed\n  };\n}\n// nat64Embedded() expands the address and reads the last 32 bits as IPv4.</code></pre>\n\n<p>The <code>exempt</code> list is the explicit opt-out the best-practices page asks for in development. The lab exempts 203.0.113.0/24 to stand in for public hosts; a development build might exempt <code>127.0.0.1/32</code>, from configuration production never loads. CIMD -02 forbids the loopback exception in production outright.</p>\n\n<p>If your client only talks to a known set of authorization servers, use an allowlist of issuers instead; the OWASP SSRF cheat sheet treats deny lists as a last resort. A client that connects to whatever server a user pastes in is OWASP's second case, where destinations are unknown in advance and a deny list on resolved addresses is the practical control.</p>\n\n<h2 id=\"mcpssrf-wrong-and-right\">Check at connect time, on the address you connect to</h2>\n\n<p>Here is the client from the opening. Its list is the one from the best-practices page. Its <code>resolve</code> and dial layer are the lab's fake network, which tries each address in turn the way Node.js does with <code>autoSelectFamily</code>:</p>\n\n<pre><code class=\"language-js\">// Wrong: check the name once, then hand the URL to fetch.\nreturn async function naiveFetch(input) {\n  const url = new URL(input);\n  const host = url.hostname.replace(/^\\[|\\]$/g, '');\n  const [first] = net.isIP(host) ? [host] : await resolve(host); // time of check\n  const reason = verdict(first);\n  if (reason) throw new Error(`blocked: ${reason}`);\n  const res = await fetch(url, { dispatcher: agent });          // time of use; redirects followed\n  return { status: res.status, body: await res.text() };\n};</code></pre>\n\n<p>Its three failures have three causes. <code>fetch</code> follows redirects, so the check never sees hop two. <code>fetch</code> resolves the name again, so check and connection can get different answers: DNS rebinding. And the check reads only the first address while the connection falls back to the next. None is a bug in the list. The OWASP cheat sheet states the fix: validate the resolved addresses, connect only to them while keeping the original hostname for the <code>Host</code> header, SNI and certificate verification, and apply the policy to retries and fallback connections too.</p>\n\n<p>In undici that place is the <code>connect</code> option of a dispatcher. undici calls it for every new connection with the hostname, port and protocol, and expects a connected socket back:</p>\n\n<pre><code class=\"language-js\">// safe.mjs: resolve once, validate every address, connect to a validated\n// address, and handle each redirect hop as a new request.\nconst agent = new Agent({\n  connect(opts, cb) {\n    const host = opts.hostname.replace(/^\\[|\\]$/g, '');\n    (net.isIP(host) ? Promise.resolve([host]) : resolve(host)).then((addrs) =&gt; {\n      for (const a of addrs) {\n        const reason = verdict(a);\n        if (reason) throw new PolicyError(a === host ? reason : `${host} -&gt; ${reason}`);\n      }\n      dial(addrs[0], opts, cb);            // the address that was checked, nothing else\n    }).catch(cb);\n  },\n});</code></pre>\n\n<p>In production, <code>resolve</code> is <code>dns.promises.lookup(host, { all: true })</code> mapped to addresses, and <code>dial</code> calls a connector from undici's <code>buildConnector()</code> with <code>hostname</code> set to the checked IP and <code>servername</code> set to the original name, so TLS still verifies the certificate against the name. On loopback, that connector with a test CA connected for <code>servername=public.example.test</code> and failed with <code>ERR_TLS_CERT_ALTNAME_INVALID</code> for any other name. Never put an IP in <code>servername</code> for a plain <code>http:</code> target: undici 8.12.3's connector then connects to <code>servername</code> instead of <code>hostname</code>, with no lookup to pin it. A probe with <code>hostname=127.0.0.2</code> and <code>servername=127.0.0.1</code> printed <code>connected to 127.0.0.1</code>, the address nobody checked. In the lab, <code>dial</code> maps the address to a loopback stub with the same certificate check. Refusing a whole answer when any address is bad is a choice: a name that resolves to public and private addresses at once is misconfigured or hostile, and you want to know either way.</p>\n\n<p>The tempting shortcut is the <code>lookup</code> option that <code>net.connect</code> accepts. The lab shows its two traps:</p>\n\n<pre><code class=\"language-text\">autoSelectFamily default: true\nconnect host=stub.example.test  remote=127.0.0.1 lookup calls=[{\"hostname\":\"stub.example.test\",\"all\":true}]\nconnect host=127.0.0.1          remote=127.0.0.1 lookup calls=[]\nconnect host=0.0.0.0            remote=127.0.0.1 lookup calls=[]</code></pre>\n\n<p>With <code>autoSelectFamily</code> on, the default, the lookup gets <code>all: true</code> and the socket tries every address returned, so a lookup that checks only the first is the naive client again. And an IP-literal host never calls the lookup, so a lookup-only policy never sees <code>0.0.0.0</code> or an octal metadata URL. A connector that checks literals itself has neither gap.</p>\n\n<p>Redirects are the second half. With <code>redirect: 'manual'</code>, Node's fetch hands back the real 3xx and its <code>Location</code>; the lab's probe printed <code>status=302 type=basic location=http://169.254.169.254/latest/meta-data/</code>. So each hop can be a new request:</p>\n\n<pre><code class=\"language-js\">return async function safeFetch(input, { maxHops = 3, deadlineMs = 2000, maxBytes = 5 * 1024 } = {}) {\n  const signal = AbortSignal.timeout(deadlineMs); // one deadline for every hop and the body\n  let url = new URL(input);\n  for (let hop = 0; ; hop++) {\n    try { checkUrl(url); } catch (e) { trace(`hop ${hop} ${url.href} refused: ${e.message}`); throw e; }\n    let res;\n    try {\n      res = await fetch(url, { dispatcher: agent, redirect: 'manual', signal });\n    } catch (e) {\n      const cause = e.cause instanceof PolicyError ? e.cause : e;\n      trace(`hop ${hop} ${url.href} refused: ${cause.message}`);\n      throw cause;\n    }\n    const location = res.headers.get('location');\n    if (res.status &gt;= 300 &amp;&amp; res.status &lt; 400 &amp;&amp; location) {\n      await res.body?.cancel();\n      const next = new URL(location, url);\n      trace(`hop ${hop} ${url.href} ${res.status} -&gt; ${next.href}`);\n      if (hop === maxHops) throw new PolicyError(`more than ${maxHops} redirects`);\n      url = next;\n      continue;\n    }\n    trace(`hop ${hop} ${url.href} ${res.status}`);\n    return { status: res.status, body: await readCapped(res, maxBytes) };\n  }\n};\n\nfunction checkUrl(url) {\n  if (url.protocol !== 'https:') throw new PolicyError(`scheme ${url.protocol} not allowed`);\n  if (url.username || url.password) throw new PolicyError('credentials in URL');\n}\n\nasync function readCapped(res, maxBytes) {\n  const declared = Number(res.headers.get('content-length') ?? 0);\n  if (declared &gt; maxBytes) { await res.body?.cancel(); throw new PolicyError(`content-length ${declared} &gt; ${maxBytes}`); }\n  const chunks = []; let total = 0;\n  for await (const chunk of res.body) {\n    total += chunk.length;\n    if (total &gt; maxBytes) throw new PolicyError(`body exceeded ${maxBytes} bytes`);\n    chunks.push(chunk);\n  }\n  return Buffer.concat(chunks).toString('utf8');\n}</code></pre>\n\n<p><code>checkUrl</code> enforces HTTPS, which the best-practices page says clients SHOULD require for OAuth-related URLs. The address check stays in the connector, the only code that knows which address is about to be used. Discovery can refuse cross-origin redirects outright, as the SDK does; the loop follows them through the checks because a tool fetching user-supplied pages usually has to.</p>\n\n<h2 id=\"mcpssrf-stub-matrix\">The matrix, against both clients</h2>\n\n<p>Each row is one URL, an expected outcome and a fresh resolver, so the rebinding name always answers public first. Run the rows against the naive client too: a row it also passes tests nothing. The safe client, with its hop trace:</p>\n\n<pre><code class=\"language-text\">client=safe\npublic metadata document                 200 40 bytes (11 ms)\nmetadata IP, octal                       refused: 169.254.169.254 is in a special-purpose range (1 ms)\nmetadata IP, IPv4-mapped IPv6            refused: ::ffff:a9fe:a9fe is in a special-purpose range (1 ms)\nmetadata IP via NAT64 prefix             refused: 64:ff9b::a9fe:a9fe embeds 169.254.169.254 (0 ms)\n\"this network\" 0.0.0.0                   refused: 0.0.0.0 is in a special-purpose range (1 ms)\n302 to http://169.254.169.254            refused: scheme http: not allowed (5 ms)\n    dial 203.0.113.10:443\n    hop 0 https://public.example.test/discover 302 -&gt; http://169.254.169.254/latest/meta-data/iam/security-credentials/\n    hop 1 http://169.254.169.254/latest/meta-data/iam/security-credentials/ refused: scheme http: not allowed\n302 to https name -&gt; 169.254.169.254     refused: meta.example.test -&gt; 169.254.169.254 is in a special-purpose range (3 ms)\n    dial 203.0.113.10:443\n    hop 0 https://public.example.test/discover-https 302 -&gt; https://meta.example.test/latest/meta-data/iam/security-credentials/\n    hop 1 https://meta.example.test/latest/meta-data/iam/security-credentials/ refused: meta.example.test -&gt; 169.254.169.254 is in a special-purpose range\nrebinding name (public, then metadata)   200 40 bytes (2 ms)\n    dial 203.0.113.10:443\ndead public address, then 10.0.0.5       refused: mixed.example.test -&gt; 10.0.0.5 is in a special-purpose range (1 ms)\n10 MB response                           refused: body exceeded 5120 bytes (9 ms)\ndrip-fed body, 4.5 s                     refused: The operation was aborted due to timeout (2004 ms)</code></pre>\n\n<p>Trace lines for single-hop rows are left out of both runs. The naive client on the rows not shown at the top:</p>\n\n<pre><code class=\"language-text\">client=naive\nmetadata IP via NAT64 prefix             refused: lab network: no route to [64:ff9b::a9fe:a9fe]:443 (1 ms)\n    dial [64:ff9b::a9fe:a9fe]:443\n\"this network\" 0.0.0.0                   refused: lab network: no route to 0.0.0.0:443 (1 ms)\n    dial 0.0.0.0:443\ndead public address, then 10.0.0.5       200 14 bytes (1 ms)\n    dial 203.0.113.99:443\n    dial 10.0.0.5:443\n    INTERNAL STUB served /ok\n10 MB response                           200 10485760 bytes (21 ms)\ndrip-fed body, 4.5 s                     200 17 bytes (4518 ms)</code></pre>\n\n<p>The two redirect rows fail at different layers, which is why both exist: the <code>http:</code> hop stops at <code>checkUrl</code>, and the HTTPS hop to a name resolving to the metadata address stops in the connector, before any socket opens. Against the naive client, the NAT64 and 0.0.0.0 rows reached the dial layer, a \"no route\" error in the lab and whatever answers there on a real host.</p>\n\n<p>The drip row is a trap in undici's own options. <code>bodyTimeout</code> measures the time between body chunks, per the dispatcher documentation, so one byte every 300 ms never trips the naive client's 1,000 ms setting, and it waited out the whole drip. One <code>AbortSignal.timeout</code> across every hop and the body bounds the fetch. For size, CIMD -02 recommends reading at most 5 kilobytes of a client metadata document. Authorization server metadata has no such figure, so measure the documents your deployment fetches and cap above them.</p>\n\n<p>A refusal should leave a hop trace and a policy reason behind, and no response body. A metadata service's answer never belongs in a log, an error message or a model's context.</p>\n\n<h2 id=\"mcpssrf-server-side-fetch\">When the server is the one fetching</h2>\n\n<p>The same client belongs under any MCP tool that takes a URL. An authorization server that fetches Client ID Metadata Documents, which the 2026-07-28 best-practices page added as its own SSRF case, needs the same connector with a stricter profile: CIMD -02 says it MUST NOT follow redirects, so call <code>safeFetch</code> with <code>maxHops: 0</code> and treat any 3xx as a refusal. In a tool, what the model sees and what you log should differ:</p>\n\n<pre><code class=\"language-js\">async function fetchPage({ url }) {\n  try {\n    const { status, body } = await safeFetch(url);\n    return { content: [{ type: 'text', text: `HTTP ${status}\\n${body}` }] };\n  } catch (e) {\n    const reason = e instanceof PolicyError ? 'destination not allowed' : 'fetch failed';\n    console.error(JSON.stringify({ tool: 'fetch_page', url, refused: e.message, hops: audit.splice(0) }));\n    return { isError: true, content: [{ type: 'text', text: `Could not fetch ${url}: ${reason}.` }] };\n  }\n}</code></pre>\n\n<pre><code class=\"language-text\">{\"tool\":\"fetch_page\",\"url\":\"https://public.example.test/discover-https\",\"refused\":\"meta.example.test -&gt; 169.254.169.254 is in a special-purpose range\",\"hops\":[\"hop 0 https://public.example.test/discover-https 302 -&gt; https://meta.example.test/latest/meta-data/iam/security-credentials/\",\"hop 1 https://meta.example.test/latest/meta-data/iam/security-credentials/ refused: meta.example.test -&gt; 169.254.169.254 is in a special-purpose range\"]}\n{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"Could not fetch https://public.example.test/discover-https: destination not allowed.\"}]}</code></pre>\n\n<p>The model learns that the fetch was refused, not which internal address the name resolved to. The best-practices page counts error messages that reveal internal topology among the risks, and a model can repeat them to whoever planted the URL. Nor does the model decide whether a destination is safe: it can ask for a URL, and the connector answers the same way whatever it says.</p>\n\n<h2 id=\"mcpssrf-limits\">What the guarded client costs, and what it cannot see</h2>\n\n<p>Owning the connector means owning what the platform used to handle: lookup errors, IPv6 preference, connect timeouts. Pinning one address gives up fallback across a host's other addresses, which is the part the wrong version showed to be dangerous. Pooling is unaffected, since undici calls the connector only for new connections.</p>\n\n<p>The guard sees only direct connections. Behind an HTTP egress proxy the client resolves nothing and connects only to the proxy, so the proxy must enforce the policy. The best-practices page suggests one, such as Smokescreen, for server-side clients; that makes it a second place to run the matrix. The lab cannot exercise a network-specific NAT64 prefix, and its rebinding row tests one property, one resolution per connection, not a real DNS server with a zero TTL. Write both down as known gaps.</p>\n\n<h2 id=\"mcpssrf-apply-today\">Add the first rows this week</h2>\n\n<p>List every place your MCP client or server fetches a URL it did not write: discovery, token and registration requests, tools, webhooks. Stand up a stub HTTPS server with a test CA and a resolver table, and write six rows: an octal metadata IP, an IPv4-mapped one, 0.0.0.0, a 302 to <code>http://169.254.169.254</code>, an HTTPS redirect to a name that resolves there, and a name that answers public then private. Run them against the client you have before changing it, then put a guarded fetch in the transport's <code>fetch</code> option and run them again. Keep both runs, each refusal with its hop trace and policy reason, and a check that no response body reached a log line. When you add 301, 303, 307 and 308 rows, the <a href=\"/tools/http-status\">HTTP status reference</a> helps; the SDK's origin check follows a non-GET request only on 307 and 308.</p>\n\n<h2 id=\"mcpssrf-faq\">Questions about SSRF in MCP</h2>\n\n<h3 id=\"mcpssrf-faq-imdsv2\">If the cloud metadata service needs a session token, is this still worth testing?</h3>\n<p>Yes. The OWASP cheat sheet calls IMDSv2 defence in depth that mitigates some SSRF cases, not a fix. The same client can also reach databases, admin panels and internal APIs that ask for no token at all, and the MCP best-practices page points out that a POST to a planted token endpoint can change state on an internal service.</p>\n\n<h3 id=\"mcpssrf-faq-desktop\">Does a desktop or stdio client need this?</h3>\n<p>The best-practices MUST covers clients deployed to a server. A laptop client can still be pointed at the user's router or a service on <code>localhost</code>, so keep the HTTPS rule and loopback refusal, with a development opt-out. A stdio server never runs OAuth discovery, but its tools can still fetch URLs.</p>\n\n<h3 id=\"mcpssrf-faq-conformance\">Does the conformance suite catch any of this?</h3>\n<p>Its <code>dns-rebinding-protection</code> scenario, covered in <a href=\"/blog/ai-in-testing/mcp-conformance-framework-ci\">part 2</a>, tests a different defence: a local server rejecting a foreign <code>Host</code> or <code>Origin</code> header. A search of both published packages on 2026-10-26, 0.1.16 and 0.2.0-alpha.12, finds no scenario that mentions SSRF or the 169.254 range, so the rows here are your own tests.</p>\n\n<h2 id=\"mcpssrf-references\">Primary references</h2>\n<ul>\n<li><a href=\"https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices\">MCP security best practices, SSRF section (2026-07-28)</a>. The three discovery URL sources, the attack patterns, the MUST for server-deployed clients, HTTPS with a loopback development opt-out, the listed private ranges, the warning against manual IP parsing, per-hop redirect validation, egress proxies, DNS TOCTOU, and the new subsection on authorization servers fetching Client ID Metadata Documents.</li>\n<li><a href=\"https://modelcontextprotocol.io/docs/2025-11-25/tutorials/security/security_best_practices\">MCP security best practices as of 2025-11-25</a>. The earlier SSRF section, without the authorization-server subsection.</li>\n<li><a href=\"https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery\">Authorization server discovery in MCP 2026-07-28</a>. The <code>resource_metadata</code>-then-well-known order, the three and two metadata locations, and the MUST to reject metadata whose issuer is not identical.</li>\n<li><a href=\"https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration\">MCP client registration, 2026-07-28</a>. Client ID Metadata Documents as HTTPS URLs fetched by the authorization server, and Dynamic Client Registration deprecated.</li>\n<li><a href=\"https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations\">Security considerations for MCP authorization, 2026-07-28</a>. HTTPS for authorization server endpoints, and the SSRF note for authorization servers fetching metadata documents.</li>\n<li><a href=\"https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization\">MCP authorization, revision 2025-11-25</a>. The same discovery order without the issuer-identity requirement.</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc9728.html\">OAuth 2.0 Protected Resource Metadata (RFC 9728)</a>. Section 7.7: clients fetch authorization server metadata from server-supplied values and SHOULD block internal ranges.</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc8414.html\">RFC 8414, OAuth 2.0 Authorization Server Metadata</a>. The well-known suffix and the section 3.3 rule that a non-identical issuer means the metadata MUST NOT be used.</li>\n<li><a href=\"https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-02.html\">OAuth Client ID Metadata Document, draft -02</a>. No automatic redirects, MUST NOT fetch special-use addresses per RFC 6890, no loopback exception in production, and the 5 KB read limit.</li>\n<li><a href=\"https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html\">Client ID Metadata Document draft -00, the revision MCP links</a>. The earlier SHOULD-level SSRF wording.</li>\n<li><a href=\"https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml\">IANA IPv4 Special-Purpose Address Registry</a>. 0.0.0.0/8, 100.64.0.0/10 and the other blocks not marked globally reachable.</li>\n<li><a href=\"https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml\">IANA IPv6 Special-Purpose Address Registry</a>. IPv4-mapped, unique-local and link-local blocks, and 64:ff9b::/96 marked globally reachable.</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc6052.html\">RFC 6052, IPv6 addressing of IPv4/IPv6 translators</a>. The 64:ff9b::/96 well-known prefix with the IPv4 address in its last 32 bits, and translators required to drop non-global targets.</li>\n<li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html\">OWASP SSRF Prevention Cheat Sheet</a>. Disabling redirects, validating resolved addresses and connecting only to them while keeping Host, SNI and certificate checks, deny lists as a last resort, and IMDSv2 as defence in depth.</li>\n<li><a href=\"https://nodejs.org/docs/latest-v26.x/api/net.html\">Node.js 26 net module</a>. <code>BlockList</code> matching IPv4 rules against IPv4-mapped addresses, the <code>lookup</code> option, and <code>autoSelectFamily</code> passing <code>all: true</code> and trying every address.</li>\n<li><a href=\"https://github.com/nodejs/undici/blob/v8.12.3/docs/docs/api/Connector.md\">undici Connector documentation at v8.12.3</a>. The <code>connect</code> option, <code>buildConnector</code>, and the per-connection options a connector receives.</li>\n<li><a href=\"https://github.com/nodejs/undici/blob/v8.12.3/docs/docs/api/Dispatcher.md\">undici Dispatcher documentation at v8.12.3</a>. <code>bodyTimeout</code> measuring the time between body chunks.</li>\n<li><a href=\"https://github.com/modelcontextprotocol/typescript-sdk/blob/v2.4.0/packages/core-internal/src/shared/transport.ts\">TypeScript SDK shared transport at v2.4.0</a>. <code>fetchWithinOrigin</code>: manual redirects, same-origin and same-method hops only, five at most.</li>\n<li><a href=\"https://github.com/modelcontextprotocol/typescript-sdk/blob/v2.4.0/packages/client/src/client/auth.ts\">TypeScript SDK client auth at v2.4.0</a>. Discovery fetching <code>resource_metadata</code> as given, the <code>fetchFn</code> parameter, and the HTTPS check limited to token endpoints.</li>\n<li><a href=\"https://github.com/nodejs/undici/blob/v8.12.3/lib/core/connect.js\">undici connector source at v8.12.3</a>. A plain-HTTP connection with an IP <code>hostname</code> and a different <code>servername</code> connecting to <code>servername</code> through a pinned lookup.</li>\n<li><a href=\"https://github.com/modelcontextprotocol/conformance\">MCP conformance repository</a>. The scenario sets of the packages searched for SSRF coverage.</li>\n</ul>\n"}