Parse untrusted JSON
One call that parses and removes the three key names that turn data into instructions.
#The problem
__proto__ is not an ordinary property name in JavaScript. Assigning to it changes an object's prototype, and a prototype is shared. So a request body like this is not data:
{ "name": "alice", "__proto__": { "isAdmin": true } }JSON.parse itself is safe — it creates a plain own property — but almost anything you do next is not. A deep merge, a clone, a config loader, an ORM's hydrate step: any of them can carry the assignment out, and every object in the process inherits isAdmin: true.
#The fix
import { safeJsonParse } from "unsecure/sanitize";
const payload = safeJsonParse('{"a":1,"__proto__":{"admin":true},"n":{"constructor":2}}');
// { a: 1, n: {} }
Object.keys(payload); // ["a", "n"]safeJsonParse parses, then walks the result and deletes every own property named exactly __proto__, prototype or constructor, at any depth. Any JSON root works: an object, an array, or a primitive.
#In a handler
import { safeJsonParse } from "unsecure/sanitize";
import { UnsecureError } from "unsecure/errors";
export async function readBody<T>(request: Request): Promise<T | null> {
const text = await request.text();
try {
return safeJsonParse<T>(text);
} catch (error) {
if (error instanceof UnsecureError && error.code === "MALFORMED") return null;
throw error;
}
}Unparseable JSON is MALFORMED, with the engine's own SyntaxError in cause — that is the half that says where the text went wrong, so log it.
safeJsonParse("{oops");
// UnsecureError MALFORMED: safeJsonParse: Expected property name or '}' in JSON at position 1 (line 1 column 2)#When the framework already parsed
Most frameworks hand you a parsed body. Sanitize that instead.
import { sanitizeObject } from "unsecure/sanitize";
// Express, Hono, Fastify: the body is yours, so mutate it
function sanitizeMiddleware(req, res, next) {
if (req.body && typeof req.body === "object") sanitizeObject(req.body);
next();
}sanitizeObject returns the same reference it was given and is the cheapest of the three: a single pass per node, no intermediate allocations.
If the object is not yours to change, copy it:
import { sanitizeObjectCopy } from "unsecure/sanitize";
const safe = sanitizeObjectCopy(caller.body); // caller.body is untouched#What is and is not covered
Covered: own properties named __proto__, prototype or constructor, on arrays and objects, at any depth, including non-enumerable ones planted with Object.defineProperty, and including cycles.
Not covered: the contents of a Map, a Set, a class instance or any other non-plain object. sanitizeObjectCopy carries those into the copy by reference, which means unchanged and also unsanitized.
// A strictly plain, fully walked structure
const plain = safeJsonParse(JSON.stringify(obj));Nothing here reads a getter. Every value comes from its property descriptor, array elements included, so traversing an object never runs code the object carries. A Proxy is the one exception and cannot be otherwise: its traps run for every property operation, getOwnPropertyDescriptor included. Sanitize the target rather than the proxy when that matters.
#Frozen input
sanitizeObject(Object.freeze(JSON.parse('{"__proto__":{"x":1}}')));
// UnsecureError FROZEN: sanitizeObject: cannot remove "__proto__" from a frozen object; use sanitizeObjectCopy().A frozen object cannot have the key removed, and reporting success would be a lie. Use the copy variant.
#Depth
All three functions walk an explicit stack, so nesting depth is bounded by memory rather than by the call stack. A payload a few kilobytes long, nested ten thousand levels deep, is handled — the kind of input that used to be a denial-of-service against a recursive sanitizer.
That is not a substitute for a body size limit. Set one.