From 2276225dd18e264e014c9c9b8bc0a4967218ca4b Mon Sep 17 00:00:00 2001 From: vinci-worker Date: Sat, 5 Sep 2026 14:19:14 +0000 Subject: [PATCH 1/3] fix: classify IPv4-mapped and NAT64 loopback addresses in web_fetch SSRF guard --- vinci/extensions/vinci-search.test.mjs | 79 ++++++++++++++++++++++++++ vinci/extensions/vinci-search.ts | 20 ++++++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 vinci/extensions/vinci-search.test.mjs diff --git a/vinci/extensions/vinci-search.test.mjs b/vinci/extensions/vinci-search.test.mjs new file mode 100644 index 000000000..ee4d0b9e3 --- /dev/null +++ b/vinci/extensions/vinci-search.test.mjs @@ -0,0 +1,79 @@ +// web_fetch SSRF guard regression tests. The IPv4-mapped IPv6 and NAT64 forms embed an IPv4 in +// the last 32 bits; WHATWG new URL() normalizes the dotted spelling to hex (::ffff:7f00:1), so +// isPrivateIp must decode and classify the embedded address in BOTH spellings. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isPrivateIp, preflightUrl } from "./vinci-search.ts"; + +// Negative: every private / loopback / link-local / NAT64 form must be classified PRIVATE. +const PRIVATE = [ + "127.0.0.1", + "[::1]", + "::1", + "[::]", + "[::ffff:7f00:1]", // IPv4-mapped, hex spelling (what new URL(href).hostname yields) -> 127.0.0.1 + "::ffff:7f00:1", // same, without brackets (what dns.lookup can return) + "[::ffff:127.0.0.1]", // IPv4-mapped, dotted spelling + "::ffff:127.0.0.1", + "[::ffff:0:0]", // IPv4-mapped 0.0.0.0 + "[64:ff9b::7f00:1]", // NAT64 -> 127.0.0.1 + "64:ff9b::7f00:1", + "[64:ff9b::127.0.0.1]", + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", // cloud metadata + "100.64.0.1", // CGNAT + "100.127.255.255", + "fe80::1", + "fc00::1", + "fd00::1", + "0.0.0.0", +]; + +for (const ip of PRIVATE) { + test(`isPrivateIp(${ip}) === true`, () => { + assert.equal(isPrivateIp(ip), true, `${ip} must be classified private`); + }); +} + +// Positive control: genuinely public addresses must still be classified PUBLIC (without this, +// "return true always" would pass every negative test). +const PUBLIC = [ + "93.184.216.34", + "[2606:2800:220:1:248:1893:25c8:1946]", + "8.8.8.8", + "172.32.0.1", // outside 172.16/12 + "100.128.0.1", // outside 100.64/10 + "[::ffff:5db8:d822]", // IPv4-mapped 93.184.216.34 + "::ffff:93.184.216.34", // IPv4-mapped, dotted spelling + "[64:ff9b::5db8:d822]", // NAT64 to 93.184.216.34 + "2001:4860:4860::8888", +]; + +for (const ip of PUBLIC) { + test(`isPrivateIp(${ip}) === false`, () => { + assert.equal(isPrivateIp(ip), false, `${ip} must be classified public`); + }); +} + +// End-to-end: preflightUrl must refuse the URL forms that exercise the hex normalization, and the +// decimal/octal/hex shorthand forms, while still allowing genuinely public hosts. +test("preflightUrl refuses IPv4-mapped and NAT64 hosts", () => { + assert.equal(preflightUrl("http://[::ffff:127.0.0.1]/").ok, false); // URL normalizes to [::ffff:7f00:1] + assert.equal(preflightUrl("http://[::ffff:7f00:1]/").ok, false); + assert.equal(preflightUrl("http://[64:ff9b::7f00:1]/").ok, false); +}); + +test("preflightUrl refuses decimal/octal/hex and shorthand loopback hosts", () => { + assert.equal(preflightUrl("http://127.1/").ok, false); // -> 127.0.0.1 + assert.equal(preflightUrl("http://2130706433/").ok, false); // -> 127.0.0.1 + assert.equal(preflightUrl("http://0x7f000001/").ok, false); // -> 127.0.0.1 + assert.equal(preflightUrl("http://0177.0.0.1/").ok, false); // -> 127.0.0.1 +}); + +test("preflightUrl still allows genuinely public hosts", () => { + assert.equal(preflightUrl("http://93.184.216.34/").ok, true); + assert.equal(preflightUrl("http://[2606:2800:220:1:248:1893:25c8:1946]/").ok, true); +}); \ No newline at end of file diff --git a/vinci/extensions/vinci-search.ts b/vinci/extensions/vinci-search.ts index 31bfb89c4..111bb4bf0 100644 --- a/vinci/extensions/vinci-search.ts +++ b/vinci/extensions/vinci-search.ts @@ -208,6 +208,17 @@ function formatDocs(library: string, topic: string | undefined, docs: string, tr const FETCH_MAX_CHARS = 30000; // cap the extracted text — a docs page can be huge; more just bloats context const FETCH_MAX_BYTES = 5_000_000; // refuse to download more than ~5MB of HTML +/** Decode the 32 bits after an IPv4-mapped / NAT64 /96 prefix as a dotted-quad IPv4 string. + * Accepts both spellings: ``::ffff:127.0.0.1`` (dotted) and ``::ffff:7f00:1`` (hex). + * Returns null when the tail isn't a valid embedded IPv4. */ +function ipv4FromTail(tail: string): string | null { + if (/^(\d{1,3}\.){3}\d{1,3}$/.test(tail)) return tail; + const hex = tail.match(/^([0-9a-f]{1,4})(?::([0-9a-f]{1,4}))?$/); + if (!hex) return null; + const n = (parseInt(hex[1], 16) << 16) | (hex[2] ? parseInt(hex[2], 16) : 0); + return `${( n >>> 24) & 0xff}.${( n >>> 16) & 0xff}.${( n >>> 8) & 0xff}.${n & 0xff}`; +} + /** SSRF guard: is this IP literal private / loopback / link-local / cloud-metadata? Blocks the classic * prompt-injection "fetch http://169.254.169.254/latest/meta-data/…" credential-theft vector. */ export function isPrivateIp(ip: string): boolean { @@ -224,7 +235,14 @@ export function isPrivateIp(ip: string): boolean { } const low = ip.toLowerCase().replace(/^\[|\]$/g, ""); if (low === "::1" || low === "::") return true; - if (low.startsWith("::ffff:")) return isPrivateIp(low.slice(7)); // IPv4-mapped IPv6 + // IPv4-mapped IPv6 (::ffff:127.0.0.1 / ::ffff:7f00:1) and NAT64 (64:ff9b::/96) embed an IPv4 + // in their last 32 bits. WHATWG new URL() normalizes the dotted form to hex before we see it, + // so decode the embedded IPv4 and classify IT rather than assuming one spelling. + const mapped = low.match(/^(::ffff:|64:ff9b::)(.*)$/); + if (mapped) { + const embedded = ipv4FromTail(mapped[2] ?? ""); + if (embedded !== null) return isPrivateIp(embedded); + } if (low.startsWith("fe80") || low.startsWith("fc") || low.startsWith("fd")) return true; // link-local / ULA return false; } From caf0263cb88fc3990a59340ad0f5b8cf70617a0d Mon Sep 17 00:00:00 2001 From: George Pu <19347973+thegeorgepu@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:35:50 -0400 Subject: [PATCH 2/3] fix(search): close NAT64 single-group SSRF gaps --- vinci/extensions/vinci-search.test.mjs | 43 +++++++++++++++++++++++-- vinci/extensions/vinci-search.ts | 36 +++++++++++++++++---- vinci/test/run.sh | 5 +++ vinci/test/search-ssrf-registration.mjs | 18 +++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 vinci/test/search-ssrf-registration.mjs diff --git a/vinci/extensions/vinci-search.test.mjs b/vinci/extensions/vinci-search.test.mjs index ee4d0b9e3..7c90b384b 100644 --- a/vinci/extensions/vinci-search.test.mjs +++ b/vinci/extensions/vinci-search.test.mjs @@ -19,6 +19,12 @@ const PRIVATE = [ "[64:ff9b::7f00:1]", // NAT64 -> 127.0.0.1 "64:ff9b::7f00:1", "[64:ff9b::127.0.0.1]", + "64:ff9b::808", // one-group tail -> 0.0.8.8 (the omitted high group is zero) + "64:ff9b::8088", // -> 0.0.128.136 + "64:ff9b::ffff", // -> 0.0.255.255 + "64:ff9b::100", // -> 0.0.1.0 + "[64:FF9B::808]", // bracketed, uppercase alternate spelling + "::ffff", // mapped-prefix boundary with no 32-bit tail "10.0.0.1", "172.16.0.1", "172.31.255.255", @@ -38,6 +44,30 @@ for (const ip of PRIVATE) { }); } +// Once a mapped/NAT64 prefix is recognized, a malformed embedded tail must not become an ordinary +// public IPv6 literal. These drive the parser directly because WHATWG rejects several before the +// SSRF predicate sees them. +const MALFORMED_EMBEDDED = [ + "64:ff9b::", + "64:ff9b::gggg", + "64:ff9b::10000", + "64:ff9b::1:2:3", + "64:ff9b::256.0.0.1", + "64:ff9b::999.0.0.1", + "::ffff:", + "::ffff:gggg", + "::ffff:10000", + "::ffff:1:2:3", + "::ffff:256.0.0.1", + "::ffff:999.0.0.1", +]; + +for (const ip of MALFORMED_EMBEDDED) { + test(`isPrivateIp(${ip}) fails closed`, () => { + assert.equal(isPrivateIp(ip), true, `${ip} must fail closed`); + }); +} + // Positive control: genuinely public addresses must still be classified PUBLIC (without this, // "return true always" would pass every negative test). const PUBLIC = [ @@ -50,6 +80,7 @@ const PUBLIC = [ "::ffff:93.184.216.34", // IPv4-mapped, dotted spelling "[64:ff9b::5db8:d822]", // NAT64 to 93.184.216.34 "2001:4860:4860::8888", + "::ffff:ffff", // one tail group: ordinary IPv6, not an IPv4-mapped address ]; for (const ip of PUBLIC) { @@ -64,6 +95,11 @@ test("preflightUrl refuses IPv4-mapped and NAT64 hosts", () => { assert.equal(preflightUrl("http://[::ffff:127.0.0.1]/").ok, false); // URL normalizes to [::ffff:7f00:1] assert.equal(preflightUrl("http://[::ffff:7f00:1]/").ok, false); assert.equal(preflightUrl("http://[64:ff9b::7f00:1]/").ok, false); + assert.equal(preflightUrl("http://[64:ff9b::808]/").ok, false); + assert.equal(preflightUrl("http://[64:ff9b::8088]/").ok, false); + assert.equal(preflightUrl("http://[64:ff9b::ffff]/").ok, false); + assert.equal(preflightUrl("http://[64:ff9b::100]/").ok, false); + assert.equal(preflightUrl("http://[::ffff]/").ok, false); }); test("preflightUrl refuses decimal/octal/hex and shorthand loopback hosts", () => { @@ -73,7 +109,10 @@ test("preflightUrl refuses decimal/octal/hex and shorthand loopback hosts", () = assert.equal(preflightUrl("http://0177.0.0.1/").ok, false); // -> 127.0.0.1 }); -test("preflightUrl still allows genuinely public hosts", () => { +test("preflightUrl still allows public mapped, NAT64, IPv4, and IPv6 hosts", () => { assert.equal(preflightUrl("http://93.184.216.34/").ok, true); + assert.equal(preflightUrl("http://[::ffff:5db8:d822]/").ok, true); + assert.equal(preflightUrl("http://[64:ff9b::5db8:d822]/").ok, true); assert.equal(preflightUrl("http://[2606:2800:220:1:248:1893:25c8:1946]/").ok, true); -}); \ No newline at end of file + assert.equal(preflightUrl("http://[::ffff:ffff]/").ok, true); +}); diff --git a/vinci/extensions/vinci-search.ts b/vinci/extensions/vinci-search.ts index 111bb4bf0..44a352dfd 100644 --- a/vinci/extensions/vinci-search.ts +++ b/vinci/extensions/vinci-search.ts @@ -212,11 +212,22 @@ const FETCH_MAX_BYTES = 5_000_000; // refuse to download more than ~5MB of HTML * Accepts both spellings: ``::ffff:127.0.0.1`` (dotted) and ``::ffff:7f00:1`` (hex). * Returns null when the tail isn't a valid embedded IPv4. */ function ipv4FromTail(tail: string): string | null { - if (/^(\d{1,3}\.){3}\d{1,3}$/.test(tail)) return tail; + const dotted = tail.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (dotted) { + const octets = dotted.slice(1).map(Number); + if (octets.some((octet) => octet > 255)) return null; + return octets.join("."); + } const hex = tail.match(/^([0-9a-f]{1,4})(?::([0-9a-f]{1,4}))?$/); if (!hex) return null; - const n = (parseInt(hex[1], 16) << 16) | (hex[2] ? parseInt(hex[2], 16) : 0); - return `${( n >>> 24) & 0xff}.${( n >>> 16) & 0xff}.${( n >>> 8) & 0xff}.${n & 0xff}`; + // A lone group after a fixed /96 prefix is the LOW 16 bits; IPv6 ``::`` + // compression supplies the omitted high group as zero. Treating it as the + // high half turns NAT64 64:ff9b::808 (0.0.8.8, blocked by 0/8) into the + // apparently public 8.8.0.0. + const high = hex[2] ? parseInt(hex[1], 16) : 0; + const low = parseInt(hex[2] ?? hex[1], 16); + const n = (high << 16) | low; + return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`; } /** SSRF guard: is this IP literal private / loopback / link-local / cloud-metadata? Blocks the classic @@ -238,10 +249,21 @@ export function isPrivateIp(ip: string): boolean { // IPv4-mapped IPv6 (::ffff:127.0.0.1 / ::ffff:7f00:1) and NAT64 (64:ff9b::/96) embed an IPv4 // in their last 32 bits. WHATWG new URL() normalizes the dotted form to hex before we see it, // so decode the embedded IPv4 and classify IT rather than assuming one spelling. - const mapped = low.match(/^(::ffff:|64:ff9b::)(.*)$/); - if (mapped) { - const embedded = ipv4FromTail(mapped[2] ?? ""); - if (embedded !== null) return isPrivateIp(embedded); + // A mapped address needs a full 32-bit tail: dotted IPv4 or two hex groups. A lone valid hex + // group after ::ffff: is not mapped: compression puts ffff in group 7, not the mapped prefix's + // group 6. Preserve that ordinary IPv6 spelling, but fail closed for mapped-looking malformed + // tails (empty/non-hex/too many groups/out-of-range dotted octets). + if (low === "::ffff") return true; + if (low.startsWith("::ffff:")) { + const tail = low.slice("::ffff:".length); + if (!/^[0-9a-f]{1,4}$/.test(tail)) { + const embedded = ipv4FromTail(tail); + return embedded === null ? true : isPrivateIp(embedded); + } + } + if (low.startsWith("64:ff9b::")) { + const embedded = ipv4FromTail(low.slice("64:ff9b::".length)); + return embedded === null ? true : isPrivateIp(embedded); } if (low.startsWith("fe80") || low.startsWith("fc") || low.startsWith("fd")) return true; // link-local / ULA return false; diff --git a/vinci/test/run.sh b/vinci/test/run.sh index a78a554a4..442222de4 100644 --- a/vinci/test/run.sh +++ b/vinci/test/run.sh @@ -778,6 +778,9 @@ run_group scope-integration node "${ROOT}/vinci/test/scope-integration.mjs" # Mid-run steering amends the scope task; it never shrinks it to just the adjustment. run_group scope-amendment-integration node "${ROOT}/vinci/test/scope-amendment-integration.mjs" run_group shell-integration node "${ROOT}/vinci/test/shell-integration.mjs" +# Keep the SSRF production-import lane registered in this canonical harness; the adjacent pin test +# fails if this exact group is later dropped or silently redirected. +run_group search-ssrf-registration node "${ROOT}/vinci/test/search-ssrf-registration.mjs" run_group repo-corpus-unit node "${ROOT}/vinci/test/ec2/repo-corpus-unit.mjs" run_group aggregate-corpus-unit node "${ROOT}/vinci/test/ec2/aggregate-corpus-unit.mjs" run_group verify-holdout-corpus-unit node "${ROOT}/vinci/test/ec2/verify-holdout-corpus-unit.mjs" @@ -798,6 +801,8 @@ if node -e 'const [a,b]=process.versions.node.split(".").map(Number); process.ex run_group mask-integration node --experimental-strip-types "${ROOT}/vinci/test/mask-integration.mjs" # prompt-injection boundary: untrusted web content fenced so a page can't hijack the agent. run_group search-integration node --experimental-strip-types "${ROOT}/vinci/test/search-integration.mjs" + # SSRF boundary: drive the real search extension through helper and WHATWG URL forms. + run_group search-ssrf node --experimental-strip-types "${ROOT}/vinci/extensions/vinci-search.test.mjs" # command sandbox: real write confinement through macOS sandbox-exec or Linux bubblewrap. run_group sandbox-integration node --experimental-strip-types "${ROOT}/vinci/test/sandbox-integration.mjs" # session auto-naming: model title reply → tidy session name for the resume picker. diff --git a/vinci/test/search-ssrf-registration.mjs b/vinci/test/search-ssrf-registration.mjs new file mode 100644 index 000000000..f5a31e78a --- /dev/null +++ b/vinci/test/search-ssrf-registration.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const harness = readFileSync(resolve(here, "run.sh"), "utf8"); +const registration = + 'run_group search-ssrf node --experimental-strip-types "${ROOT}/vinci/extensions/vinci-search.test.mjs"'; +const occurrences = harness.split(registration).length - 1; + +assert.equal( + occurrences, + 1, + "the canonical harness must run the production-import SSRF test exactly once with TypeScript stripping", +); + +console.log("search-ssrf-registration: canonical production-import lane is registered exactly once"); From 8f8e321f4def104aac2d7dfec8679a4b5dd152e4 Mon Sep 17 00:00:00 2001 From: George Pu <19347973+thegeorgepu@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:43:00 -0400 Subject: [PATCH 3/3] test(search): pin executable SSRF harness registration --- vinci/test/search-ssrf-registration.mjs | 36 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/vinci/test/search-ssrf-registration.mjs b/vinci/test/search-ssrf-registration.mjs index f5a31e78a..d96b8e19b 100644 --- a/vinci/test/search-ssrf-registration.mjs +++ b/vinci/test/search-ssrf-registration.mjs @@ -5,14 +5,32 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const harness = readFileSync(resolve(here, "run.sh"), "utf8"); -const registration = - 'run_group search-ssrf node --experimental-strip-types "${ROOT}/vinci/extensions/vinci-search.test.mjs"'; -const occurrences = harness.split(registration).length - 1; -assert.equal( - occurrences, - 1, - "the canonical harness must run the production-import SSRF test exactly once with TypeScript stripping", -); +function executableRegistrations(source) { + // Whole executable shell line only: a comment, echo/string decoy, suffix, or prefix must not count. + return source.match( + /^[ \t]*run_group search-ssrf node --experimental-strip-types "\$\{ROOT\}\/vinci\/extensions\/vinci-search\.test\.mjs"[ \t]*\r?$/gm, + ) ?? []; +} -console.log("search-ssrf-registration: canonical production-import lane is registered exactly once"); +function assertRegisteredOnce(source) { + assert.equal( + executableRegistrations(source).length, + 1, + "the canonical harness must run the production-import SSRF test exactly once with TypeScript stripping", + ); +} + +assertRegisteredOnce(harness); +const [registrationLine] = executableRegistrations(harness); +const withoutRegistration = harness.replace(registrationLine, ""); +assert.throws(() => assertRegisteredOnce(withoutRegistration), /must run the production-import SSRF test exactly once/); + +const indentation = registrationLine.match(/^[ \t]*/)?.[0] ?? ""; +const commentedOut = harness.replace(registrationLine, `${indentation}# ${registrationLine.trimStart()}`); +assert.throws(() => assertRegisteredOnce(commentedOut), /must run the production-import SSRF test exactly once/); + +const substringDecoy = `${withoutRegistration}\necho '${registrationLine.trim()}'\n`; +assert.throws(() => assertRegisteredOnce(substringDecoy), /must run the production-import SSRF test exactly once/); + +console.log("search-ssrf-registration: exact executable line passes; removal/comment/substring mutations fail");