From 109cbfca25883b6b079b1eea7d8a0158eb592e4b Mon Sep 17 00:00:00 2001 From: GabrielDrapor Date: Sat, 22 Aug 2026 21:54:06 +0800 Subject: [PATCH 1/4] fix(core): classify IPv6 literals by parsed bytes in the private-range SSRF gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPrivateRangeHost matched hostname spellings, and WHATWG URL parsing canonicalizes them: '[::ffff:127.0.0.1]' reaches the gate as '[::ffff:7f00:1]', which matched nothing — so every IPv4-mapped spelling of a loopback or private destination sailed through the remote-provenance https check (fail open). The 'fe8' prefix check also covered only a quarter of fe80::/10, missing fe9x/feax/febx link-local spellings. The literal now parses to its 16 bytes (minimal RFC 4291 text parser) and classifies structurally: IPv4-mapped (::ffff/96) and NAT64 (64:ff9b::/96) embeddings classify by their embedded IPv4 — including 127/8, which lands on the PRIVATE side of the gate on purpose. isLoopbackHost deliberately does NOT learn the mapped spellings: its unknown-spelling default must stay 'not loopback' so cleartext is refused (fail closed), while this gate's must stay 'private' so the request is blocked (also fail closed) — the two defaults point in opposite directions, per review guidance on #2653. An unparseable bracketed literal is treated as private. Regressions: core unit coverage across spellings, the full fe80::/10 range, NAT64, global-IPv6 reachability, and garbage-in-brackets; plus transport-security end-to-end cases asserting mapped private/loopback https is refused under remote provenance while cleartext to the mapped loopback stays refused by the strict loopback predicate. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj --- .../__tests__/mcp-host-classification.test.ts | 64 +++++++++++ packages/core/src/mcp.ts | 104 ++++++++++++++++-- .../src/__tests__/transport-security.test.ts | 29 +++++ 3 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/__tests__/mcp-host-classification.test.ts diff --git a/packages/core/src/__tests__/mcp-host-classification.test.ts b/packages/core/src/__tests__/mcp-host-classification.test.ts new file mode 100644 index 0000000000..ee6671f9e3 --- /dev/null +++ b/packages/core/src/__tests__/mcp-host-classification.test.ts @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isLoopbackHost, isPrivateRangeHost } from '../mcp.js'; + +describe('MCP host classification', () => { + it('classifies IPv6 literals by parsed bytes, not spelling', () => { + // WHATWG URL parsing canonicalizes hostnames, so the same address + // arrives in different spellings depending on how it was written; the + // classifier must agree across all of them. + for (const spelling of [ + '[::ffff:127.0.0.1]', + '[::FFFF:127.0.0.1]', + '[::ffff:7f00:1]', + '[0:0:0:0:0:ffff:7f00:1]', + ]) { + assert.equal(isPrivateRangeHost(spelling), true, spelling); + // The asymmetry is deliberate: teaching isLoopbackHost the mapped + // spelling would flip its fail-closed cleartext refusal into + // fail-open. The mapped loopback must land on the PRIVATE side. + assert.equal(isLoopbackHost(spelling), false, spelling); + } + // The canonical URL round-trip agrees with the raw spellings. + assert.equal(isPrivateRangeHost(new URL('https://[::ffff:192.168.1.1]/').hostname), true); + assert.equal(isPrivateRangeHost(new URL('https://[64:ff9b::10.0.0.5]/').hostname), true); + }); + + it('covers the whole fe80::/10 link-local range, not just the fe8 spelling', () => { + for (const inner of ['fe80::1', 'fe9a::1', 'feaf::1', 'febf::1']) { + assert.equal(isPrivateRangeHost(`[${inner}]`), true, inner); + } + assert.equal(isPrivateRangeHost('[fec0::1]'), false); + }); + + it('keeps global addresses reachable and fails closed on garbage', () => { + assert.equal(isPrivateRangeHost('[2606:4700::1]'), false); + assert.equal(isPrivateRangeHost('[::ffff:808:808]'), false); // mapped 8.8.8.8 + assert.equal(isPrivateRangeHost('[64:ff9b::808:808]'), false); + assert.equal(isPrivateRangeHost('[::1]'), false); // isLoopbackHost's positive case + assert.equal(isLoopbackHost('[::1]'), true); + // An unparseable bracketed literal is treated as private: the SSRF + // gate fails closed on spellings it cannot classify. + assert.equal(isPrivateRangeHost('[not-an-address]'), true); + assert.equal(isPrivateRangeHost('[1::2::3]'), true); + }); +}); diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index a9dc697016..1318a5d0c1 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -81,25 +81,107 @@ export function isLoopbackHost(hostname: string): boolean { /** Private-range and link-local IP LITERALS (RFC 1918, RFC 3927/4291, * CGNAT). Hostname-based checks are deliberately out of scope: they would * need a resolve here and could still re-resolve differently at request - * time — callers treat privately-RESOLVING names as accepted risk. */ + * time — callers treat privately-RESOLVING names as accepted risk. + * + * IPv6 literals are classified by PARSED BYTES, not spelling: WHATWG URL + * parsing canonicalizes hostnames (`[::ffff:127.0.0.1]` arrives here as + * `[::ffff:7f00:1]`), so any spelling-based match is one canonicalization + * away from missing an address it means to cover. IPv4-mapped (::ffff/96) + * and NAT64 (64:ff9b::/96) embeddings classify by their embedded IPv4 — + * INCLUDING 127/8, which `isLoopbackHost` deliberately does not learn: + * there, an unrecognized spelling must stay "not loopback" so cleartext is + * refused (fail closed); here it must stay "private" so the SSRF gate + * blocks it (also fail closed) — the two defaults point in opposite + * directions on purpose. A bracketed literal that does not parse at all is + * therefore treated as private. */ export function isPrivateRangeHost(hostname: string): boolean { const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(hostname); if (v4) { - const [a, b] = [Number(v4[1]), Number(v4[2])]; - if (a === 10) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 168) return true; - if (a === 169 && b === 254) return true; - if (a === 100 && b >= 64 && b <= 127) return true; - return false; + return isPrivateIpv4(Number(v4[1]), Number(v4[2])); } - if (hostname.startsWith('[')) { - const inner = hostname.slice(1, -1).toLowerCase(); - return inner.startsWith('fc') || inner.startsWith('fd') || inner.startsWith('fe8'); + if (hostname.startsWith('[') && hostname.endsWith(']')) { + const bytes = parseIpv6(hostname.slice(1, -1)); + if (!bytes) return true; + return classifyIpv6(bytes) === 'private'; } return false; } +function isPrivateIpv4(a: number, b: number): boolean { + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +} + +/** Minimal RFC 4291 text-representation parser: enough to turn any spelling + * of an address into its 16 bytes so the classifier never depends on how a + * caller (or the URL canonicalizer) chose to write it. Returns undefined + * for anything that is not a well-formed address. */ +function parseIpv6(literal: string): Uint8Array | undefined { + const zone = literal.indexOf('%'); + const text = (zone === -1 ? literal : literal.slice(0, zone)).toLowerCase(); + const gap = text.indexOf('::'); + if (gap !== -1 && text.indexOf('::', gap + 1) !== -1) return undefined; + const parseGroups = (part: string): number[] | undefined => { + if (part === '') return []; + const out: number[] = []; + for (const piece of part.split(':')) { + if (piece.includes('.')) { + const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(piece); + if (!dotted) return undefined; + const octets = dotted.slice(1).map(Number); + if (octets.some((octet) => octet > 255)) return undefined; + out.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0), ((octets[2] ?? 0) << 8) | (octets[3] ?? 0)); + } else { + if (!/^[0-9a-f]{1,4}$/u.test(piece)) return undefined; + out.push(Number.parseInt(piece, 16)); + } + } + return out; + }; + let groups: number[] | undefined; + if (gap === -1) { + groups = parseGroups(text); + if (!groups || groups.length !== 8) return undefined; + } else { + const head = parseGroups(text.slice(0, gap)); + const tail = parseGroups(text.slice(gap + 2)); + if (!head || !tail || head.length + tail.length > 7) return undefined; + groups = [...head, ...new Array(8 - head.length - tail.length).fill(0), ...tail]; + } + const bytes = new Uint8Array(16); + groups.forEach((group, index) => { + bytes[2 * index] = group >> 8; + bytes[2 * index + 1] = group & 0xff; + }); + return bytes; +} + +function classifyIpv6(bytes: Uint8Array): 'loopback' | 'private' | 'global' { + const mapped = + bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff; + const nat64 = + bytes[0] === 0 && + bytes[1] === 0x64 && + bytes[2] === 0xff && + bytes[3] === 0x9b && + bytes.slice(4, 12).every((byte) => byte === 0); + if (mapped || nat64) { + const [a, b] = [bytes[12] ?? 0, bytes[13] ?? 0]; + // Mapped loopback lands on the PRIVATE side of the gate: isLoopbackHost + // stays strict-by-spelling, so this is the check that must catch it. + if (a === 127) return 'private'; + return isPrivateIpv4(a, b) ? 'private' : 'global'; + } + if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return 'loopback'; + if (((bytes[0] ?? 0) & 0xfe) === 0xfc) return 'private'; // fc00::/7 (ULA) + if (bytes[0] === 0xfe && ((bytes[1] ?? 0) & 0xc0) === 0x80) return 'private'; // fe80::/10 + return 'global'; +} + /** The composed rule the config store enforces, the runtime's fetch guard * re-checks per hop, and the editor mirrors onto the URL field: cleartext * http is only acceptable where it never leaves the machine. One diff --git a/packages/mcp/src/__tests__/transport-security.test.ts b/packages/mcp/src/__tests__/transport-security.test.ts index 41c47b6f3d..0370aaf772 100644 --- a/packages/mcp/src/__tests__/transport-security.test.ts +++ b/packages/mcp/src/__tests__/transport-security.test.ts @@ -69,6 +69,35 @@ describe('transport security provenance', () => { ); }); + it('refuses IPv6-mapped spellings of machine-local and private destinations', () => { + // WHATWG canonicalizes `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]` before + // the gate ever sees it — the classification is by parsed bytes, so + // every spelling of the same address lands on the same side. Note the + // asymmetry: these are caught as PRIVATE (isLoopbackHost deliberately + // stays spelling-strict, so cleartext to the mapped loopback is still + // refused by the http branch). + for (const url of [ + 'https://[::ffff:127.0.0.1]:8443/latest/meta-data/', + 'https://[::ffff:192.168.1.1]/router', + 'https://[0:0:0:0:0:ffff:10.0.0.5]/internal', + 'https://[64:ff9b::192.168.1.1]/nat64', + 'https://[fe9a::1]/link-local', + ]) { + assert.throws(() => assertTransportSecurity(new URL(url), remoteRoot), /refused remotely/u); + } + // Public IPv6 stays reachable — that is what real OAuth endpoints on + // IPv6 look like. + assert.doesNotThrow(() => + assertTransportSecurity(new URL('https://[2606:4700::1]/token'), remoteRoot), + ); + // Cleartext to the mapped loopback is refused by the strict loopback + // predicate, exactly as before. + assert.throws( + () => assertTransportSecurity(new URL('http://[::ffff:127.0.0.1]:8080/mcp'), remoteRoot), + /non-loopback hosts require https/u, + ); + }); + it('never allows cleartext http off the machine', () => { assert.throws( () => assertTransportSecurity(new URL('http://api.example.com/mcp'), remoteRoot), From ba4f36d746acbc8f8c01dbbac13d80f18989294f Mon Sep 17 00:00:00 2001 From: GabrielDrapor Date: Sat, 22 Aug 2026 22:55:04 +0800 Subject: [PATCH 2/4] fix(core): classify deprecated IPv4-compatible ::/96 embeddings too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #3502: classifyIpv6 knew two byte-level IPv4 embeddings (::ffff/96, 64:ff9b::/96) but not the deprecated IPv4-compatible ::/96 (RFC 4291 §2.5.5.1) — '[::192.168.1.1]' canonicalizes to '[::c0a8:101]' and fell through to 'global'. The classifier's premise is bytes over spellings, and whether the other end's stack still translates the deprecated form must not be what the gate's correctness rests on. ::/96 now classifies by its embedded IPv4 like the other two. '::1' is returned by the loopback check first; the all-zero '::' (embedded 0.0.0.0) is treated as private — connecting to the unspecified address reaches the LOCAL machine on common stacks, so it fails closed. Public v4-compatible embeddings (e.g. ::8.8.8.8) stay global. Regressions: core unit cases across the URL round-trip and raw canonical forms, plus transport-security cases arriving through the remote-provenance path. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj --- .../__tests__/mcp-host-classification.test.ts | 17 +++++++++++++++++ packages/core/src/mcp.ts | 14 ++++++++++++-- .../src/__tests__/transport-security.test.ts | 5 +++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/mcp-host-classification.test.ts b/packages/core/src/__tests__/mcp-host-classification.test.ts index ee6671f9e3..8da03cd67d 100644 --- a/packages/core/src/__tests__/mcp-host-classification.test.ts +++ b/packages/core/src/__tests__/mcp-host-classification.test.ts @@ -50,6 +50,23 @@ describe('MCP host classification', () => { assert.equal(isPrivateRangeHost('[fec0::1]'), false); }); + it('classifies deprecated IPv4-compatible ::/96 embeddings by their bytes', () => { + // RFC 4291 §2.5.5.1 — deprecated, but a byte-level embedding the + // classifier must know regardless of whether modern stacks still + // translate it: the gate's correctness must not rest on the other + // end's network stack. + assert.equal(isPrivateRangeHost(new URL('https://[::192.168.1.1]/').hostname), true); + assert.equal(isPrivateRangeHost(new URL('https://[::127.0.0.1]/').hostname), true); + assert.equal(isPrivateRangeHost('[::c0a8:101]'), true); + assert.equal(isPrivateRangeHost('[::7f00:1]'), true); + assert.equal(isPrivateRangeHost('[::a00:5]'), true); // ::10.0.0.5 + // Public v4-compatible stays global; the unspecified address reaches + // the local machine on common stacks and is private (fail closed). + assert.equal(isPrivateRangeHost('[::808:808]'), false); + assert.equal(isPrivateRangeHost('[::]'), true); + assert.equal(isPrivateRangeHost('[::2]'), false); + }); + it('keeps global addresses reachable and fails closed on garbage', () => { assert.equal(isPrivateRangeHost('[2606:4700::1]'), false); assert.equal(isPrivateRangeHost('[::ffff:808:808]'), false); // mapped 8.8.8.8 diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index 1318a5d0c1..24e72c7f77 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -161,6 +161,7 @@ function parseIpv6(literal: string): Uint8Array | undefined { } function classifyIpv6(bytes: Uint8Array): 'loopback' | 'private' | 'global' { + if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return 'loopback'; const mapped = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff; const nat64 = @@ -169,14 +170,23 @@ function classifyIpv6(bytes: Uint8Array): 'loopback' | 'private' | 'global' { bytes[2] === 0xff && bytes[3] === 0x9b && bytes.slice(4, 12).every((byte) => byte === 0); - if (mapped || nat64) { + // The third byte-level IPv4 embedding: deprecated IPv4-compatible ::/96 + // (RFC 4291 §2.5.5.1). Deprecated is not absent — the classifier's whole + // premise is bytes over spellings, and whether the OTHER end's stack + // still translates these must not be what the gate's correctness rests + // on. `::1` was returned above; the all-zero `::` flows through as an + // embedded 0.0.0.0 and is treated as private below. + const compat = bytes.slice(0, 12).every((byte) => byte === 0); + if (mapped || nat64 || compat) { const [a, b] = [bytes[12] ?? 0, bytes[13] ?? 0]; // Mapped loopback lands on the PRIVATE side of the gate: isLoopbackHost // stays strict-by-spelling, so this is the check that must catch it. if (a === 127) return 'private'; + // The unspecified address (`[::]`, embedded 0.0.0.0): connecting to it + // reaches the LOCAL machine on common stacks — private, fail closed. + if (compat && a === 0 && b === 0 && bytes[14] === 0 && bytes[15] === 0) return 'private'; return isPrivateIpv4(a, b) ? 'private' : 'global'; } - if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return 'loopback'; if (((bytes[0] ?? 0) & 0xfe) === 0xfc) return 'private'; // fc00::/7 (ULA) if (bytes[0] === 0xfe && ((bytes[1] ?? 0) & 0xc0) === 0x80) return 'private'; // fe80::/10 return 'global'; diff --git a/packages/mcp/src/__tests__/transport-security.test.ts b/packages/mcp/src/__tests__/transport-security.test.ts index 0370aaf772..2a36893472 100644 --- a/packages/mcp/src/__tests__/transport-security.test.ts +++ b/packages/mcp/src/__tests__/transport-security.test.ts @@ -82,6 +82,11 @@ describe('transport security provenance', () => { 'https://[0:0:0:0:0:ffff:10.0.0.5]/internal', 'https://[64:ff9b::192.168.1.1]/nat64', 'https://[fe9a::1]/link-local', + // Deprecated IPv4-compatible ::/96 — the third byte-level embedding, + // arriving through the same remote-provenance path a real one would. + 'https://[::192.168.1.1]/compat', + 'https://[::127.0.0.1]:8443/compat-loopback', + 'https://[::]/unspecified', ]) { assert.throws(() => assertTransportSecurity(new URL(url), remoteRoot), /refused remotely/u); } From f0707b1e89338ca3bc5f4999cd04031f001c25bf Mon Sep 17 00:00:00 2001 From: GabrielDrapor Date: Sat, 22 Aug 2026 23:52:32 +0800 Subject: [PATCH 3/4] fix(core): close the remaining IPv4-embedding and parser gaps in the SSRF gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent adversarial review (Codex) against the previous head found three gaps, all fixed here: - The mapped unspecified address ([::ffff:0:0]) classified as global; connecting to ::ffff:0.0.0.0 verifiably reaches a 127.0.0.1-bound listener. The embedded-0.0.0.0 → private rule now applies under EVERY embedding, not only IPv4-compatible. - RFC 8215's local-use NAT64 space (64:ff9b:1::/48) and RFC 2765 SIIT (::ffff:0:0/96) were unclassified. The /48 is reserved for in-network translation and deployments carve arbitrary RFC 6052 prefix lengths out of it, so the whole prefix fails closed; SIIT classifies by its embedded IPv4 like the other exact /96 prefixes. - The parser accepted dotted IPv4 outside the low-order 32 bits ('[192.168.1.1::]' parsed shifted instead of failing) and an empty zone id — both violated RFC 4291 and the documented fail-closed contract. WHATWG URL parsing rejects these today, so the transport path was covered, but the exported classifier now honors its own contract. Regressions in both suites, including transport-security cases through the remote-provenance path. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj --- .../__tests__/mcp-host-classification.test.ts | 22 ++++++++ packages/core/src/mcp.ts | 55 ++++++++++++++----- .../src/__tests__/transport-security.test.ts | 3 + 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/packages/core/src/__tests__/mcp-host-classification.test.ts b/packages/core/src/__tests__/mcp-host-classification.test.ts index 8da03cd67d..5b9c38a0f5 100644 --- a/packages/core/src/__tests__/mcp-host-classification.test.ts +++ b/packages/core/src/__tests__/mcp-host-classification.test.ts @@ -67,6 +67,28 @@ describe('MCP host classification', () => { assert.equal(isPrivateRangeHost('[::2]'), false); }); + it('classifies the remaining translation embeddings and the mapped unspecified address', () => { + // Mapped unspecified: connecting to ::ffff:0.0.0.0 reaches a + // 127.0.0.1-bound listener on common stacks — private, fail closed. + assert.equal(isPrivateRangeHost('[::ffff:0:0]'), true); + // RFC 8215 local-use NAT64 space (64:ff9b:1::/48) is reserved for + // in-network translation; deployments carve arbitrary RFC 6052 prefix + // lengths out of it, so the whole /48 fails closed. + assert.equal(isPrivateRangeHost('[64:ff9b:1:c0a8:1:100::]'), true); + assert.equal(isPrivateRangeHost('[64:ff9b:1::1]'), true); + // RFC 2765 SIIT (::ffff:0:0/96) classifies by its embedded IPv4. + assert.equal(isPrivateRangeHost(new URL('https://[0::ffff:0:192.168.1.1]/').hostname), true); + assert.equal(isPrivateRangeHost('[::ffff:0:808:808]'), false); // SIIT 8.8.8.8 + }); + + it('rejects literals RFC 4291 does not permit instead of mis-parsing them', () => { + // Dotted IPv4 belongs only in the low-order 32 bits; an empty zone id + // is not a literal. Both fail closed as private. + assert.equal(isPrivateRangeHost('[192.168.1.1::]'), true); + assert.equal(isPrivateRangeHost('[1:2.2.2.2:3::]'), true); + assert.equal(isPrivateRangeHost('[2606:4700::1%]'), true); + }); + it('keeps global addresses reachable and fails closed on garbage', () => { assert.equal(isPrivateRangeHost('[2606:4700::1]'), false); assert.equal(isPrivateRangeHost('[::ffff:808:808]'), false); // mapped 8.8.8.8 diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index 24e72c7f77..e050732fae 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -122,14 +122,22 @@ function isPrivateIpv4(a: number, b: number): boolean { * for anything that is not a well-formed address. */ function parseIpv6(literal: string): Uint8Array | undefined { const zone = literal.indexOf('%'); + // An empty zone id ('fe80::1%') is not a valid literal; rejecting it + // keeps the documented fail-closed default honest. + if (zone !== -1 && zone === literal.length - 1) return undefined; const text = (zone === -1 ? literal : literal.slice(0, zone)).toLowerCase(); const gap = text.indexOf('::'); if (gap !== -1 && text.indexOf('::', gap + 1) !== -1) return undefined; - const parseGroups = (part: string): number[] | undefined => { + const parseGroups = (part: string, dottedAllowedAtEnd: boolean): number[] | undefined => { if (part === '') return []; const out: number[] = []; - for (const piece of part.split(':')) { + const pieces = part.split(':'); + for (let index = 0; index < pieces.length; index += 1) { + const piece = pieces[index] ?? ''; if (piece.includes('.')) { + // RFC 4291 §2.2: dotted IPv4 only in the low-order 32 bits — the + // FINAL piece. '[192.168.1.1::]' must fail, not parse shifted. + if (!dottedAllowedAtEnd || index !== pieces.length - 1) return undefined; const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(piece); if (!dotted) return undefined; const octets = dotted.slice(1).map(Number); @@ -144,11 +152,12 @@ function parseIpv6(literal: string): Uint8Array | undefined { }; let groups: number[] | undefined; if (gap === -1) { - groups = parseGroups(text); + groups = parseGroups(text, true); if (!groups || groups.length !== 8) return undefined; } else { - const head = parseGroups(text.slice(0, gap)); - const tail = parseGroups(text.slice(gap + 2)); + // Dotted IPv4 can only close the address, so only the tail may carry it. + const head = parseGroups(text.slice(0, gap), false); + const tail = parseGroups(text.slice(gap + 2), true); if (!head || !tail || head.length + tail.length > 7) return undefined; groups = [...head, ...new Array(8 - head.length - tail.length).fill(0), ...tail]; } @@ -170,21 +179,37 @@ function classifyIpv6(bytes: Uint8Array): 'loopback' | 'private' | 'global' { bytes[2] === 0xff && bytes[3] === 0x9b && bytes.slice(4, 12).every((byte) => byte === 0); - // The third byte-level IPv4 embedding: deprecated IPv4-compatible ::/96 - // (RFC 4291 §2.5.5.1). Deprecated is not absent — the classifier's whole - // premise is bytes over spellings, and whether the OTHER end's stack - // still translates these must not be what the gate's correctness rests - // on. `::1` was returned above; the all-zero `::` flows through as an - // embedded 0.0.0.0 and is treated as private below. + // RFC 8215 local-use NAT64 space (64:ff9b:1::/48) is reserved for + // translation inside one network — by definition never a global + // destination. Deployments carve arbitrary RFC 6052 prefix lengths out + // of it, so the embedded IPv4's position is not recoverable here; the + // whole /48 fails closed instead. + if (bytes[0] === 0 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b && bytes[4] === 0 && bytes[5] === 1) { + return 'private'; + } + // The remaining byte-level IPv4 embeddings, classified by the embedded + // address: deprecated IPv4-compatible ::/96 (RFC 4291 §2.5.5.1) and the + // RFC 2765 SIIT prefix ::ffff:0:0/96. Deprecated is not absent — the + // classifier's whole premise is bytes over spellings, and whether the + // OTHER end's stack still translates these must not be what the gate's + // correctness rests on. `::1` was returned above. const compat = bytes.slice(0, 12).every((byte) => byte === 0); - if (mapped || nat64 || compat) { + const siit = + bytes.slice(0, 8).every((byte) => byte === 0) && + bytes[8] === 0xff && + bytes[9] === 0xff && + bytes[10] === 0 && + bytes[11] === 0; + if (mapped || nat64 || compat || siit) { const [a, b] = [bytes[12] ?? 0, bytes[13] ?? 0]; // Mapped loopback lands on the PRIVATE side of the gate: isLoopbackHost // stays strict-by-spelling, so this is the check that must catch it. if (a === 127) return 'private'; - // The unspecified address (`[::]`, embedded 0.0.0.0): connecting to it - // reaches the LOCAL machine on common stacks — private, fail closed. - if (compat && a === 0 && b === 0 && bytes[14] === 0 && bytes[15] === 0) return 'private'; + // The unspecified embedded address (0.0.0.0) under ANY embedding: + // connecting to it reaches the LOCAL machine on common stacks + // (`::ffff:0:0` verifiably reaches a 127.0.0.1-bound listener) — + // private, fail closed. + if (a === 0 && b === 0 && bytes[14] === 0 && bytes[15] === 0) return 'private'; return isPrivateIpv4(a, b) ? 'private' : 'global'; } if (((bytes[0] ?? 0) & 0xfe) === 0xfc) return 'private'; // fc00::/7 (ULA) diff --git a/packages/mcp/src/__tests__/transport-security.test.ts b/packages/mcp/src/__tests__/transport-security.test.ts index 2a36893472..1dd8ac19da 100644 --- a/packages/mcp/src/__tests__/transport-security.test.ts +++ b/packages/mcp/src/__tests__/transport-security.test.ts @@ -87,6 +87,9 @@ describe('transport security provenance', () => { 'https://[::192.168.1.1]/compat', 'https://[::127.0.0.1]:8443/compat-loopback', 'https://[::]/unspecified', + 'https://[::ffff:0:0]:8443/mapped-unspecified', + 'https://[64:ff9b:1:c0a8:1:100::]/local-nat64', + 'https://[0::ffff:0:192.168.1.1]/siit', ]) { assert.throws(() => assertTransportSecurity(new URL(url), remoteRoot), /refused remotely/u); } From 4e00843039c049c772bfd31ca2a333b34b32f1c3 Mon Sep 17 00:00:00 2001 From: GabrielDrapor Date: Sun, 23 Aug 2026 11:47:34 +0800 Subject: [PATCH 4/4] fix(core): treat exactly 0.0.0.0 as machine-local in the plain IPv4 branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #3502: the embedded paths classified the unspecified address as private, but the plain-IPv4 branch never consulted that rationale — 'https://0/' canonicalizes to 0.0.0.0, isPrivateIpv4(0, 0) returned false, and the gate allowed a destination that verifiably reaches a loopback-bound listener. isPrivateIpv4 now sees all four octets and treats exactly 0.0.0.0 as private (0.0.0.1 does not reach a local listener, so 0.0.0.0/8 stays out). The embedded checks route through the same predicate, replacing their separate special case. Also reformats two over-long lines from the previous commit that failed format:check. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj --- .../__tests__/mcp-host-classification.test.ts | 10 ++++++ packages/core/src/mcp.ts | 35 +++++++++++++------ .../src/__tests__/transport-security.test.ts | 3 ++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/core/src/__tests__/mcp-host-classification.test.ts b/packages/core/src/__tests__/mcp-host-classification.test.ts index 5b9c38a0f5..e9b1d19f2f 100644 --- a/packages/core/src/__tests__/mcp-host-classification.test.ts +++ b/packages/core/src/__tests__/mcp-host-classification.test.ts @@ -81,6 +81,16 @@ describe('MCP host classification', () => { assert.equal(isPrivateRangeHost('[::ffff:0:808:808]'), false); // SIIT 8.8.8.8 }); + it('treats exactly 0.0.0.0 as machine-local in the plain IPv4 branch too', () => { + // `https://0/` canonicalizes to 0.0.0.0, and connecting to it reaches + // the local machine — the same rationale as the embedded checks, which + // this branch previously never consulted. Only the exact address: + // 0.0.0.1 does not reach a local listener. + assert.equal(isPrivateRangeHost('0.0.0.0'), true); + assert.equal(isPrivateRangeHost(new URL('https://0/').hostname), true); + assert.equal(isPrivateRangeHost('0.0.0.1'), false); + }); + it('rejects literals RFC 4291 does not permit instead of mis-parsing them', () => { // Dotted IPv4 belongs only in the low-order 32 bits; an empty zone id // is not a literal. Both fail closed as private. diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index e050732fae..dae073c5c2 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -97,7 +97,7 @@ export function isLoopbackHost(hostname: string): boolean { export function isPrivateRangeHost(hostname: string): boolean { const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(hostname); if (v4) { - return isPrivateIpv4(Number(v4[1]), Number(v4[2])); + return isPrivateIpv4(Number(v4[1]), Number(v4[2]), Number(v4[3]), Number(v4[4])); } if (hostname.startsWith('[') && hostname.endsWith(']')) { const bytes = parseIpv6(hostname.slice(1, -1)); @@ -107,12 +107,18 @@ export function isPrivateRangeHost(hostname: string): boolean { return false; } -function isPrivateIpv4(a: number, b: number): boolean { +function isPrivateIpv4(a: number, b: number, c: number, d: number): boolean { if (a === 10) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 169 && b === 254) return true; if (a === 100 && b >= 64 && b <= 127) return true; + // Exactly the unspecified address: connecting to 0.0.0.0 reaches the + // LOCAL machine on common stacks (`https://0/` canonicalizes to it), so + // it fails closed — but only the exact address: 0.0.0.1 does not reach a + // local listener, and widening to 0.0.0.0/8 would grow the private set + // without buying anything. + if (a === 0 && b === 0 && c === 0 && d === 0) return true; return false; } @@ -142,7 +148,10 @@ function parseIpv6(literal: string): Uint8Array | undefined { if (!dotted) return undefined; const octets = dotted.slice(1).map(Number); if (octets.some((octet) => octet > 255)) return undefined; - out.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0), ((octets[2] ?? 0) << 8) | (octets[3] ?? 0)); + out.push( + ((octets[0] ?? 0) << 8) | (octets[1] ?? 0), + ((octets[2] ?? 0) << 8) | (octets[3] ?? 0), + ); } else { if (!/^[0-9a-f]{1,4}$/u.test(piece)) return undefined; out.push(Number.parseInt(piece, 16)); @@ -184,7 +193,14 @@ function classifyIpv6(bytes: Uint8Array): 'loopback' | 'private' | 'global' { // destination. Deployments carve arbitrary RFC 6052 prefix lengths out // of it, so the embedded IPv4's position is not recoverable here; the // whole /48 fails closed instead. - if (bytes[0] === 0 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b && bytes[4] === 0 && bytes[5] === 1) { + if ( + bytes[0] === 0 && + bytes[1] === 0x64 && + bytes[2] === 0xff && + bytes[3] === 0x9b && + bytes[4] === 0 && + bytes[5] === 1 + ) { return 'private'; } // The remaining byte-level IPv4 embeddings, classified by the embedded @@ -201,16 +217,13 @@ function classifyIpv6(bytes: Uint8Array): 'loopback' | 'private' | 'global' { bytes[10] === 0 && bytes[11] === 0; if (mapped || nat64 || compat || siit) { - const [a, b] = [bytes[12] ?? 0, bytes[13] ?? 0]; + const [a, b, c, d] = [bytes[12] ?? 0, bytes[13] ?? 0, bytes[14] ?? 0, bytes[15] ?? 0]; // Mapped loopback lands on the PRIVATE side of the gate: isLoopbackHost // stays strict-by-spelling, so this is the check that must catch it. + // The embedded unspecified address (0.0.0.0 — reaches the local + // machine on common stacks) is private via isPrivateIpv4. if (a === 127) return 'private'; - // The unspecified embedded address (0.0.0.0) under ANY embedding: - // connecting to it reaches the LOCAL machine on common stacks - // (`::ffff:0:0` verifiably reaches a 127.0.0.1-bound listener) — - // private, fail closed. - if (a === 0 && b === 0 && bytes[14] === 0 && bytes[15] === 0) return 'private'; - return isPrivateIpv4(a, b) ? 'private' : 'global'; + return isPrivateIpv4(a, b, c, d) ? 'private' : 'global'; } if (((bytes[0] ?? 0) & 0xfe) === 0xfc) return 'private'; // fc00::/7 (ULA) if (bytes[0] === 0xfe && ((bytes[1] ?? 0) & 0xc0) === 0x80) return 'private'; // fe80::/10 diff --git a/packages/mcp/src/__tests__/transport-security.test.ts b/packages/mcp/src/__tests__/transport-security.test.ts index 1dd8ac19da..bbceead72d 100644 --- a/packages/mcp/src/__tests__/transport-security.test.ts +++ b/packages/mcp/src/__tests__/transport-security.test.ts @@ -90,6 +90,9 @@ describe('transport security provenance', () => { 'https://[::ffff:0:0]:8443/mapped-unspecified', 'https://[64:ff9b:1:c0a8:1:100::]/local-nat64', 'https://[0::ffff:0:192.168.1.1]/siit', + // `https://0/` canonicalizes to 0.0.0.0 and reaches the local machine. + 'https://0.0.0.0:8443/unspecified-v4', + 'https://0/short-form', ]) { assert.throws(() => assertTransportSecurity(new URL(url), remoteRoot), /refused remotely/u); }