Sanitize

Strip __proto__, prototype and constructor out of untrusted data, at parse time, in place, or into a copy.

Note

ELI5. In JavaScript, an object named __proto__ is not an ordinary key: assigning to it can change the behaviour of every object in the program. So a request body containing {"__proto__": {"isAdmin": true}} is not data, it is an instruction, and a careless merge or clone can carry it out. These three functions walk a value and delete those three names wherever they appear as own properties. Use safeJsonParse on text that came from outside. Use the others when you already have the parsed object.

import { safeJsonParse } from "unsecure/sanitize";

safeJsonParse('{"a":1,"__proto__":{"admin":true},"n":{"constructor":2}}');
// { a: 1, n: {} }

#Signatures

// Mutates the input and returns the same reference
function sanitizeObject<T extends Record<string, unknown> | undefined>(obj: T): T;

// Returns a sanitized deep copy; the input is never touched. Cycle-safe.
function sanitizeObjectCopy<T extends Record<string, unknown> | undefined>(obj: T): T;

// JSON.parse, then the in-place sanitizer. Any JSON root.
function safeJsonParse<T = any>(json: string): T;

#Which one to use

If you…Use
Have JSON text and want one safe stepsafeJsonParse
Already parsed, and the object is yours to mutatesanitizeObject — the fastest
Must leave the caller's object exactly as it wassanitizeObjectCopy
import { safeJsonParse, sanitizeObject, sanitizeObjectCopy } from "unsecure/sanitize";

// 1. Parse and sanitize together
const payload = safeJsonParse<{ user: { name: string } }>(untrustedText);

// 2. Post-parse, in place — cheapest on a hot path
const parsed = JSON.parse(untrustedText);
sanitizeObject(parsed); // the same reference comes back

// 3. Post-parse, without touching what you were given
const safe = sanitizeObjectCopy(caller.body);

#What they do

  • Deep traversal over objects and arrays, driven by an explicit stack. Nesting depth is bounded by memory, not by the call stack, so a deeply nested payload cannot overflow it.
  • Cycle-safe. sanitizeObject tracks visited nodes in a WeakSet; sanitizeObjectCopy uses a WeakMap and rewires cycles to point at the copied node.
  • Only own properties named exactly __proto__, prototype and constructor are removed. sanitizeObject enumerates own property names, so a non-enumerable __proto__ planted with Object.defineProperty is removed too.
  • No getter is ever invoked. Every value comes from its property descriptor, array elements included. sanitizeObject leaves accessors in place and deletes a dangerous name unread; sanitizeObjectCopy copies own enumerable data properties only, so accessors do not appear in the copy and an accessor at an array index leaves a hole rather than shifting later elements.
  • Object identity survives sanitizeObject: it strips keys and never replaces an object.
const obj = JSON.parse('{"a":1,"__proto__":{"x":1}}');
sanitizeObject(obj) === obj; // true
obj; // { a: 1 }

const input = JSON.parse('{"a":1,"__proto__":{"x":1}}');
sanitizeObjectCopy(input); // { a: 1 }
Object.keys(input); // ["a", "__proto__"] — untouched

#What they do not do

sanitizeObjectCopy descends only into arrays and plain objects, meaning objects rooted on Object.prototype or on null. Every other value — a Date, Map, Set, typed array, RegExp, class instance, function — is carried into the copy by reference. So copy.when === input.when for a Date.

Carried by reference means unchanged and also unsanitized. Neither function reads what a Map holds, what a Set contains, or what a class instance keeps in its own state. If untrusted material lives inside one of those, run it through safeJsonParse(JSON.stringify(x)) or sanitize it yourself.

A Proxy is the exception and cannot be otherwise: its traps run for every property operation, getOwnPropertyDescriptor included, so a proxied object is traversed through its own traps and proxied code does run. Sanitize the target rather than the proxy when that matters.

sanitizeObjectCopy returns plain objects rooted on Object.prototype even when the input had a null prototype. undefined and non-object inputs come back unchanged, as does a copy root that is neither an array nor a plain object.

#Errors

Causecode
A dangerous key cannot be deleted because the object is frozen or sealedFROZEN
safeJsonParse was handed text that is not JSONMALFORMED
sanitizeObject(Object.freeze(JSON.parse('{"__proto__":{"x":1}}')));
// UnsecureError FROZEN: sanitizeObject: cannot remove "__proto__" from a frozen object; use sanitizeObjectCopy().

safeJsonParse("{oops");
// UnsecureError MALFORMED: safeJsonParse: Expected property name or '}' in JSON at position 1 (line 1 column 2)

The MALFORMED error keeps the engine's own SyntaxError in cause, which is the half that says where the text went wrong.

#Recipe: sanitize a request body

import { safeJsonParse, sanitizeObject } from "unsecure/sanitize";

// A framework that already parsed for you: mutate in place
function sanitizeMiddleware(req, res, next) {
  if (req.body && typeof req.body === "object") sanitizeObject(req.body);
  next();
}

// Reading the body yourself: parse and sanitize in one step
async function readBody(request: Request) {
  return safeJsonParse(await request.text());
}

#Performance

sanitizeObject makes a single pass over each node. It iterates Object.getOwnPropertyNames once with the dangerous-key check inlined into that loop, pays one getOwnPropertyDescriptor per surviving key — the price of never calling a getter — and pushes children onto a traversal stack without allocating an intermediate array. Arrays iterate with a numeric loop, which beats Object.keys on dense arrays.

For deep trees that noticeably reduces both allocations and branches compared to a scan-then-walk approach.

#Pitfall: mutating an object you do not own

// The caller still holds this reference and did not ask for it to change
const original = JSON.parse(data);
sanitizeObject(original);

// Copy instead
const safe = sanitizeObjectCopy(caller.body);

#Pitfall: assuming the sanitizers harden non-plain objects

They strip dangerous own properties from arrays and plain objects. They do not re-home class instances, convert a Map, or alter a prototype chain. A Date reached from a sanitized tree is the same object it was. For a strictly plain, fully-walked structure:

const plain = safeJsonParse(JSON.stringify(obj));