Parse untrusted JSON
Parse a request body, or clean one your framework already parsed, so that __proto__, constructor and prototype never reach the rest of your code.
JSON is allowed to contain a key literally called __proto__, as in {"__proto__": {"isAdmin": true}}. Parse it, hand it to a deep merge, and that key lands on Object.prototype, which every object in your process inherits from. Now everything has isAdmin: true. That's prototype pollution, and it's the whole reason this module exists.
safeJsonParse parses the text and then deletes every key named __proto__, constructor or prototype, however deep it's buried, so what you get back is plain data. If you're not sure what counts as untrusted, trusted and untrusted input covers it.
#Before you start
- Any runtime works. These functions use no Web Crypto and no other platform API.
- They delete three key names and nothing else. They do not check the shape of the data, and the type parameter of
safeJsonParse<T>is a cast, not a check. Validate the result before you read a field. - Keep the body size limit your server sets. Depth needs no limit of its own: a payload nested 100,000 levels deep is walked without overflowing the call stack.
#Parse a request body
Read the body as text and let safeJsonParse do the parsing, so the keys are gone before any of your code gets a look at the value:
import { UnsecureError } from "unsecure/errors";
import { safeJsonParse, sanitizeObject, sanitizeObjectCopy } from "unsecure/sanitize";
// Settings and SettingsStore are your application's own types.
export async function updateSettings(
request: Request,
userId: string,
store: SettingsStore,
): Promise<Response> {
let patch: unknown;
try {
patch = safeJsonParse(await request.text());
} catch (error) {
if (error instanceof UnsecureError && error.code === "MALFORMED") {
return Response.json({ error: "The body is not valid JSON." }, { status: 400 });
}
throw error;
}
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
return Response.json({ error: "Expected a JSON object." }, { status: 422 });
}
return Response.json(await store.update(userId, patch as Settings));
}The handler deals with two client mistakes on its own:
- One code to catch: text that isn't JSON throws
MALFORMED. That's the client's mistake, so it becomes a 400, and anything else is rethrown because it's yours to fix. Handle errors covers the rest. - Any JSON root:
42,nulland["dark"]are valid JSON too. Check that you got an object before you treat it like one.
#Clean a body your framework already parsed
If your framework parses JSON for you, clean the object in place with sanitizeObject, which returns the same reference minus the keys:
/** For a framework that already parsed the body: strip the keys in place. */
export function sanitizeBody(req: { body?: unknown }, _res: unknown, next: () => void): void {
if (typeof req.body === "object" && req.body !== null) {
sanitizeObject(req.body as Record<string, unknown>);
}
next();
}#Copy a value other code still holds
When other code still holds the object, sanitizeObjectCopy leaves it alone and builds a clean copy, passing through only the classes you name in allowlist:
interface Job {
id: string;
runAt: Date;
payload: Record<string, unknown>;
}
/**
* A job message from a worker thread. Every listener receives the same
* object, so copy it instead of changing it. `Date` values survive the
* message, so they are allowed through as they are.
*/
export function readJob(message: unknown): Job {
return sanitizeObjectCopy(message, { allowlist: [Date] }) as Job;
}The copy rebuilds plain objects and arrays, the shapes JSON produces, and refuses anything else with INVALID_TYPE. Whatever allowlist lets through goes into the copy as it is, unexamined, so only list classes whose instances you'd trust untouched, like Date.
#What is not sanitized
Both functions only look at own properties, so a few places stay unchecked:
- Collections: entries of a
Mapand members of aSet - Getters: neither function calls one, so whatever sits behind a getter is never walked
- Proxies: a
Proxyruns its own code for every property read, so sanitize the object it wraps instead
#When it fails
| Situation | What happens |
|---|---|
| Text is not JSON | safeJsonParse throws MALFORMED. Its cause holds the engine's SyntaxError, which says where the text broke. |
| JSON is valid but the wrong shape | Nothing throws. Your own validation has to answer it. |
| A dangerous key sits on a frozen or sealed object | sanitizeObject throws FROZEN instead of reporting a clean object that is not one. Use sanitizeObjectCopy. |
The copy meets a Date, Map, Set, typed array or class instance that allowlist does not name | sanitizeObjectCopy throws INVALID_TYPE, naming the type it met. |
allowlist is not a list of constructors | sanitizeObjectCopy throws INVALID_TYPE. |
sanitizeObject or sanitizeObjectCopy gets undefined, null or a primitive | Both return it unchanged. safeJsonParse needs text: anything else throws MALFORMED. |
#Options
safeJsonParse and sanitizeObject take no options. sanitizeObjectCopy(value, options) takes one:
| Option | Default | Notes |
|---|---|---|
allowlist | none | Constructors whose instances pass into the copy by reference, unexamined. Matched with instanceof, so subclasses count. |
#Going further
- Values from another realm: an object parsed in a
vmcontext or an iframe has that realm's ownObject.prototype.sanitizeObjectCopystill sees it as plain and builds the copy on your realm's prototype. The catch isallowlist:instanceofonly sees one realm, so aDatefrom an iframe needs the iframe'sDatein the list. - Hidden keys:
sanitizeObjectalso deletes a non-enumerable__proto__added withObject.defineProperty, and the three names hung on an array.sanitizeObjectCopyonly copies own enumerable data properties, so neither makes it into the copy, and the copy always sits onObject.prototype.