From 3dbb5d0475e3b61fa5dfa1812f6034a9e107e720 Mon Sep 17 00:00:00 2001 From: Jonathan Baldie Date: Tue, 22 Sep 2026 05:35:13 +0100 Subject: [PATCH] fix: avoid per-value JSON reviver overhead Parse request bodies natively, then scan the original JSON source to validate number literals. This preserves exact-number rejection while avoiding a reviver callback for every value in number-dense payloads. Add public handler regressions for exact integers, nested metadata, JSON strings, and the explicit nesting limit. --- README.md | 1 + mutation/stryker.config.json | 2 +- src/handler.ts | 56 ++++++++++++++++++++++++------------ 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4add3e7..c7066ba 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ curl -X GET -H "Authorization: Bearer replace-with-a-secret-token" http://127.0. ``` This returns the oldest added payload on queue `foo` as JSON and removes it, guaranteeing both the order and that each payload will only be read once. Strings, numbers, booleans, arrays, and objects all use `application/json` so a string `"0"` is distinct from the number `0`. Numeric values that JavaScript cannot represent without loss and request bodies that are not well-formed UTF-8 are rejected with a `400` response. +JSON request bodies nested more than 3,000 container levels are also rejected with a `400` response. That's all you need to get started! 😎 diff --git a/mutation/stryker.config.json b/mutation/stryker.config.json index 0b7f8a6..0f03960 100644 --- a/mutation/stryker.config.json +++ b/mutation/stryker.config.json @@ -11,7 +11,7 @@ }, "coverageAnalysis": "off", "timeoutMS": 30000, - "concurrency": 2, + "concurrency": 4, "thresholds": { "high": 80, "low": 70, diff --git a/src/handler.ts b/src/handler.ts index 8eda311..11d849a 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -42,12 +42,47 @@ function isUnsupportedNumber(value: number, source: string): boolean { return canonicalJsonNumber(source) !== canonicalJsonNumber(serializedValue); } +// Matches strings, containers, and number literals in valid JSON. Scanning +// the source avoids invoking a JSON.parse reviver once per JSON value. +const JSON_TOKEN = /"[^"\\]*(?:\\.[^"\\]*)*"|[[{]|[\]}]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g; +const EXACT_INTEGER = /^-?\d{1,15}$/; +const MAX_JSON_DEPTH = 3000; + +function rejectUnsupportedNumber(source: string): void { + // Every integer with at most 15 digits is below Number.MAX_SAFE_INTEGER. + if (EXACT_INTEGER.test(source)) { + return; + } + if (isUnsupportedNumber(Number(source), source)) { + throw new UnsupportedNumberError(); + } +} + +function validateJsonSource(source: string): void { + let depth = 0; + for (const match of source.matchAll(JSON_TOKEN)) { + const token = match[0]; + if (token === "[" || token === "{") { + depth++; + if (depth > MAX_JSON_DEPTH) { + throw new JsonNestingTooDeepError(); + } + } else if (token === "]" || token === "}") { + depth--; + } else if (token[0] !== '"') { + rejectUnsupportedNumber(token); + } + } +} + function parseJsonBody(body: string) { try { - return JSON.parse(body, rejectUnsupportedNumbers); + const json = JSON.parse(body); + validateJsonSource(body); + return json; } catch (error) { - // V8 applies the reviver recursively, so deeply nested JSON overflows - // the stack; treat it as unparseable input rather than a server fault. + // Deeply nested JSON can overflow the native parser's stack; treat it + // as unparseable input rather than a server fault. if (error instanceof RangeError) { throw new JsonNestingTooDeepError(); } @@ -55,21 +90,6 @@ function parseJsonBody(body: string) { } } -function rejectUnsupportedNumbers(key: string, value: unknown) { - void key; - if (typeof value !== "number") { - return value; - } - - // V8 supplies context.source at runtime; Deno's JSON.parse type still - // only declares the legacy two-argument reviver signature. - const context = arguments[2] as { source: string }; - if (isUnsupportedNumber(value, context.source)) { - throw new UnsupportedNumberError(); - } - return value; -} - async function readRequestBody(request: Request): Promise { const contentLength = request.headers.get("content-length"); if (contentLength && parseInt(contentLength) > MAX_BODY_SIZE) {