From 9722839339f787f2a553123ade3cf339555a8fa1 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 3 May 2026 11:06:59 +0700 Subject: [PATCH] fix(web-runtime): cap sanitize recursion to prevent stack overflow State exposes document and window (per WebState in types.ts). A user extractor returning either crashes the runtime via stack overflow on the circular DOM/Window references. Track seen objects in a WeakSet and bail at depth 32 so the worst case becomes a truncated value, not a process kill. --- pkg/spec/src/web-runtime.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 9fafce5..ac70641 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -380,20 +380,33 @@ function evaluateExtractors(): Record { return result; } +// SANITIZE_MAX_DEPTH bounds how far sanitize will recurse. State exposes +// `document` and `window`, both of which contain cycles; without a depth or +// seen-set guard a user extractor returning either crashes the runtime via +// stack overflow. +const SANITIZE_MAX_DEPTH = 32; + function sanitize(value: unknown): unknown { + return sanitizeAt(value, 0, new WeakSet()); +} + +function sanitizeAt(value: unknown, depth: number, seen: WeakSet): unknown { if (value === null || value === undefined) return value; if (typeof value === "function") return undefined; - if (Array.isArray(value)) return value.map(sanitize); - if (typeof value === "object") { - const out: Record = {}; - for (const key of Object.keys(value as Record)) { - const sub = (value as Record)[key]; - if (typeof sub === "function") continue; - out[key] = sanitize(sub); - } - return out; + if (typeof value !== "object") return value; + if (depth >= SANITIZE_MAX_DEPTH) return null; + if (seen.has(value as object)) return null; + seen.add(value as object); + if (Array.isArray(value)) { + return value.map((item) => sanitizeAt(item, depth + 1, seen)); } - return value; + const out: Record = {}; + for (const key of Object.keys(value as Record)) { + const sub = (value as Record)[key]; + if (typeof sub === "function") continue; + out[key] = sanitizeAt(sub, depth + 1, seen); + } + return out; } function pickWeighted(handle: ActionGeneratorHandle): ActionGeneratorHandle | null {