# 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.

Published: 2026-07-14

Canonical article: https://0.security/blog/validation-read-stability/

*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](/blog/validation-flow-split.png)

## The proof

```js
// superstruct@2.0.2
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:

```js
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](https://github.com/typestack/class-validator/issues/2687) and [superstruct #1301](https://github.com/ianstormtaylor/superstruct/issues/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.
