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.
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:
| Tool | Result for id 9007199254740993 |
|---|---|
Node JSON.parse | 9007199254740992, and re-serialising wrote that wrong value back |
Python json.loads | 9007199254740993 (exact) |
| jq 1.8.2, value passed through unchanged | 9007199254740993 (the original text is kept) |
jq 1.8.2, .id+0 | 9007199254740992 (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):
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}}:
| Step | Result |
|---|---|
JSON.parse(payload) alone | Safe. __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.1 | by 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 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:
| Input | JSON.parse / json.loads | eval |
|---|---|---|
{"a":1,"b":(globalThis.RAN = "code ran", 42)} in Node | SyntaxError | accepted; RAN was set to "code ran" |
{"a": 1, "b": __import__("builtins").print("...") or 42} in Python | JSONDecodeError | accepted; 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.loadscalled the function named inside the data (__reduce__returnedprintand 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 ["..."]calledprint.yaml.safe_loadrefused the same text with aConstructorError. 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:
| Operation | Result |
|---|---|
Node JSON.parse | depth 5,000,000 parsed fine (about 1 second) |
Node JSON.stringify / structuredClone on a nested object | failed 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 result | both 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.dumps | fine to about depth 15,500 |
Python copy.deepcopy of a depth-5,000 parse result | RecursionError |
| jq 1.8.2 parsing | depth 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
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:
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:
| Route | What Chrome made of it |
|---|---|
text/html | the script ran: the page title became "RAN" (reflected XSS from a JSON body) |
application/json with nosniff | shown as text: the dump contained <script> 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:
- It removed
password,profile.ssn,items[0].tokenand theAuthorizationheader 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=hunter2wasUnexpected 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).
| Input | Gate |
|---|---|
| ordinary document; same key in two different objects; braces and an escaped quote inside strings | accepted |
{"role":"user","role":"admin"}; a nested duplicate; "a" versus its escaped form | rejected: duplicate key |
| depth 64 / depth 65 / depth 100,000 | accepted / rejected / rejected |
| a 1,000,001-byte string | rejected: too large |
content types application/json, with charset, application/vnd.api+json / text/html, text/plain | accepted / 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
| Risk | Defence (each shown above) |
|---|---|
| big numbers lose digits | ids, money and long numbers as strings |
| duplicate keys parsed differently | reject them at the boundary, or re-serialise before checking |
| prototype pollution | no generic merge of untrusted data; skip __proto__/constructor/prototype; Map or null-prototype objects; secure-json-parse |
| code execution | only JSON.parse/json.loads; safe_load for YAML; never eval, pickle or unsafe loaders on untrusted data |
| depth and size | cap bytes before reading, cap depth, catch errors, keep jq current |
| injection | build JSON only with JSON.stringify/json.dumps; escape < in script blocks |
| content type | application/json plus nosniff; object at the top level |
| logs | allow-list fields; do not log bodies with parse errors |
"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
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.
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.
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.
Chapter 11 Quick Reference
- Ids as strings: Node read 9007199254740993 as 9007199254740992; jq
.id+0does 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; useMaporObject.create(null);secure-json-parserejects them;Object.assignchanged the new object's prototype - Never
evalJSON;pickle.loadsandyaml.loadwith an unsafe loader ran code,yaml.safe_loadrefused - Limits: Node parse took depth 5,000,000 but
stringifyfailed about 4,767 andstructuredCloneabout 1,831; Pythonloadsabout 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 arolekey (the attacker's field must come last to win); escape<as\u003cinside script blocks - Send
application/jsonplusX-Content-Type-Options: nosniff: Chrome ran a script from atext/htmlresponse and showed theapplication/jsonone as text - Logs: redact by allow-list; key-name redaction missed free text and an unlisted key; parse errors can quote the input
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.