Blog / / 5 min read

Your validator checked one value. Your app used another.

A validation soundness bug class in JavaScript. Some widely-used validators inspect the caller's live object and hand it back unchanged, so a getter or Proxy can slip a value past the schema that the app then uses. We confirmed it in class-validator and superstruct. joi and zod are safe, and the reason they are safe is the fix. Reported upstream.

Some validators check the object you hand them and give the same object back. In a language with side-effecting property reads, that is a time-of-check/time-of-use bug, and it can turn an allowlist into a bypass. We found it in two libraries that millions of projects depend on, reported it, and here is the fix.

A validator has one job: guarantee that the data your application uses satisfies a schema. So ask a precise question of any validator. Does the value it checked stay the same when your code reads it next?

For a surprising number of popular JavaScript validators, the answer is no.

Two designs, one of them unsound

There are two ways a validator can hand you back “validated” data:

  • Return the live object. Read the caller’s properties, check them, return the same object. Your app then reads those properties again when it uses them.
  • Materialize a snapshot. Read each property once, build a fresh validated value, return that. Your app uses the copy.

The first design silently assumes every property read returns the same value every time. JavaScript makes no such promise. An accessor getter, a Proxy get-trap, or a value derived from mutable state can return a compliant value at check time and a different one at use time.

Validation Flow Split

The proof

// [email protected]
import { object, enums, validate } from "superstruct";
const Schema = object({ role: enums(["readonly", "guest"]) });

let reads = 0;
const input = { get role() { return ++reads === 1 ? "readonly" : "admin"; } };

const [err, output] = validate(input, Schema);
// err === undefined     -> passes the allowlist
// output === input       -> same live object
// output.role === "admin"  -> your app now uses a value validation never saw

The same shape bypasses class-validator when validating a class instance whose property is a getter, an idiomatic pattern in, for example, NestJS DTOs:

class Dto { @IsIn(["readonly", "guest"]) role!: string; }
const dto = Object.assign(new Dto(), {
  get role() { return ++reads === 1 ? "readonly" : "admin"; },
});
validateSync(dto).length === 0;  // valid against {readonly, guest}
dto.role === "admin";            // used anyway

Run the identical input through joi and zod and it does not work, because they return a materialized value read once. That differential is the whole story: the safe libraries are safe for a reason you can copy.

How serious is it? Medium, and here is exactly why

We are allergic to inflated severity, so let’s be precise. This is not reachable from a plain JSON HTTP body. JSON.parse produces inert data, not getters. It becomes exploitable when an application validates:

  • class instances or models with accessor getters (common in DTO frameworks, e.g. a getter deriving its value from request or session state),
  • Proxy-wrapped objects, or
  • any property whose value changes between the validate call and the later use.

Under those conditions it is a real authorization or allowlist bypass. Outside them it is a latent soundness gap. We rate it Medium and say so plainly.

Why this is a class, not a one-off

The bug is a design property, not one library’s implementation slip. “Validate the live object and return it” is unsound in any language with side-effecting property reads. Any validator built that way is a candidate; any validator that returns a single-read materialized snapshot is immune. That is a one-line litmus test you can run against any validator you depend on.

The fix

Return, and encourage callers to use, a materialized, read-stable snapshot of the validated data. Read each property exactly once into a fresh plain object, the way joi (value) and zod (data) do. At minimum, document that the validator inspects the caller’s live object and gives no read-stability guarantee, so callers must not validate objects carrying attacker-influenced accessor or Proxy properties.

Disclosure

We reported both findings upstream with the proof-of-concept and the fix, framed as hardening, asking for nothing in return: class-validator #2687 and superstruct #1301. Neither repository had private vulnerability reporting enabled, so the reports are public and constructive.

A note on method

We did not find this by asking a language model to “look for bugs.” We built a deterministic trap-object harness: objects whose valueOf, toString, toJSON, Symbol.toPrimitive, getters, and Proxy traps record when they fire and can return read-unstable values, fed through the entry points of widely-used packages. A finding is confirmed only on an observed security consequence, and assumed to be a false positive otherwise. The model proposes hypotheses; the harness proves or kills them by execution. That is how you find a new class instead of re-discovering known patterns, and it is the approach we are building into our engine.