diff --git a/src/review/content-lane/registry-logic.ts b/src/review/content-lane/registry-logic.ts index b6af1a4058..16b3b1df86 100644 --- a/src/review/content-lane/registry-logic.ts +++ b/src/review/content-lane/registry-logic.ts @@ -552,10 +552,14 @@ export function assessSubnetDocument( return fail("malformed-json", "Subnet document must be a JSON object."); } const doc = document as { netuid?: unknown; surfaces?: unknown }; - if (!Number.isInteger(Number(doc.netuid))) { + // Type-first, like the `!Array.isArray(doc.surfaces)` sibling below: validate the RAW value, never Number(raw) + // (#9665). Number() coerces null/""/[]/booleans/hex/whitespace strings into "valid" integers, so a document + // with `netuid: null` was silently accepted as subnet 0 and threaded into assessSurfaceEntry's consistency + // check as if validated. A netuid is a non-negative index, so a negative integer is rejected too. + if (typeof doc.netuid !== "number" || !Number.isInteger(doc.netuid) || doc.netuid < 0) { return fail("unsupported-shape", "Subnet document netuid must be an integer."); } - const netuid = Number(doc.netuid); // normalize once; thread the canonical integer to entry + (future) grounding + const netuid = doc.netuid; // already validated as a non-negative integer above; no coercion on this path if (!Array.isArray(doc.surfaces)) { return fail("unsupported-shape", "Subnet document must carry a surfaces[] array."); } diff --git a/test/unit/content-lane-registry-logic.test.ts b/test/unit/content-lane-registry-logic.test.ts index 8eb0b34305..f017c0798b 100644 --- a/test/unit/content-lane-registry-logic.test.ts +++ b/test/unit/content-lane-registry-logic.test.ts @@ -231,10 +231,42 @@ describe("assessSubnetDocument (whole-file gate: root netuid + exactly-one appen expect(assessSubnetDocument(dirty, { appendedEntry: entry, secretsScan: false }).verdict).toBe("merged"); }); - it("threads the normalized root netuid to the entry (string root coerces; conflicting entry netuid rejected)", () => { - expect(assessSubnetDocument({ netuid: "14", surfaces: [entry] }, { appendedEntry: entry }).verdict).toBe("merged"); + it("threads the validated integer root netuid to the entry; a conflicting entry netuid is rejected", () => { + // A string root netuid is no longer coerced/accepted (#9665) — that is covered by the reject table below. + // Here the root is a real integer that matches the entry, and a conflicting entry netuid still fails. + expect(assessSubnetDocument(doc, { appendedEntry: { ...entry, netuid: 14 } }).verdict).toBe("merged"); expect(assessSubnetDocument(doc, { appendedEntry: { ...entry, netuid: 99 } }).reason).toBe("unsupported-shape"); }); + + describe("root netuid must be a real non-negative integer, not a coercible value (#9665)", () => { + // Before #9665 the check was `!Number.isInteger(Number(doc.netuid))`, so Number() smuggled these through: + // Number(null)===0, Number("")===0, Number([])===0, Number(true)===1, Number("0x10")===16, Number(" 7 ")===7. + for (const bad of [null, "", true, [], [5], "7", "0x10", 1.5, -1, undefined] as unknown[]) { + it(`rejects netuid ${JSON.stringify(bad)} with unsupported-shape`, () => { + const r = assessSubnetDocument({ netuid: bad, surfaces: [entry] }, { appendedEntry: entry }); + expect(r.reason).toBe("unsupported-shape"); + expect(r.summary).toBe("Subnet document netuid must be an integer."); + }); + } + + for (const good of [0, 64]) { + it(`keeps a valid integer netuid ${good} (not over-tightened)`, () => { + const r = assessSubnetDocument({ netuid: good, surfaces: [{ ...entry, netuid: good }] }, { appendedEntry: { ...entry, netuid: good } }); + expect(r.reason).not.toBe("unsupported-shape"); + }); + } + + it("no longer merges a document with netuid null and a surface declaring netuid 0 (end-to-end regression)", () => { + // The exact exploit: a null root coerced to subnet 0, and a surface netuid of 0 matched it, reaching a + // decisive merge on the surface lane. It must now fail on the root shape check instead. + const r = assessSubnetDocument( + { netuid: null, surfaces: [{ ...entry, netuid: 0 }] }, + { appendedEntry: { ...entry, netuid: 0 } }, + ); + expect(r.verdict).not.toBe("merged"); + expect(r.reason).toBe("unsupported-shape"); + }); + }); }); describe("assessProviderDocument", () => {