From f79fd59e962ff00da7b57fdd3513512f3db29ca3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:40:51 +0800 Subject: [PATCH] fix(domain): validate JSON data without invoking caller code Reject proxies, accessors, sparse arrays and non-JSON object members at the owning boundary. Validate once before preserving JSON data; retain immutable snapshot reuse. Validation: JSON, Evidence and immutable snapshot focused tests; cached build and static checks passed. --- src/domain/jsonValue.test.ts | 59 ++++++++++++++++++++++++++++++++++++ src/domain/jsonValue.ts | 53 +++++++++++++++++++++++--------- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/domain/jsonValue.test.ts b/src/domain/jsonValue.test.ts index 2378b23b2..f27dc058e 100644 --- a/src/domain/jsonValue.test.ts +++ b/src/domain/jsonValue.test.ts @@ -40,6 +40,13 @@ it("still parses only JSON values at runtime", () => { expect(jsonValueSchema.safeParse(value).success).toBe(false); }); +it("rejects sparse arrays and enumerable symbol-keyed object members", () => { + const sparse = new Array(1); + const symbolKeyed = { [Symbol("caller data")]: "retain or reject" }; + expect(jsonValueSchema.safeParse(sparse).success).toBe(false); + expect(jsonValueSchema.safeParse(symbolKeyed).success).toBe(false); +}); + const deepObject = (depth: number): unknown => { let value: unknown = 1; for (let index = 0; index < depth; index += 1) value = { nested: value }; @@ -122,3 +129,55 @@ it.each([undefined, Number.NaN, Infinity, 1n, () => 0, new Date(0)])( } }, ); + +describe("inert JSON data boundaries", () => { + it.each(["member", "constructor", "array"])( + "rejects an accessor at %s without invoking it", + (kind) => { + let accessed = false; + const value = kind === "array" ? [] : {}; + Object.defineProperty(value, kind === "array" ? "0" : kind, { + enumerable: true, + get() { + accessed = true; + throw new Error("caller accessor must remain inert"); + }, + }); + expect(jsonValueSchema.safeParse(value).success).toBe(false); + expect(accessed).toBe(false); + }, + ); + + it("rejects proxies before reflection can invoke traps", () => { + let observed = false; + const value = new Proxy( + {}, + { + getPrototypeOf() { + observed = true; + throw new Error("caller trap must remain inert"); + }, + }, + ); + expect(jsonValueSchema.safeParse(value).success).toBe(false); + expect(observed).toBe(false); + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + expect(jsonValueSchema.safeParse(revoked.proxy).success).toBe(false); + }); + + it("projects array data without invoking caller iteration or map methods", () => { + const value = [1, { nested: "preserved" }]; + Object.defineProperty(value, Symbol.iterator, { + value() { + throw new Error("caller iterator must remain inert"); + }, + }); + Object.defineProperty(value, "map", { + value() { + throw new Error("caller map must remain inert"); + }, + }); + expect(jsonValueSchema.parse(value)).toEqual([1, { nested: "preserved" }]); + }); +}); diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index adaf2017f..ef09c3856 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -1,3 +1,4 @@ +import { types } from "node:util"; import { z } from "zod"; import { isImmutableJsonSnapshot } from "./immutableJson.js"; @@ -15,7 +16,6 @@ export type JsonValue = const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const recordShape = z.record(z.string(), z.unknown()); const immutableValidation = new WeakMap(); /** Validate JSON meaning and nesting without cloning a trusted immutable value. */ @@ -57,6 +57,8 @@ export function* jsonValueValidationSteps( `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`, ); const value = item.value; + if (typeof value === "object" && value !== null && types.isProxy(value)) + return finish("JSON values cannot contain proxies"); if ( value === null || typeof value === "string" || @@ -66,12 +68,27 @@ export function* jsonValueValidationSteps( if (typeof value === "number") { if (!Number.isFinite(value)) return finish("JSON numbers must be finite"); } else if (Array.isArray(value)) { - for (const child of value) - pending.push({ value: child, depth: item.depth + 1 }); - } else if (isRecord(value) && recordShape.safeParse(value).success) { - // Inspect every own member, including keys Zod's record parser skips. - for (const child of Object.values(value)) - pending.push({ value: child, depth: item.depth + 1 }); + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, index); + if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) + return finish( + "JSON arrays require dense data properties without accessors", + ); + pending.push({ value: descriptor.value, depth: item.depth + 1 }); + } + } else if (isRecord(value)) { + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + return finish("JSON objects require ordinary data properties"); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor?.enumerable !== true) continue; + if (typeof key !== "string") + return finish("JSON object keys must be strings"); + if (!Object.hasOwn(descriptor, "value")) + return finish("JSON objects cannot contain accessors"); + pending.push({ value: descriptor.value, depth: item.depth + 1 }); + } } else return finish("Invalid JSON value"); } return finish(undefined); @@ -80,7 +97,12 @@ export function* jsonValueValidationSteps( const preserveJsonValue = (value: JsonValue): JsonValue => { const immutableCandidate: unknown = value; if (isImmutableJsonSnapshot(immutableCandidate)) return value; - if (Array.isArray(value)) return value.map(preserveJsonValue); + if (Array.isArray(value)) { + const copy: JsonValue[] = []; + for (let index = 0; index < value.length; index += 1) + copy.push(preserveJsonValue(value[index]!)); + return copy; + } if (value !== null && typeof value === "object") return Object.fromEntries( Object.entries(value).map(([key, child]) => [ @@ -91,13 +113,14 @@ const preserveJsonValue = (value: JsonValue): JsonValue => { return value; }; -const validatedJsonValue = z.custom( - (value) => jsonValueValidationIssue(value) === undefined, - { - error: (issue) => - jsonValueValidationIssue(issue.input) ?? "Invalid JSON value", - }, -); +const validatedJsonValue = z + .unknown() + .superRefine((value, context) => { + const issue = jsonValueValidationIssue(value); + if (issue !== undefined) + context.addIssue({ code: "custom", message: issue }); + }) + .transform((value) => value as JsonValue); // Keep both stages nonrecursive in public and Standard Schema projections. validatedJsonValue._zod.toJSONSchema = () => ({});