json1-11 Exercise 3: Limits, a Front Gate, Content Types and Redaction ========================================================================= Run with Node 24.18.0 (secure-json-parse 4.1.1), Python 3.14.0 (PyYAML 6.0.3), jq 1.8.2, curl 8.12.1 and headless Chrome 154 on Windows 11, installed in a scratch folder. Every payload only prints a message or sets a variable. Depth limits come from one run; they vary with the stack and the program. --- depth.mjs --- const tryParse = (n) => { const s = "[".repeat(n) + "]".repeat(n); const t = performance.now(); try { JSON.parse(s); return "ok " + (performance.now() - t).toFixed(0) + " ms"; } catch (e) { return e.name + ": " + e.message.slice(0, 50); } }; for (const n of [1000, 10000, 100000, 1000000, 5000000]) console.log("JSON.parse depth", String(n).padStart(8), "->", tryParse(n)); const nest = (n) => { let o = []; for (let i = 0; i < n; i++) o = [o]; return o; }; for (const n of [1000, 5000, 10000, 100000]) { try { const s = JSON.stringify(nest(n)); console.log("JSON.stringify depth", String(n).padStart(7), "-> ok,", s.length, "chars"); } catch (e) { console.log("JSON.stringify depth", String(n).padStart(7), "->", e.name + ": " + e.message.slice(0, 40)); } } Output: JSON.parse depth 1000 -> ok 1 ms JSON.parse depth 10000 -> ok 4 ms JSON.parse depth 100000 -> ok 15 ms JSON.parse depth 1000000 -> ok 166 ms JSON.parse depth 5000000 -> ok 915 ms JSON.stringify depth 1000 -> ok, 2002 chars JSON.stringify depth 5000 -> RangeError: Maximum call stack size exceeded JSON.stringify depth 10000 -> RangeError: Maximum call stack size exceeded JSON.stringify depth 100000 -> RangeError: Maximum call stack size exceeded --- depth.py --- import json, sys, time print("recursion limit:", sys.getrecursionlimit()) for n in (500, 900, 1000, 2000, 10000, 100000, 1000000): s = "[" * n + "]" * n; t = time.perf_counter() try: json.loads(s); r = "ok" except RecursionError: r = "RecursionError" except Exception as e: r = type(e).__name__ print("json.loads depth %8d -> %s (%.0f ms)" % (n, r, (time.perf_counter() - t) * 1000)) def nest(n): o = [] for _ in range(n): o = [o] return o for n in (500, 1000, 10000): try: json.dumps(nest(n)); r = "ok" except RecursionError: r = "RecursionError" print("json.dumps depth %8d -> %s" % (n, r)) Output: recursion limit: 1000 json.loads depth 500 -> ok (0 ms) json.loads depth 900 -> ok (0 ms) json.loads depth 1000 -> ok (0 ms) json.loads depth 2000 -> ok (0 ms) json.loads depth 10000 -> ok (2 ms) json.loads depth 100000 -> RecursionError (1 ms) json.loads depth 1000000 -> RecursionError (1 ms) json.dumps depth 500 -> ok json.dumps depth 1000 -> ok json.dumps depth 10000 -> ok --- depthjq.sh --- J=/c/lwdj/json/c4/bin/jq.exe for n in 5000 9999 10000 10001 100000; do printf '%*s' $n '' | tr ' ' '[' > d.txt; printf '%*s' $n '' | tr ' ' ']' >> d.txt printf 'jq depth %6d -> ' $n; $J -c 'length' d.txt 2>&1 | head -1 | cut -c1-80 done echo "array index CVE-2024-23337 area:"; $J -n -c '[] | .[999999999999] = 1' 2>&1 | head -2 echo "negative index set:"; $J -n -c '[1] | .[-5] = 1' 2>&1 | head -2 Output: jq depth 5000 -> 1 jq depth 9999 -> 1 jq depth 10000 -> 1 jq depth 10001 -> jq: parse error: Exceeds depth limit for parsing at line 1, column 10001 jq depth 100000 -> jq: parse error: Exceeds depth limit for parsing at line 1, column 10001 array index CVE-2024-23337 area: jq: error (at ): Array index too large negative index set: jq: error (at ): Out of bounds negative array index --- bis.mjs (binary search for the stringify and clone limits; then use a parsed depth-100000 document) --- const nest = (n) => { let o = []; for (let i = 0; i < n; i++) o = [o]; return o; }; function limit(f, lo, hi) { while (hi - lo > 1) { const m = (lo + hi) >> 1; try { f(m); lo = m; } catch { hi = m; } } return lo; } console.log("JSON.stringify works up to about depth", limit((n) => JSON.stringify(nest(n)), 1000, 5000)); console.log("structuredClone works up to about depth", limit((n) => structuredClone(nest(n)), 100, 200000)); const parsed = JSON.parse("[".repeat(100000) + "]".repeat(100000)); console.log("parse of depth 100000 succeeded; now using the result:"); for (const [name, f] of [["JSON.stringify", () => JSON.stringify(parsed)], ["structuredClone", () => structuredClone(parsed)]]) { try { f(); console.log(" ", name, "ok"); } catch (e) { console.log(" ", name, "->", e.name); } } Output: JSON.stringify works up to about depth 4767 structuredClone works up to about depth 1831 parse of depth 100000 succeeded; now using the result: JSON.stringify -> RangeError structuredClone -> RangeError --- bis.py (binary search; deepcopy) --- import json def limit(f, lo, hi): while hi - lo > 1: m = (lo + hi) // 2 try: f(m); lo = m except RecursionError: hi = m return lo print("json.loads works up to about depth", limit(lambda n: json.loads("[" * n + "]" * n), 1000, 100000)) def nest(n): o = [] for _ in range(n): o = [o] return o print("json.dumps works up to about depth", limit(lambda n: json.dumps(nest(n)), 1000, 1000000)) deep = json.loads("[" * 5000 + "]" * 5000) import copy try: copy.deepcopy(deep); print("copy.deepcopy depth 5000 ok") except RecursionError: print("copy.deepcopy of a depth-5000 parse result -> RecursionError") try: print(deep == json.loads("[" * 5000 + "]" * 5000)) except RecursionError: print("comparing two depth-5000 results with == -> RecursionError") Output: json.loads works up to about depth 16917 json.dumps works up to about depth 15507 copy.deepcopy of a depth-5000 parse result -> RecursionError True --- gate.mjs (the front gate and its twelve test inputs; run the file to see the table) --- // A small "front gate" for JSON request bodies: size cap, depth cap, duplicate-key rejection, then JSON.parse. const MAX_BYTES = 1_000_000, MAX_DEPTH = 64; export function scan(text) { // single pass over the text; returns {depth} or throws let depth = 0, maxDepth = 0, i = 0; const stack = []; // one Set of seen keys per open object, null for arrays const n = text.length; while (i < n) { const c = text[i]; if (c === '"') { let j = i + 1; while (j < n && text[j] !== '"') j += text[j] === String.fromCharCode(92) ? 2 : 1; let k = j + 1; while (k < n && " \t\r\n".includes(text[k])) k++; if (text[k] === ":" && stack[stack.length - 1] instanceof Set) { const key = JSON.parse(text.slice(i, j + 1)); const seen = stack[stack.length - 1]; if (seen.has(key)) throw new Error("duplicate key " + JSON.stringify(key)); seen.add(key); } i = j + 1; continue; } if (c === "{" || c === "[") { depth++; if (depth > MAX_DEPTH) throw new Error("too deep (limit " + MAX_DEPTH + ")"); maxDepth = Math.max(maxDepth, depth); stack.push(c === "{" ? new Set() : null); } else if (c === "}" || c === "]") { depth--; stack.pop(); } i++; } return { depth: maxDepth }; } export function safeParse(text, contentType = "application/json") { if (!/^application\/(.+\+)?json(\s*;.*)?$/i.test(contentType)) throw new Error("unsupported content type " + contentType); if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("body too large (limit " + MAX_BYTES + " bytes)"); scan(text); const v = JSON.parse(text); return v; } if (process.argv[1].endsWith("gate.mjs")) { const BS = String.fromCharCode(92); const cases = [ ["ordinary", '{"a":[1,2,{"b":3}]}'], ["duplicate key", '{"role":"user","role":"admin"}'], ["nested duplicate", '{"x":[{"k":1,"k":2}]}'], ["same key in different objects", '[{"k":1},{"k":2}]'], ["escaped duplicate (a vs a)", '{"a":1,"' + BS + 'u0061":2}'], ["depth 64", "[".repeat(64) + "]".repeat(64)], ["depth 65", "[".repeat(65) + "]".repeat(65)], ["depth 100000", "[".repeat(100000) + "]".repeat(100000)], ["brace inside a string", '{"s":"{{{[[[","t":1}'], ["string with escaped quote", '{"s":"a' + BS + '"b","s2":1}'], ["not JSON", "{oops}"], ["too large", '"' + "x".repeat(1_000_001) + '"'], ]; for (const [name, text] of cases) { try { const v = safeParse(text); console.log(name.padEnd(30), "accepted"); } catch (e) { console.log(name.padEnd(30), "REJECTED:", e.message.slice(0, 60)); } } for (const ct of ["application/json", "application/json; charset=utf-8", "application/vnd.api+json", "text/html", "text/plain"]) { try { safeParse("{}", ct); console.log(("content-type " + ct).padEnd(48), "accepted"); } catch (e) { console.log(("content-type " + ct).padEnd(48), "REJECTED"); } } } Output: ordinary accepted duplicate key REJECTED: duplicate key "role" nested duplicate REJECTED: duplicate key "k" same key in different objects accepted escaped duplicate (a vs a) REJECTED: duplicate key "a" depth 64 accepted depth 65 REJECTED: too deep (limit 64) depth 100000 REJECTED: too deep (limit 64) brace inside a string accepted string with escaped quote accepted not JSON REJECTED: Expected property name or '}' in JSON at position 1 (line 1 too large REJECTED: body too large (limit 1000000 bytes) content-type application/json accepted content-type application/json; charset=utf-8 accepted content-type application/vnd.api+json accepted content-type text/html REJECTED content-type text/plain REJECTED --- srv11.mjs (a small server; the status codes and routes are my choices) --- import http from "node:http"; import { safeParse } from "./gate.mjs"; http.createServer((req, res) => { const u = new URL(req.url, "http://x"); const reply = (code, body, type = "application/json", extra = {}) => { res.writeHead(code, { "Content-Type": type, ...extra }); res.end(typeof body === "string" ? body : JSON.stringify(body)); }; const q = u.searchParams.get("q") || ""; if (u.pathname === "/echo-html") return reply(200, JSON.stringify({ q }), "text/html"); // JSON labelled as HTML if (u.pathname === "/echo-json") return reply(200, JSON.stringify({ q }), "application/json; charset=utf-8", { "X-Content-Type-Options": "nosniff" }); if (u.pathname === "/submit" && req.method === "POST") { const chunks = []; let size = 0; req.on("data", (c) => { size += c.length; if (size > 1_000_000) { reply(413, { error: "body too large" }); req.destroy(); } else chunks.push(c); }); req.on("end", () => { if (res.writableEnded) return; try { const v = safeParse(Buffer.concat(chunks).toString("utf8"), req.headers["content-type"] || ""); reply(200, { ok: true, keys: Object.keys(v) }); } catch (e) { const code = /content type/.test(e.message) ? 415 : /too large/.test(e.message) ? 413 : 400; reply(code, { error: e.message.slice(0, 80) }); } }); return; } reply(404, { error: "not found" }); }).listen(8771, "127.0.0.1", () => console.log("listening")); --- post.sh and chrome.ps1 --- CT='Content-Type: application/json'; U=http://127.0.0.1:8771/submit req() { printf '%-34s ' "$1"; shift; curl -s -o - -w ' | %{http_code}\n' "$@"; } req 'good JSON' -X POST -H "$CT" -d '{"a":1}' $U req 'duplicate key' -X POST -H "$CT" -d '{"role":"user","role":"admin"}' $U req 'depth 100 (unclosed)' -X POST -H "$CT" -d "$(printf '[%.0s' $(seq 1 100))" $U req 'malformed' -X POST -H "$CT" -d '{oops}' $U req 'text/plain' -X POST -H 'Content-Type: text/plain' -d '{"a":1}' $U head -c 2000000 /dev/zero | tr '\0' 'x' > big.txt req '2 MB body' -X POST -H "$CT" --data-binary @big.txt $U echo "--- headers of the two reflecting routes" curl -s -i "http://127.0.0.1:8771/echo-html?q=x" | head -2 curl -s -i "http://127.0.0.1:8771/echo-json?q=x" | head -3 $ch = "C:\Program Files\Google\Chrome\Application\chrome.exe" $q = "%3Cscript%3Edocument.title%3D%27RAN%27%3C/script%3E" foreach ($p in "echo-html","echo-json") { $out = "C:\lwdj\json\c11\dom_$p.txt" $proc = Start-Process -FilePath $ch -ArgumentList "--headless=new","--disable-gpu","--no-sandbox","--user-data-dir=C:\lwdj\json\c11\chrome-prof2","--virtual-time-budget=3000","--dump-dom","http://127.0.0.1:8771/$p`?q=$q" -RedirectStandardOutput $out -NoNewWindow -PassThru if (-not $proc.WaitForExit(40000)) { $proc.Kill(); "$p : timed out" } "== $p (page after Chrome loaded http://127.0.0.1:8771/$p with a script in the q parameter)" (Get-Content $out -Raw).Trim() } Output of bash post.sh (body, then status): good JSON {"ok":true,"keys":["a"]} | 200 duplicate key {"error":"duplicate key \"role\""} | 400 depth 100 (unclosed) {"error":"too deep (limit 64)"} | 400 malformed {"error":"Expected property name or '}' in JSON at position 1 (line 1 column 2)"} | 400 text/plain {"error":"unsupported content type text/plain"} | 415 2 MB body {"error":"body too large"} | 413 --- headers of the two reflecting routes HTTP/1.1 200 OK Content-Type: text/html HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 X-Content-Type-Options: nosniff Output of powershell -File chrome.ps1: == echo-html (page after Chrome loaded http://127.0.0.1:8771/echo-html with a script in the q parameter) RAN{"q":""} == echo-json (page after Chrome loaded http://127.0.0.1:8771/echo-json with a script in the q parameter)
{"q":"<script>document.title='RAN'</script>"}
--- redact.mjs --- const SENSITIVE = /^(password|passwd|secret|token|access_token|refresh_token|authorization|api_?key|card(_?number)?|ssn)$/i; const redact = (v) => JSON.parse(JSON.stringify(v, (k, val) => (SENSITIVE.test(k) ? "[REDACTED]" : val))); const req = { user: "ada", password: "hunter2", profile: { ssn: "123-45-6789", note: "my password is hunter2" }, items: [{ token: "abc", sku: "x" }], headers: { Authorization: "Bearer eyJ...", Accept: "application/json" }, pw: "hunter2" }; console.log("raw log line :", JSON.stringify(req)); console.log("redacted log line:", JSON.stringify(redact(req))); console.log("-> still leaked: the free-text note and the key 'pw' (not in my list):", JSON.stringify(redact(req)).includes("hunter2")); const withErr = (() => { try { JSON.parse('password=hunter2'); } catch (e) { return e.message; } })(); console.log("a parse error message from Node can quote your input:", withErr); Output: raw log line : {"user":"ada","password":"hunter2","profile":{"ssn":"123-45-6789","note":"my password is hunter2"},"items":[{"token":"abc","sku":"x"}],"headers":{"Authorization":"Bearer eyJ...","Accept":"application/json"},"pw":"hunter2"} redacted log line: {"user":"ada","password":"[REDACTED]","profile":{"ssn":"[REDACTED]","note":"my password is hunter2"},"items":[{"token":"[REDACTED]","sku":"x"}],"headers":{"Authorization":"[REDACTED]","Accept":"application/json"},"pw":"hunter2"} -> still leaked: the free-text note and the key 'pw' (not in my list): true a parse error message from Node can quote your input: Unexpected token 'p', "password=hunter2" is not valid JSON --- jqred.sh (redaction with walk) --- J=/c/lwdj/json/c4/bin/jq.exe echo '{"user":"ada","password":"hunter2","a":{"token":"abc","n":1},"list":[{"secret":"s"}]}' | $J -c 'walk(if type=="object" then with_entries(if (.key|test("^(password|token|secret)$";"i")) then .value="[REDACTED]" else . end) else . end)' Output: {"user":"ada","password":"[REDACTED]","a":{"token":"[REDACTED]","n":1},"list":[{"secret":"[REDACTED]"}]} WHY THIS WORKS AS AN ANSWER --------------------------- The depth table shows that JSON.parse succeeds at depths where JSON.stringify, structuredClone and copy.deepcopy fail, so the safest test is the whole path the data takes. The binary-search figures are approximate and may differ slightly between runs; the order of magnitude is the finding. jq stops at depth 10,001 and refuses an absurd array index on 1.8.2. The gate rejects duplicate keys (including keys that are equal only after decoding), depth over 64, bodies over 1,000,000 bytes and wrong content types, and accepts braces and escaped quotes inside strings; through the server these become 400, 413 and 415. In headless Chrome the text/html response ran the script (the title became RAN) and the application/json response with nosniff was shown as text. Redaction by key name removed the listed keys at any depth and missed a free-text note and an unlisted key. Not tested: other browsers, application/json without nosniff, a production-grade parser, fuzzing.