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.
This commit is contained in:
morluto committed 2026-10-14 13:40:51 +08:00
1 parent 500e32399a
commit f79fd59e96
2 files changed
+97 -15

No files matched your search

+59
View File
@@ -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" }]);
});
});
+38 -15
View File
@@ -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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const recordShape = z.record(z.string(), z.unknown());
const immutableValidation = new WeakMap<object, string | undefined>();
/** 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<JsonValue>(
Object.entries(value).map(([key, child]) => [
@@ -91,13 +113,14 @@ const preserveJsonValue = (value: JsonValue): JsonValue => {
return value;
};
const validatedJsonValue = z.custom<JsonValue>(
(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 = () => ({});