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..e9b1d19f2f --- /dev/null +++ b/packages/core/src/__tests__/mcp-host-classification.test.ts @@ -0,0 +1,113 @@ +/* + * 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('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('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('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. + 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 + 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..dae073c5c2 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -81,25 +81,155 @@ 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]), Number(v4[3]), Number(v4[4])); } - 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, 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; +} + +/** 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('%'); + // 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, dottedAllowedAtEnd: boolean): number[] | undefined => { + if (part === '') return []; + const out: number[] = []; + 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); + 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, true); + if (!groups || groups.length !== 8) return undefined; + } else { + // 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]; + } + 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' { + 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 = + bytes[0] === 0 && + bytes[1] === 0x64 && + bytes[2] === 0xff && + bytes[3] === 0x9b && + bytes.slice(4, 12).every((byte) => byte === 0); + // 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); + 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, 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'; + 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 + 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..bbceead72d 100644 --- a/packages/mcp/src/__tests__/transport-security.test.ts +++ b/packages/mcp/src/__tests__/transport-security.test.ts @@ -69,6 +69,46 @@ 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', + // 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', + '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); + } + // 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),