Security and Pitfalls

Working with JSON

Chapter 11 ยท Security and Parsing Pitfalls

JSON cannot run code: it has no functions, no references and no includes. That makes it much safer than many formats, and it also makes people careless. Almost every JSON security problem is really about what the program does with the data: it trusts a number it cannot represent, lets two components read the same text differently, merges an attacker's keys into an object, builds JSON out of strings, or accepts a document of any size and depth. This chapter shows each problem happening, then the fix, using the tools from the earlier chapters. All demonstrations are harmless (they print a message or set a variable) and run only against my own scratch files and a server on 127.0.0.1.

Every example here was run, except where marked
Node 24.18.0 (with 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, in a scratch folder. The jq security history comes from jq's NEWS file as summarised by a fetch tool, so check the file itself before quoting a CVE number. Not run: other languages (Java, Go, PHP, C#, Ruby), other JavaScript engines, other browsers, lodash and other merge libraries (I wrote my own merge function), the historical "JSON hijacking" attack (explained, not reproduced), real exploitation of any service, and fuzzing. Limits such as "depth about 16,900" come from one run on one machine and are not constants.

1. Numbers Beyond 253: Use Strings for IDs

The JSON grammar has no limit on a number's size; the program that reads it does. I fed the same text, {"id":9007199254740993,"price":0.1}, to three tools:

ToolResult for id 9007199254740993
Node JSON.parse9007199254740992, and re-serialising wrote that wrong value back
Python json.loads9007199254740993 (exact)
jq 1.8.2, value passed through unchanged9007199254740993 (the original text is kept)
jq 1.8.2, .id+09007199254740992 (arithmetic converts to a double)
jq 1.8.2, .id|tostring"9007199254740993" (exact)

Two different records can have ids that differ only in the last digit; after Node reads them they are the same record. For the security angle: an access check on "id 9007199254740993" in one component and a lookup on "id 9007199254740992" in another is a bug an attacker can aim at. Send identifiers (and money, and anything with more than 15 significant digits) as strings (Chapters 1 and 2 explain the precision limit and the rawJSON alternative).

2. Duplicate Keys: Two Readers, Two Answers

The standard says object names "SHOULD be unique" and does not say what happens otherwise. All three tools I ran (Node, Python, jq) take the last value: for {"role":"user","role":"admin","n":1,"n":2} every one of them gave role "admin" and n 2. I did not find a first-wins parser among the tools I ran, but other parsers exist that behave differently (I did not test any), and the attack needs only one pair of components that disagree: a gateway that checks the first role and a backend that obeys the last. To show the shape of the problem I wrote a small first-wins reader myself (labelled as a simulation in the output):

DOC = '{"role":"user","role":"admin","tags":[{"a":1,"a":2}]}' def strict(pairs): # reject duplicates instead of choosing seen = set() for k, _ in pairs: if k in seen: raise ValueError("duplicate key: " + repr(k)) seen.add(k) return dict(pairs) json.loads(DOC) # {'role': 'admin', ...} last wins json.loads(DOC, object_pairs_hook=strict) # ValueError

The strict hook caught a duplicate nested inside an array (tags[0].a) as well. Note that the hook runs from the inside out, so it reported the inner duplicate 'a' before it reached the outer role. Parsing and then re-serialising (json.dumps(json.loads(doc))) produces a document with one role, which removes the ambiguity for whatever reads it next, but only if the security check happens after that step. Node's JSON.parse has no option to reject duplicates: in section 9 I use a small scanner for it.

3. Prototype Pollution in JavaScript

Every JavaScript object inherits from Object.prototype. If an attacker can set a property on that shared prototype, every object in the program gains it. The route is a key named __proto__ (or constructor.prototype) in JSON that a program merges into an object. I tested the pieces separately with the payload {"name":"x","__proto__":{"isAdmin":true}}:

StepResult
JSON.parse(payload) aloneSafe. __proto__ becomes an ordinary own property (Object.keys shows it); ({}).isAdmin stays undefined
my naive recursive mergeBad(defaults, parsed)Polluted: afterwards ({}).isAdmin is true for a brand-new object
same merge with payload {"constructor":{"prototype":{"polluted":1}}}polluted: ({}).polluted is 1
my mergeSafe (skips __proto__, constructor, prototype, uses Object.keys and Object.hasOwn)both payloads: nothing polluted
new Map(Object.entries(parsed))keys a and __proto__ are just map keys; nothing affected
Object.assign(Object.create(null), parsed)a dictionary with no prototype: 'isAdmin' in dict is false
secure-json-parse 4.1.1by default throws SyntaxError: Object contains forbidden prototype property; with protoAction: "remove" returns {"name":"x"}
Object.assign({}, parsed)no global pollution, but the new object's prototype became the attacker's object: t1.isAdmin was true
{...parsed} and structuredClone(parsed)safe: __proto__ stays an own key, isAdmin is undefined
// the vulnerable pattern: a recursive merge that trusts every key function mergeBad(target, src) { for (const k in src) { if (src[k] && typeof src[k] === "object") { if (!target[k]) target[k] = {}; mergeBad(target[k], src[k]); } else target[k] = src[k]; } return target; } // for k = "__proto__", target[k] is Object.prototype itself, so the recursion writes into it // the fix: skip the three dangerous names and only touch own properties function mergeSafe(target, src) { for (const k of Object.keys(src)) { if (k === "__proto__" || k === "constructor" || k === "prototype") continue; if (src[k] && typeof src[k] === "object" && !Array.isArray(src[k])) { if (!Object.hasOwn(target, k) || typeof target[k] !== "object") target[k] = {}; mergeSafe(target[k], src[k]); } else target[k] = src[k]; } return target; }

The lesson is that the parser was not the problem; the merge was. The same is true of "clone", "set by path" and "extend" helpers, and published packages have had exactly this bug. (I wrote my own functions and did not test any library.) Use a Map or a null-prototype object for data keyed by user-supplied names, reject or remove the three names at the boundary, and keep untrusted data out of generic merge helpers.

4. Never eval JSON, and the Other Formats That Run Code

Before JSON.parse existed, people turned JSON into data with eval. I fed both approaches a document with a call hidden in a value:

InputJSON.parse / json.loadseval
{"a":1,"b":(globalThis.RAN = "code ran", 42)} in NodeSyntaxErroraccepted; RAN was set to "code ran"
{"a": 1, "b": __import__("builtins").print("...") or 42} in PythonJSONDecodeErroraccepted; the print ran

Honest JSON works with eval too, which is why the habit lasted. A real attacker's value would not print a message. The same class of bug lives in other formats' loaders, and I ran two harmless demonstrations (each makes the loader call print):

  • pickle.loads called the function named inside the data (__reduce__ returned print and an argument): "code ran during pickle.loads". Never unpickle data you did not make.
  • PyYAML: yaml.load(..., Loader=yaml.UnsafeLoader) on the text !!python/object/apply:builtins.print ["..."] called print. yaml.safe_load refused the same text with a ConstructorError. The same text inside a JSON string is only data.

So the safe habit is: JSON goes through JSON.parse or json.loads, YAML through safe_load, and nothing that can instantiate arbitrary types through untrusted input. JSON's lack of features is the protection; keep it that way.

5. Depth and Size: Denial of Service

A parser needs memory for what it reads, and some code recurses once per nesting level. Chapter 9 measured the memory side; here is the depth side. I generated nested arrays ([[[[...]]]]) and tried each operation:

OperationResult
Node JSON.parsedepth 5,000,000 parsed fine (about 1 second)
Node JSON.stringify / structuredClone on a nested objectfailed with RangeError (call stack) above about depth 4,767 / 1,831
a depth-100,000 document parsed by Node, then JSON.stringify or structuredClone on the resultboth RangeError: the parse succeeded, and the next step crashed
Python json.loads (recursion limit 1000)fine to about depth 16,900; RecursionError at 100,000
Python json.dumpsfine to about depth 15,500
Python copy.deepcopy of a depth-5,000 parse resultRecursionError
jq 1.8.2 parsingdepth 10,000 fine; 10,001: "parse error: Exceeds depth limit for parsing"

The "about" figures came from a single binary search each and move with the stack size and the program's state, so treat them as orders of magnitude. The point is that different layers fail at different depths, and the failures are exceptions, not security holes, but an uncaught one in a request handler is an easy way to knock a service over. Cap the body size before reading it all, cap the depth (64 is generous for almost any API), and catch the errors.

jq has had its own memory-safety fixes. As summarised from jq's NEWS: 1.8.0 fixed CVE-2024-23337, a signed integer overflow in array and object internals, by limiting them to 229 elements; 1.8.2 fixed a number of issues including CVE-2026-40164 (crafted keys that collide in the hash table, causing high CPU use from about 100 KB of JSON; it now randomises the hash seed) and capped recursion depth in several functions. On 1.8.2 I tried jq -n '[] | .[999999999999] = 1' and got "Array index too large" instead of an allocation. If you process untrusted JSON with jq, run a current version (jq --version).

6. Injection: Never Build JSON with String Concatenation

def bad2(name): return '{"role":"user","user":"' + name + '"}' def good(name): return json.dumps({"role": "user", "user": name}) attack = 'bob","role":"admin' json.loads(bad2(attack)) # {'role': 'admin', 'user': 'bob'} injected a second "role" json.loads(good(attack)) # {'role': 'user', 'user': 'bob","role":"admin'} just a strange name

The name contained a quote, a comma and a new key, and with string concatenation it became JSON structure. Two things I observed that are easy to miss. First, the attack only worked when the injected key came after the real one: in the template that put the name first ({"user":"NAME","role":"user"}) the later real role overwrote the injected one, because parsers take the last duplicate (section 2). So the order of fields decided whether the attack worked, which is no defence. Second, the same concatenation breaks on innocent input: a name with a plain double quote made the parser fail ("Expecting ',' delimiter"), and a name containing a backslash followed by t (C:\temp) was silently turned into a tab. JSON.stringify and json.dumps escape correctly; use them for every value.

JSON inside HTML

A common pattern puts JSON in a <script> block. JSON.stringify does not escape <, so a value containing </script> ends the script block early and the rest is read as HTML:

const data = { comment: "</script><script>alert(1)</script>" }; "<script>const data = " + JSON.stringify(data) + ";</script>" // the string contains a real closing tag const safe = JSON.stringify(data).replace(/</g, "\u003c").replace(/>/g, "\u003e").replace(/&/g, "\u0026"); // "comment":"\u003c/script\u003e..." still valid JSON, and it parses back to the same data (checked)

The escaped form contains no raw <, so the browser cannot find a closing tag in it. (I checked the strings and the round trip, not the browser rendering of this particular page. The next section does run a browser.) Since ES2019 a raw U+2028 inside a JSON string is also legal JavaScript, so it no longer needs special handling in a script block; I tested that in Node only.

Content-Type: a Browser Does What the Header Says

I wrote a tiny server with two routes that echo a query parameter inside a JSON object, {"q":"..."}. One labels the response text/html, the other application/json; charset=utf-8 with X-Content-Type-Options: nosniff. I opened both in headless Chrome 154 with q=<script>document.title='RAN'</script> and dumped the page:

RouteWhat Chrome made of it
text/htmlthe script ran: the page title became "RAN" (reflected XSS from a JSON body)
application/json with nosniffshown as text: the dump contained &lt;script&gt; inside a <pre>; nothing ran

So: always send JSON as application/json, and add X-Content-Type-Options: nosniff. (I did not test what Chrome does with application/json without nosniff, or any other browser.) The old "JSON hijacking" attack is a related historical case: a site returned a top-level array of private data, and a hostile page loaded it with a <script src> tag and overrode the browser's array constructor to read it. That relied on old browser behaviour; I did not reproduce it. Modern guidance is the same as above (correct content type, authentication that does not rely on cookies alone, and an object at the top level rather than a bare array), but check current OWASP guidance for your situation.

7. Sensitive Data in Logs

JSON is easy to log whole, and that puts passwords and tokens in log files. Redaction by key name is simple (a replacer function in JavaScript, walk in jq) and I ran both. It also has limits, which the test exposed:

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)));
  • It removed password, profile.ssn, items[0].token and the Authorization header at any depth.
  • It missed a free-text field ("note": "my password is hunter2") and a key I had not listed ("pw"): both still contained the secret. A list of names protects only the names on the list; an allow-list (log only the fields you choose) is safer.
  • Error messages can leak too: Node's parser error for the text password=hunter2 was Unexpected token 'p', "password=hunter2" is not valid JSON, which quotes the input. Do not log a parse error together with the body that caused it.

8. A Front Gate for Untrusted JSON

Putting the limits together, here is a small gate I wrote for Node: check the content type, the byte size, then scan the text once for depth and duplicate keys, then call JSON.parse. The scanner ignores braces inside strings, handles escaped quotes, and decodes keys before comparing them (so "a" and "\u0061" count as the same key).

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) > 1_000_000) throw new Error("body too large (limit 1000000 bytes)"); scan(text); // throws on depth > 64 or a duplicate key in any one object return JSON.parse(text); }
InputGate
ordinary document; same key in two different objects; braces and an escaped quote inside stringsaccepted
{"role":"user","role":"admin"}; a nested duplicate; "a" versus its escaped formrejected: duplicate key
depth 64 / depth 65 / depth 100,000accepted / rejected / rejected
a 1,000,001-byte stringrejected: too large
content types application/json, with charset, application/vnd.api+json / text/html, text/plainaccepted / rejected

Behind a small HTTP server the same gate returned 200 for good JSON, 400 for a duplicate key, for depth 100 and for malformed JSON, 415 for text/plain, and 413 for a 2 MB body (the status codes are my choices). The gate is deliberately small: it does not validate the shape (use JSON Schema, Chapters 6 and 7, after the gate), it does not stop a flood of requests, and it is a scanner I wrote, not a hardened library: I tested the cases in the table and no others. In production, also put a size limit in the web server so an oversized body never reaches your code.

Checklist

RiskDefence (each shown above)
big numbers lose digitsids, money and long numbers as strings
duplicate keys parsed differentlyreject them at the boundary, or re-serialise before checking
prototype pollutionno generic merge of untrusted data; skip __proto__/constructor/prototype; Map or null-prototype objects; secure-json-parse
code executiononly JSON.parse/json.loads; safe_load for YAML; never eval, pickle or unsafe loaders on untrusted data
depth and sizecap bytes before reading, cap depth, catch errors, keep jq current
injectionbuild JSON only with JSON.stringify/json.dumps; escape < in script blocks
content typeapplication/json plus nosniff; object at the top level
logsallow-list fields; do not log bodies with parse errors
Mistakes while preparing this chapter
1. My first injection demo failed, and I had expected it to work. The injected "role":"admin" came before the real "role":"user", and last-wins parsing kept the real one. Only when I put the attacker's field last did it succeed. How to avoid it: when an attack demo does nothing, find out why before changing it; here the reason (duplicate-key order) became part of the lesson.

2. Headless Chrome started from Git Bash hung past two and a half minutes without output. I stopped it and ran Chrome from PowerShell with a timeout that kills the process, which worked first time. How to avoid it: give every browser launch a hard timeout, and clean up the profile processes it leaves.

3. I guessed a security-advisory URL for the jq CVE and the page was about a different CVE. The fetch tool said so and I did not use it as evidence for CVE-2024-23337; I took the facts from jq's NEWS file instead. How to avoid it: do not guess identifiers in URLs; start from the project's own changelog.

4. My first log-redaction example said a parse error "can quote your input" but the error I triggered did not. It was a trailing-comma error that quotes only a position. I changed the input to password=hunter2, which does quote it. How to avoid it: check the claim against the exact case you print, not a similar one.

5. I expected JSON.parse to fail at some depth. It parsed 5 million levels; the failures came later, in JSON.stringify, structuredClone and Python's deepcopy. How to avoid it: test the whole path the data takes, not only the parse.

Exercises

Exercise 1

Show the number problem in Node, Python and jq (including the effect of .id+0 and tostring). Show how Node, Python and jq treat duplicate keys, write a strict object_pairs_hook and a simulated first-wins reader, and show how they disagree. Then build JSON by string concatenation in Python and Node with a name that adds a second role: show it working with the attacker's field after the real one, failing with it before, and breaking on a plain quote and on a backslash, and compare with json.dumps/JSON.stringify. Show the </script> problem and the escaped form.

๐Ÿ“„ View solution
Exercise 2

In Node, show that JSON.parse of a __proto__ payload is harmless, write a vulnerable recursive merge and show it polluting Object.prototype through both __proto__ and constructor.prototype (remove the pollution afterwards), then fix it and test the fix. Try a Map, a null-prototype object, secure-json-parse, Object.assign, spread and structuredClone and report each. Then show eval running code that JSON.parse rejects, in Node and Python, and (with a harmless print) a pickle and an unsafe YAML load running code that safe_load refuses.

๐Ÿ“„ View solution
Exercise 3

Measure depth limits for parse, stringify, clone and copy in Node, Python and jq, including a parse that succeeds followed by an operation that fails, and try jq's huge-index assignment. Write the front gate (size, depth, duplicate keys, content type), test it on twelve inputs, put it behind a small HTTP server and call it with curl for each status code. Serve the same JSON with text/html and with application/json plus nosniff and open both in headless Chrome with a script in the data. Write a redaction function and show what it misses.

๐Ÿ“„ View solution

Chapter 11 Quick Reference

  • Ids as strings: Node read 9007199254740993 as 9007199254740992; jq .id+0 does too, while Python and a jq pass-through keep it
  • Duplicate keys: Node, Python and jq all keep the last; the standard leaves it open, so reject duplicates at the boundary (Python object_pairs_hook, a scanner in Node) or re-serialise before checking
  • Prototype pollution comes from merging untrusted keys, not from JSON.parse; skip __proto__, constructor, prototype; use Map or Object.create(null); secure-json-parse rejects them; Object.assign changed the new object's prototype
  • Never eval JSON; pickle.loads and yaml.load with an unsafe loader ran code, yaml.safe_load refused
  • Limits: Node parse took depth 5,000,000 but stringify failed about 4,767 and structuredClone about 1,831; Python loads about 16,900; jq stops at 10,001; cap body size and depth and catch the errors; keep jq current (1.8.0 limited arrays to 229 elements)
  • Build JSON only with JSON.stringify/json.dumps; concatenation let a name add a role key (the attacker's field must come last to win); escape < as \u003c inside script blocks
  • Send application/json plus X-Content-Type-Options: nosniff: Chrome ran a script from a text/html response and showed the application/json one as text
  • Logs: redact by allow-list; key-name redaction missed free text and an unlisted key; parse errors can quote the input
Coming next
Chapter 12 is the capstone: a JSON data pipeline. You take a real public JSON dump, validate it with a JSON Schema, clean and reshape it with jq, stream the result as JSON Lines with a Node or Python script, patch one record with JSON Patch, report counts and write a short README, using what Chapters 1 to 11 built. Start with Working with JSON 12.