Compare commits

..
2 Commits
4 changed files with 102 additions and 20 deletions

No files matched your search

+4
View File
@@ -5816,3 +5816,7 @@ Before: agents can read the library; supporting-path and push-credential depende
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-10-28T22:18:42-05:00 | Codex FIX-A4-001 project19 #710 BEFORE/AFTER | Isolated branch work/q284-web-fetch-ipv6-ssrf at exact base09a847ed; source pine5a7b647 and A4 report39380B/5ee6f798 verified. Focused web test baseline7/7; regression-first exact mapped-IPv6 literal/DNS/redirect run6pass/4fail; canonical-bit implementation then10/10pass. Changed only web source/test plus append-only receipts; no live/internal probe, scratch, dependency/install, heavy/full gate, commit, push, PR or API mutation. Full CI/cross-family/PR/release remain parent-owned.
2026-10-29 | Sage | #710 independent-review follow-up | Claude source approval of 4cf888c04 records LOW NAT64 policy disclosure: canonical classification refuses exact 64:ff9b::/96; the old text prefix also refused some addresses outside that range. Added explicit boundary tests; production source unchanged. Zone-suffixed inputs also fail closed. Other IPv6 transition/special-use ranges remain a separate deployment-dependent follow-up, not resolved here. Prior author no-commit statements describe that earlier session; local commit and Mos PR1834 publication followed. No native/CI/merge claim.
+4
View File
@@ -1879,3 +1879,7 @@ are never rewritten or removed; corrections are new entries.
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-10-28T22:18:42-05:00 | Codex Daybreak Blue xhigh | FIX-A4-001 / project19 #710 mapped-IPv6 SSRF source and focused test fix on work/q284-web-fetch-ipv6-ssrf | Regression-first red6pass/4fail, focused green10/10; no live/heavy gate, scratch, dependency, commit, push, PR or API mutation; full CI and integration remain with parent.
2026-10-29 | Sage | #710 independent-review follow-up | Claude source approval of 4cf888c04 records LOW NAT64 policy disclosure: canonical classification refuses exact 64:ff9b::/96; the old text prefix also refused some addresses outside that range. Added explicit boundary tests; production source unchanged. Zone-suffixed inputs also fail closed. Other IPv6 transition/special-use ranges remain a separate deployment-dependent follow-up, not resolved here. Prior author no-commit statements describe that earlier session; local commit and Mos PR1834 publication followed. No native/CI/merge claim.
+55 -17
View File
@@ -89,29 +89,67 @@ function v4Parts(s) {
return p.every((n) => n <= 255) ? p : null;
}
function isPublicV4Parts(p) {
const [a, b] = p;
if (a === 0 || a === 10 || a === 127) return false;
if (a === 100 && b >= 64 && b <= 127) return false;
if (a === 169 && b === 254) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
if (a === 192 && b === 0 && p[2] === 0) return false;
if (a === 198 && (b === 18 || b === 19)) return false;
if (a >= 224) return false;
return true;
}
// Convert every accepted IPv6 spelling to the same eight 16-bit words so
// address policy is applied to bits, not compression, case or a dotted tail.
function v6Words(s) {
const halves = s.toLowerCase().split("::");
if (halves.length > 2) return null;
const parseHalf = (half) => {
if (half === "") return [];
const fields = half.split(":");
const words = [];
for (let i = 0; i < fields.length; i += 1) {
const field = fields[i];
if (field.includes(".")) {
if (i !== fields.length - 1) return null;
const p = v4Parts(field);
if (!p) return null;
words.push((p[0] << 8) | p[1], (p[2] << 8) | p[3]);
} else {
if (!/^[0-9a-f]{1,4}$/.test(field)) return null;
words.push(parseInt(field, 16));
}
}
return words;
};
const left = parseHalf(halves[0]);
const right = halves.length === 2 ? parseHalf(halves[1]) : [];
if (!left || !right) return null;
if (halves.length === 1) return left.length === 8 ? left : null;
const omitted = 8 - left.length - right.length;
if (omitted < 1) return null;
return [...left, ...Array(omitted).fill(0), ...right];
}
export function isPublicAddress(addr) {
const fam = isIP(addr);
if (fam === 4) {
const p = v4Parts(addr);
if (!p) return false;
const [a, b] = p;
if (a === 0 || a === 10 || a === 127) return false;
if (a === 100 && b >= 64 && b <= 127) return false;
if (a === 169 && b === 254) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
if (a === 192 && b === 0 && p[2] === 0) return false;
if (a === 198 && (b === 18 || b === 19)) return false;
if (a >= 224) return false;
return true;
return p ? isPublicV4Parts(p) : false;
}
if (fam === 6) {
const s = addr.toLowerCase().replace(/^\[|\]$/g, "").split("%")[0];
if (s === "::" || s === "::1") return false;
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(s);
if (mapped) return isPublicAddress(mapped[1]);
if (/^64:ff9b::/.test(s)) return false;
const head = parseInt(s.split(":")[0] || "0", 16);
const words = v6Words(addr);
if (!words) return false;
if (words.every((word) => word === 0)) return false; // ::/128 unspecified
if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) return false; // ::1/128 loopback
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
return isPublicV4Parts([words[6] >> 8, words[6] & 0xff, words[7] >> 8, words[7] & 0xff]);
}
if (words[0] === 0x0064 && words[1] === 0xff9b && words.slice(2, 6).every((word) => word === 0)) return false;
const head = words[0];
if ((head & 0xfe00) === 0xfc00) return false; // fc00::/7 unique local
if ((head & 0xffc0) === 0xfe80) return false; // fe80::/10 link local
if ((head & 0xffc0) === 0xfec0) return false; // fec0::/10 site local
+39 -3
View File
@@ -70,6 +70,9 @@ const server = createServer((req, res) => {
case "/to-ip":
res.writeHead(302, { location: "https://127.0.0.1/plain" });
return res.end();
case "/to-mapped":
res.writeHead(302, { location: "https://[::ffff:7f00:1]/plain" });
return res.end();
case "/search": {
const q = u.searchParams.get("q");
if (u.searchParams.get("format") !== "json") { res.writeHead(403); return res.end("json off"); }
@@ -99,7 +102,9 @@ const table = {
"internal.example": [{ address: "10.0.0.5", family: 4 }],
"rebind.example": [{ address: "203.0.113.12", family: 4 }, { address: "192.168.1.1", family: 4 }],
"v6.example": [{ address: "::ffff:10.1.1.1", family: 6 }],
"mapped-private.example": [{ address: "::ffff:7f00:1", family: 6 }],
};
const requestHosts = [];
const deps = {
lookup: async (host) => {
if (!table[host]) { const e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e; }
@@ -109,8 +114,10 @@ const deps = {
// Host header kept, so the server sees which host was asked for. The
// pinned lookup the tool passes is checked: it must be the vetted address.
httpsRequest: (opts) => {
requestHosts.push(opts.hostname);
assert.ok(opts.lookup, "the tool pins the vetted address");
opts.lookup(opts.hostname, {}, (err, address) => { assert.equal(err, null); assert.equal(address, table[opts.hostname][0].address); });
const expectedAddress = table[opts.hostname]?.[0].address ?? opts.hostname;
opts.lookup(opts.hostname, {}, (err, address) => { assert.equal(err, null); assert.equal(address, expectedAddress); });
return httpRequest({ ...opts, hostname: "127.0.0.1", port, servername: undefined, lookup: undefined });
},
httpRequest,
@@ -134,8 +141,37 @@ test("web: config takes an https or loopback-http SearXNG base url and a bounded
});
test("web: address rules refuse every private, loopback, link-local, mapped and multicast form", () => {
for (const a of ["203.0.113.1", "8.8.8.8", "172.32.0.1", "100.128.0.1", "2606:4700::1111", "::ffff:8.8.8.8"]) assert.equal(isPublicAddress(a), true, a);
for (const a of ["10.1.1.1", "127.0.0.1", "127.9.9.9", "0.0.0.0", "169.254.1.1", "172.16.0.1", "172.31.255.255", "192.168.0.1", "192.0.0.1", "100.64.0.1", "198.18.0.1", "224.0.0.1", "255.255.255.255", "::1", "::", "::ffff:10.0.0.1", "::ffff:127.0.0.1", "fd00::1", "fc00::1", "fe80::1", "fec0::1", "ff02::1", "64:ff9b::a00:1", "not-an-ip", "999.1.1.1"]) assert.equal(isPublicAddress(a), false, a);
for (const a of ["203.0.113.1", "8.8.8.8", "172.32.0.1", "100.128.0.1", "2606:4700::1111", "2001:4860:4860::8888", "fbff::1", "fe7f::1", "64:ff9a::1", "::ffff:8.8.8.8", "::ffff:808:808", "0:0:0:0:0:FFFF:CB00:7101"]) assert.equal(isPublicAddress(a), true, a);
for (const a of ["10.1.1.1", "127.0.0.1", "127.9.9.9", "0.0.0.0", "169.254.1.1", "172.16.0.1", "172.31.255.255", "192.168.0.1", "192.0.0.1", "100.64.0.1", "198.18.0.1", "224.0.0.1", "255.255.255.255", "::1", "0:0:0:0:0:0:0:1", "::", "0:0:0:0:0:0:0:0", "::ffff:10.0.0.1", "::ffff:127.0.0.1", "::ffff:7f00:1", "0:0:0:0:0:FFFF:0A00:0001", "fd00::1", "FC00::1", "fe80::1", "FE80:0:0:0:0:0:0:1", "fec0::1", "ff02::1", "FF00:0:0:0:0:0:0:1", "64:ff9b::a00:1", "64:ff9b:0:0:0:0:a00:1", "not-an-ip", "999.1.1.1"]) assert.equal(isPublicAddress(a), false, a);
});
test("web: NAT64 refusal uses the well-known /96 boundary", () => {
// The old textual prefix also refused some addresses outside the well-known
// prefix; the canonical classifier deliberately uses its exact bit boundary.
for (const address of ["64:ff9b::", "64:ff9b::ffff:ffff", "64:ff9b:0:0:0:0:a00:1"]) {
assert.equal(isPublicAddress(address), false, address);
}
for (const address of ["64:ff9a::1", "64:ff9b:0:0:1:0:a00:1"]) {
assert.equal(isPublicAddress(address), true, address);
}
});
async function assertPrivateBeforeRequest(url, expectedRequestHosts = []) {
const before = requestHosts.length;
await refuses(webFetch(fast(), { url }, deps), WEB_REFUSAL.PRIVATE);
assert.deepEqual(requestHosts.slice(before), expectedRequestHosts, `${url} request dispatches`);
}
test("web: hexadecimal mapped-private literal is refused before request dispatch", async () => {
await assertPrivateBeforeRequest("https://[::ffff:7f00:1]/plain");
});
test("web: hexadecimal mapped-private DNS answer is refused before request dispatch", async () => {
await assertPrivateBeforeRequest("https://mapped-private.example/plain");
});
test("web: hexadecimal mapped-private redirect is refused before target request dispatch", async () => {
await assertPrivateBeforeRequest("https://public.example/to-mapped", ["public.example"]);
});
test("web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out", async () => {