mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
fix(domain): reject excessive JSON depth with actionable diagnostics (#1029)
* fix(domain): bound JSON depth and oversized payloads at parsing boundaries jsonValueSchema re-walked hostile input through z.json()'s recursive core, so a deeply nested payload from an MCP tool argument, inline evidence, provider result, or keyed archive threw RangeError: Maximum call stack size exceeded instead of a validation issue. The schema now runs an iterative depth guard first and keeps z.json() for full JSON semantics behind it, which also turns cyclic values into validation errors. The 1000-edge limit stays far below the range where the recursive core overflows its stack (measured between 2000 and 3000 on Node 24) while leaving room for legitimately deep target-derived trees such as compiled NIB view hierarchies, and the guard narrows with a typed record check instead of any or unchecked casts. The non-recursive JSON Schema advertisement stays intact on both the public and standard projection paths. Two payload sizes get explicit budgets tied to their producer constraints: web text artifacts reject beyond the 16 MiB source budget the web tooling already advertises, and native UI screenshot base64 beyond the 64 MiB helper output budget. Regenerated product catalog, skill catalog digest, and completion-ledger manifest follow the schema-boundary change. * fix(validation): preserve depth diagnostics across process inputs --------- Co-authored-by: N0zoM1z0 <[email protected]>
This commit is contained in:
@@ -713,9 +713,9 @@
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "9ee5392d18f04f25b5c97c1b69a3644a0d81e52c813167a6f140791bc692741d",
|
||||
"tools_sha256": "bbf6d1716e6c40e6214ec639ee55904625e37cb07248a49dd166bc0ed954cad9",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a",
|
||||
"combined_sha256": "adbd48a1533f6d7d1cdfddbe7d7f350205e106e636384537bf078b6daf870f18",
|
||||
"providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_8a67e7a3578e49c8d8d22107eab8f1258094dc40041d14deee93189fd0c514b2",
|
||||
"manifest_id": "ecm_bfae29da7d32a1f58ae7f87d6bba618590ba476a9a70fbbd0059fe33a65f3cc0",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "ef398cf4b5ee7fb5e0bcf42ca787049ec145e1d911ee7951017fee1cb3980d74"
|
||||
"sha256": "d62fe4acdd5797ded5f13ef77987d5444fee6517b00e6eda5378069fb501844d"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
|
||||
metadata:
|
||||
version: "33"
|
||||
tool_count: 139
|
||||
catalog_digest: "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a"
|
||||
catalog_digest: "adbd48a1533f6d7d1cdfddbe7d7f350205e106e636384537bf078b6daf870f18"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
@@ -113,6 +114,12 @@ const parseCaptureEvidence = (input: unknown) => {
|
||||
try {
|
||||
evidence = parseEvidence(input);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof z.ZodError)
|
||||
throw new AnalysisInputError(
|
||||
"compare_process_captures",
|
||||
{ cause },
|
||||
projectInputIssues(cause.issues, input),
|
||||
);
|
||||
throw invalidCaptureEvidence();
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { MAX_JSON_DEPTH } from "../jsonValue.js";
|
||||
import { projectKeyedArchive } from "./keyedArchive.js";
|
||||
|
||||
const deepValue = (depth: number): unknown => {
|
||||
let value: unknown = 1;
|
||||
for (let index = 0; index < depth; index += 1) value = { nested: value };
|
||||
return value;
|
||||
};
|
||||
|
||||
describe("projectKeyedArchive input bound", () => {
|
||||
it("rejects an over-deep archive value with a validation error instead of crashing", () => {
|
||||
let error: unknown;
|
||||
try {
|
||||
projectKeyedArchive(deepValue(MAX_JSON_DEPTH + 1), {
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(z.ZodError);
|
||||
expect(error).not.toBeInstanceOf(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { describe, expect } from "vitest";
|
||||
import { createAnalysisProfile } from "./analysisProfile.js";
|
||||
import type { BinaryTarget } from "./binaryTarget.js";
|
||||
import { createEvidence, evidenceSchema, parseEvidence } from "./evidence.js";
|
||||
import { MAX_JSON_DEPTH } from "./jsonValue.js";
|
||||
import { createEvidenceBundle } from "./evidenceBundle.js";
|
||||
|
||||
const TARGET: BinaryTarget = {
|
||||
@@ -255,3 +256,27 @@ describe("DOS analysis evidence identity", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evidence parameter depth bound", () => {
|
||||
it("rejects evidence whose parameters nest past the JSON depth limit", () => {
|
||||
const evidence = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: true,
|
||||
});
|
||||
let value: unknown = 1;
|
||||
for (let index = 0; index <= MAX_JSON_DEPTH; index += 1)
|
||||
value = { nested: value };
|
||||
const result = evidenceSchema.safeParse({
|
||||
...evidence,
|
||||
parameters: { attack: value },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(
|
||||
result.error.issues.some((issue) =>
|
||||
issue.message.includes("maximum nesting depth"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonObjectSchema, jsonValueSchema } from "./jsonValue.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
MAX_JSON_DEPTH,
|
||||
} from "./jsonValue.js";
|
||||
|
||||
const values = [
|
||||
"text",
|
||||
@@ -35,3 +39,63 @@ it("still parses only JSON values at runtime", () => {
|
||||
for (const value of [undefined, Number.NaN, Infinity, () => 0, [undefined]])
|
||||
expect(jsonValueSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
|
||||
const deepObject = (depth: number): unknown => {
|
||||
let value: unknown = 1;
|
||||
for (let index = 0; index < depth; index += 1) value = { nested: value };
|
||||
return value;
|
||||
};
|
||||
|
||||
describe("jsonValueSchema depth bound", () => {
|
||||
it("accepts values nested to the maximum depth", () => {
|
||||
expect(jsonValueSchema.safeParse(deepObject(MAX_JSON_DEPTH)).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects values nested past the maximum depth without throwing", () => {
|
||||
const result = jsonValueSchema.safeParse(deepObject(MAX_JSON_DEPTH + 1));
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues[0]?.message).toContain("maximum nesting depth");
|
||||
});
|
||||
|
||||
it.each(["object", "array"])(
|
||||
"rejects the original 10000-level %s crash without throwing",
|
||||
(kind) => {
|
||||
let value: unknown = 1;
|
||||
for (let index = 0; index < 10_000; index += 1)
|
||||
value = kind === "object" ? { nested: value } : [value];
|
||||
const result = jsonValueSchema.safeParse(value);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues[0]?.message).toContain(
|
||||
"maximum nesting depth",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects cyclic values instead of recursing forever", () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
const result = jsonValueSchema.safeParse(cyclic);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps json semantics for non-JSON values", () => {
|
||||
expect(jsonValueSchema.safeParse(undefined).success).toBe(false);
|
||||
expect(jsonValueSchema.safeParse({ a: Number.NaN }).success).toBe(false);
|
||||
expect(
|
||||
jsonValueSchema.safeParse({ a: Number.POSITIVE_INFINITY }).success,
|
||||
).toBe(false);
|
||||
expect(jsonValueSchema.safeParse({ a: 1n }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts ordinary nested JSON", () => {
|
||||
expect(
|
||||
jsonValueSchema.safeParse({
|
||||
list: [1, "two", null, true, { deep: { further: [{}] } }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+56
-2
@@ -1,10 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** JSON-safe value shared by domain, application, and adapter boundaries. */
|
||||
export const jsonValueSchema = z.json();
|
||||
/**
|
||||
* Maximum nesting depth accepted for JSON values.
|
||||
*
|
||||
* Sits well below the point where the recursive `z.json()` core overflows its
|
||||
* stack (measured between 2000 and 3000 on Node 24) while leaving room for
|
||||
* legitimately deep target-derived trees such as compiled NIB view hierarchies.
|
||||
*/
|
||||
export const MAX_JSON_DEPTH = 1000;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const exceedsMaxDepth = (root: unknown): boolean => {
|
||||
// Depth counts edges from the root.
|
||||
const pending: { value: unknown; depth: number }[] = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
while (pending.length > 0) {
|
||||
const item = pending.pop();
|
||||
if (item === undefined) break;
|
||||
if (item.depth > MAX_JSON_DEPTH) return true;
|
||||
const value = item.value;
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value)
|
||||
pending.push({ value: child, depth: item.depth + 1 });
|
||||
} else if (isRecord(value)) {
|
||||
for (const key of Object.keys(value))
|
||||
pending.push({ value: value[key], depth: item.depth + 1 });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON-safe value shared by domain, application, and adapter boundaries.
|
||||
*
|
||||
* An iterative depth guard runs before the recursive `z.json()` core so
|
||||
* hostile nested payloads surface as validation issues instead of RangeErrors.
|
||||
* The guard only refuses over-deep values; ordinary JSON parsing semantics
|
||||
* (including which members `z.json()` keeps) are unchanged.
|
||||
*/
|
||||
const jsonCore = z.json();
|
||||
// Every JSON value satisfies `{}`. Advertise that instead of Zod's
|
||||
// self-referential `$defs` projection, which MCP clients and model APIs that
|
||||
// reject recursive JSON Schemas refuse. Runtime parsing still admits only JSON.
|
||||
// The override sits on the core as well as on the pipe because the standard
|
||||
// JSON Schema projection walks into pipe stages without consulting the outer
|
||||
// node, while the public toJSONSchema projection stops at the pipe.
|
||||
jsonCore._zod.toJSONSchema = () => ({});
|
||||
export const jsonValueSchema = z
|
||||
.unknown()
|
||||
.superRefine((value, context) => {
|
||||
if (exceedsMaxDepth(value))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`,
|
||||
});
|
||||
})
|
||||
.pipe(jsonCore);
|
||||
jsonValueSchema._zod.toJSONSchema = () => ({});
|
||||
|
||||
/** JSON object boundary for caller-visible parameter maps. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../../../src/application/process/ProcessCli.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { PROCESS_PROVIDER } from "../../../src/application/process/ProcessEvidence.js";
|
||||
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -292,11 +293,62 @@ describe("process CLI evidence validation", () => {
|
||||
const malformed = join(root, "malformed-evidence.json");
|
||||
await writeFile(malformed, "{}");
|
||||
|
||||
expect(await compareProcessEvidenceFiles(malformed, malformed)).toEqual({
|
||||
expect(
|
||||
await compareProcessEvidenceFiles(malformed, malformed),
|
||||
).toMatchObject({
|
||||
error: "Process command failed",
|
||||
category: "invalid_input",
|
||||
message:
|
||||
"Capture evidence is malformed. Create new capture evidence, then try again.",
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "compare_process_captures",
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["parameters"],
|
||||
reason: "missing_argument",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
"reports the JSON depth constraint through the compiled comparison CLI",
|
||||
async () => {
|
||||
const root = await fixture();
|
||||
const left = join(root, "left.json");
|
||||
const right = join(root, "right.json");
|
||||
const input = INVESTIGATION_EXAMPLES.compare_process_captures.input;
|
||||
await writeFile(left, JSON.stringify(input.left));
|
||||
await writeFile(right, JSON.stringify(input.right));
|
||||
const cli = fileURLToPath(
|
||||
new URL("../../../scripts/rea.mjs", import.meta.url),
|
||||
);
|
||||
const cliArguments = [
|
||||
cli,
|
||||
"compare-process-captures",
|
||||
left,
|
||||
right,
|
||||
"--json",
|
||||
];
|
||||
const baseline = await execFileAsync(process.execPath, cliArguments);
|
||||
expect(JSON.parse(baseline.stdout)).toMatchObject({
|
||||
operation: "compare_process_captures",
|
||||
});
|
||||
const nested = '{"nested":'.repeat(10_000) + "1" + "}".repeat(10_000);
|
||||
await writeFile(
|
||||
left,
|
||||
JSON.stringify({
|
||||
...input.left,
|
||||
parameters: { attack: "depth-placeholder" },
|
||||
}).replace('"depth-placeholder"', nested),
|
||||
);
|
||||
await expect(
|
||||
execFileAsync(process.execPath, cliArguments),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: expect.stringContaining("maximum nesting depth"),
|
||||
});
|
||||
},
|
||||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
@@ -21,6 +21,8 @@ import { createServer } from "../../../src/server/createServer.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { MAX_JSON_DEPTH } from "../../../src/domain/jsonValue.js";
|
||||
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
||||
import { ok as resultOk } from "../../../src/domain/result.js";
|
||||
import {
|
||||
createSessionMcpHarness,
|
||||
@@ -260,6 +262,40 @@ describe("session filesystem path boundaries over MCP", () => {
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("json depth bounds over MCP", () => {
|
||||
it("classifies deeply nested evidence parameters as an input validation error", async () => {
|
||||
directory = await createTestTempDirectory("rea-mcp-depth-bound-");
|
||||
const { mcp } = await createSessionMcpHarness(
|
||||
directory,
|
||||
provider,
|
||||
resources,
|
||||
);
|
||||
const input = INVESTIGATION_EXAMPLES.compare_process_captures.input;
|
||||
const baseline = await mcp.callTool({
|
||||
name: "compare_process_captures",
|
||||
arguments: input,
|
||||
});
|
||||
expect(baseline.isError, JSON.stringify(baseline)).not.toBe(true);
|
||||
let value: unknown = 1;
|
||||
for (let index = 0; index <= MAX_JSON_DEPTH; index += 1)
|
||||
value = { nested: value };
|
||||
const result = await mcp.callTool({
|
||||
name: "compare_process_captures",
|
||||
arguments: {
|
||||
left: {
|
||||
...input.left,
|
||||
parameters: { attack: value },
|
||||
},
|
||||
right: input.right,
|
||||
},
|
||||
});
|
||||
const text = JSON.stringify(result.content);
|
||||
expect(result.isError, text).toBe(true);
|
||||
expect(text).toContain("Input validation");
|
||||
expect(text).toContain("maximum nesting depth");
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("process residuals over MCP", () => {
|
||||
it("records process residuals linked to capture Evidence", async () => {
|
||||
if (!(await probeProcessCaptureCapability()).available) return;
|
||||
|
||||
Reference in New Issue
Block a user