Skip to content
Merged
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
118 changes: 118 additions & 0 deletions vinci/extensions/vinci-search.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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]",
"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",
"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`);
});
}

// 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 = [
"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",
"::ffff:ffff", // one tail group: ordinary IPv6, not an IPv4-mapped address
];

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);
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", () => {
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 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);
assert.equal(preflightUrl("http://[::ffff:ffff]/").ok, true);
});
42 changes: 41 additions & 1 deletion vinci/extensions/vinci-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,28 @@ 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 {
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;
// 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
* prompt-injection "fetch http://169.254.169.254/latest/meta-data/…" credential-theft vector. */
export function isPrivateIp(ip: string): boolean {
Expand All @@ -224,7 +246,25 @@ 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.
// 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;
}
Expand Down
5 changes: 5 additions & 0 deletions vinci/test/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions vinci/test/search-ssrf-registration.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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");

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,
) ?? [];
}

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");
Loading