What can and cannot go wrong when you call JSON.parse on a string that arrived from an untrusted source?
answer
- no execution, plain values only
- the risk is what you do next
- one key name is special on assignment
- repeated names, last one wins
- doubles cannot hold every integer
basics
~20 sJSON.parse never executes code — it produces only plain objects, arrays and primitives. The real hazards are silent ones: a "proto" key that becomes dangerous once the result is merged, duplicate keys where the last silently wins, lost precision on large integers, and unbounded input size.
solid answer
~50 sParsing itself is not an execution vector: the grammar has no functions and no type tags, so the result is always plain objects, arrays, strings, numbers, booleans and null — never an instance of one of your classes. What bites is quieter. A `"__proto__"` key is created as an ordinary own data property, so the parsed object's prototype is untouched — but the moment that object is fed to a recursive merge or an assignment that goes through a normal property set, the `__proto__` accessor inherited from `Object.prototype` fires and a prototype gets replaced. Duplicate keys are legal in the grammar and the last one silently wins, so a validator reading one occurrence and consumer code reading another can disagree. Integers beyond the exactly-representable range are already rounded by the time you see them. And parsing allocates the whole document, so size must be bounded before the call, not after.
code
javascript · 8 linesconst parsed = JSON.parse('{"__proto__":{"admin":true},"a":1}');
console.log(Object.getPrototypeOf(parsed) === Object.prototype); // true - parsing is fine
console.log(parsed.admin); // undefined
const target = {};
Object.assign(target, parsed); // an assignment triggers the __proto__ setter
console.log(Object.getPrototypeOf(target) === Object.prototype); // false
console.log(target.admin); // truego deeper
Know that parsing runs no code and yields only plain objects and primitives — never one of your class instances — and that a well-formed document still needs its shape checked before use.
Explain the define-versus-assign distinction that makes a __proto__ key inert on arrival and dangerous on copy, and that duplicate names are legal with the last occurrence winning.
Show the operational picture: bound input size before the call because there is no early exit, canonicalise when more than one reader sees the same bytes, and send large identifiers as strings since precision is lost before any reviver runs.
Own where the trust boundary sits — one validated decoding layer producing typed, shape-checked values, so that no downstream code ever merges or spreads a raw parsed object into application state.
## Start with what is genuinely safe The grammar has six value shapes and no way to name a type, invoke anything, or reference another node. Whatever text arrives, the result of parsing is a fresh tree of plain objects, arrays, strings, numbers, booleans and `null`. No constructor of yours runs; no method is attached; nothing is evaluated. That is the whole reason parsing replaced the old habit of evaluating a response as source code, and it is why the parse call itself is not an execution vector. Malformed text simply fails with a `SyntaxError`. That safety is narrower than it sounds. It says the *parse* runs no code. It says nothing about what your code then does with the tree. ## The "__proto__" key: safe on arrival, dangerous on use ```js const parsed = JSON.parse('{"__proto__":{"admin":true},"a":1}'); Object.getPrototypeOf(parsed) === Object.prototype; // true parsed.admin; // undefined Object.keys(parsed); // ['__proto__', 'a'] ``` Parsing defines properties directly on the new object rather than assigning them, so `"__proto__"` lands as an ordinary own data property. The object's prototype is untouched. People often stop here and conclude there is nothing to worry about. The danger is in the next step, because `Object.prototype` exposes `__proto__` as an **accessor**, and any ordinary property *assignment* with that key invokes its setter: ```js const target = {}; Object.assign(target, parsed); // assignment, not definition Object.getPrototypeOf(target) === Object.prototype; // false target.admin; // true ``` Copying the parsed object into another object replaced that object's prototype. A recursive deep-merge is worse still: such code typically reads `target[key]` to descend into it, and reading `target['__proto__']` yields `Object.prototype` itself, so the merge writes attacker-chosen properties onto the prototype every object in the process inherits from. Suddenly an unrelated `config.isAdmin` reads as `true` because nobody set it and the lookup walked the chain. The defensive moves are concrete: read only the keys you expect rather than copying wholesale; reject or strip a `"__proto__"` key at the boundary; use `Object.defineProperty` or a `Map` rather than assignment when copying untrusted keys; and prefer a merge implementation that skips `__proto__`, `constructor` and `prototype`. ## Duplicate keys: last one wins, silently ```js JSON.parse('{"role":"user","role":"admin"}'); // { role: 'admin' } ``` The grammar permits repeated names and the parser keeps the last. This matters whenever two different pieces of software read the same bytes — a gateway that checks one occurrence and a service that acts on another can be made to disagree, and neither sees anything malformed. If the text passes through more than one reader, canonicalise: parse once, and forward the re-serialized object rather than the original bytes. ## Numbers are parsed as doubles ```js JSON.parse('{"id":12345678901234567890}').id; // 12345678901234567000 ``` The grammar allows arbitrarily many digits; the language's number type does not. Large identifiers arrive already rounded, and no reviver can recover the original — by the time your callback runs, the value argument is a number and the text is gone. This is why identifiers are conventionally transmitted as strings. Very long decimal fractions round the same way. ## Cost is unbounded until you bound it Parsing materialises the entire document before returning, so peak memory scales with the input, and a reviver adds a callback invocation per node — its cost scales with key count, not byte count. There is no early exit and no streaming: you cannot decide halfway through that the document is too big. Any limit therefore has to be enforced *before* the call, on the received bytes, at whatever layer accepts them. ## What is not a risk here Be precise about the boundary, because overclaiming is as bad as underclaiming. Parsing cannot instantiate one of your classes, cannot call a getter you wrote, cannot trigger a network request, and cannot reach a global. There is no type-tag mechanism in the grammar for a document to say "build me one of these". A parsed value is dangerous only in proportion to what the surrounding code does with unvalidated shapes — passing it straight into a template, a query builder, a merge, or a function that assumes fields exist. ## The practical checklist 1. Bound the size of the input before parsing it. 2. Wrap the call so a malformed document produces a controlled failure rather than an unhandled exception. 3. Validate the parsed value's *shape* before use; parsing guarantees well-formedness, never correctness. 4. Read the fields you expect by name instead of merging the whole object into something. 5. Treat `__proto__`, `constructor` and `prototype` as keys to reject at the boundary. 6. Transmit large identifiers as strings, and compare them as strings.
- If parsing does not change the prototype, why is __proto__ in a payload still a problem?Because parsing *defines* the property while most downstream code *assigns* it. `Object.prototype` exposes `__proto__` as an accessor, so any ordinary assignment with that key runs its setter and replaces the target's prototype. A recursive merge is worse: it reads `target['__proto__']`, gets `Object.prototype` itself, and writes attacker-chosen properties onto the object every value in the process inherits from.
- Why can a reviver not fix the precision lost on a large integer?Because the loss happens during parsing, before the reviver is ever called. By the time your callback receives the value it is already a rounded double and the original digits are gone. The only fixes are on the producing side — send large identifiers as strings — or, in engines that expose the raw source text to the reviver, read that text instead of the number.
- How do you bound the cost of parsing an untrusted document?Before the call, not during it. Parsing materialises the whole tree with no early exit and no streaming, so the limit belongs at the layer that receives the bytes: cap the accepted body size and reject over-limit input before it reaches the parser. If you pass a reviver, remember its cost scales with the number of keys, so a heavy callback multiplies the exposure.
- What guarantee does successful parsing actually give you about the value?Only that the text was well-formed. It says nothing about whether the required fields are present, whether their types are what you expect, or whether the values are in range. Shape validation is a separate step, and skipping it is how a missing field becomes a crash three layers deeper with a stack trace that points nowhere useful.
saying these in an interview costs you the question
- Claims JSON.parse can execute code in the payload
- Says a __proto__ key is harmless because parsing ignores it
- Assumes duplicate keys are a syntax error
- Believes successful parsing means the data is valid
- Thinks a size limit can be applied inside the parse call