Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/core/src/__tests__/mcp-host-classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* 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('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);
});
});
139 changes: 128 additions & 11 deletions packages/core/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,25 +81,142 @@ 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('%');
// 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<number>(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' {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] IPv4-compatible ::/96 is not classified by its embedded IPv4.

classifyIpv6 recognizes two ways of embedding an IPv4 address — ::ffff:/96 and 64:ff9b::/96 — and hands both to isPrivateIpv4. It does not recognize the third, ::/96, so those addresses reach the final return 'global'.

Reproduction, using the URL parser this gate sits behind:

new URL('https://[::192.168.1.1]/').hostname  // '[::c0a8:101]'
new URL('https://[::127.0.0.1]/').hostname    // '[::7f00:1]'

Trace [::7f00:1] through the function: bytes 0–11 are zero so mapped is false (byte 10 is not 0xff) and nat64 is false (byte 1 is not 0x64); the loopback check requires bytes 0–14 to be zero and byte 12 is 0x7f; fc00::/7 and fe80::/10 both test byte 0, which is zero. Result: global. isPrivateRangeHost('[::7f00:1]') returns false, so the remote-HTTPS branch in transport-security.ts:59-75 does not reject it, and a remote server's metadata or a redirect can still hand back a target spelled this way.

On severity. We tried to connect to ::127.0.0.1 on macOS and it times out rather than reaching the loopback listener, so on a current stack this is unlikely to be an exploitable path to a live service — IPv4-compatible addresses are deprecated by RFC 4291 and most stacks no longer translate them. That is why this is a P2 and not a P1. But it should still be closed, for a reason that does not depend on reachability: this PR's whole premise is that classification happens on parsed bytes rather than on how an address is written, and ::/96 is the one byte-level IPv4 embedding the classifier does not know about. Leaving it means the gate's correctness rests on an assumption about the other end's network stack, which is exactly the kind of assumption this change set out to remove.

Smallest fix: treat ::/96 alongside the other two — bytes 0–11 all zero, excluding :: and ::1 which the surrounding checks already own — and reuse the same isPrivateIpv4 / 127. logic. Worth a regression case that comes in through the remote-provenance path rather than only calling the classifier directly, since that is where a real one would arrive.

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] = [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 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)
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
Expand Down
37 changes: 37 additions & 0 deletions packages/mcp/src/__tests__/transport-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,43 @@ 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',
]) {
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),
Expand Down