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:

parse-json.ts
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, null and ["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:

parse-json.ts
/** 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:

parse-json.ts
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 Map and members of a Set
  • Getters: neither function calls one, so whatever sits behind a getter is never walked
  • Proxies: a Proxy runs its own code for every property read, so sanitize the object it wraps instead

#When it fails

SituationWhat happens
Text is not JSONsafeJsonParse throws MALFORMED. Its cause holds the engine's SyntaxError, which says where the text broke.
JSON is valid but the wrong shapeNothing throws. Your own validation has to answer it.
A dangerous key sits on a frozen or sealed objectsanitizeObject 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 namesanitizeObjectCopy throws INVALID_TYPE, naming the type it met.
allowlist is not a list of constructorssanitizeObjectCopy throws INVALID_TYPE.
sanitizeObject or sanitizeObjectCopy gets undefined, null or a primitiveBoth return it unchanged. safeJsonParse needs text: anything else throws MALFORMED.

#Options

safeJsonParse and sanitizeObject take no options. sanitizeObjectCopy(value, options) takes one:

OptionDefaultNotes
allowlistnoneConstructors 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 vm context or an iframe has that realm's own Object.prototype. sanitizeObjectCopy still sees it as plain and builds the copy on your realm's prototype. The catch is allowlist: instanceof only sees one realm, so a Date from an iframe needs the iframe's Date in the list.
  • Hidden keys: sanitizeObject also deletes a non-enumerable __proto__ added with Object.defineProperty, and the three names hung on an array. sanitizeObjectCopy only copies own enumerable data properties, so neither makes it into the copy, and the copy always sits on Object.prototype.