zod
Version:
TypeScript-first schema declaration and validation library with static type inference
69 lines (60 loc) • 3.32 kB
text/typescript
import { checkSync } from "recheck";
import { expect, test } from "vitest";
import * as z from "zod/v4";
const { regexes } = z.core;
/** Every pattern `core/regexes.ts` ships, with the parameterized ones materialized across their argument space. */
function allPatterns(): [string, RegExp][] {
const patterns: [string, RegExp][] = [];
for (const [name, value] of Object.entries(regexes)) {
if (value instanceof RegExp) patterns.push([name, value]);
}
patterns.push(
["emoji()", regexes.emoji()],
["mac()", regexes.mac()],
["mac('-')", regexes.mac("-")],
["uuid()", regexes.uuid()],
["string()", regexes.string()],
["string({min,max})", regexes.string({ minimum: 1, maximum: 10 })]
);
for (const version of [1, 2, 3, 4, 5, 6, 7, 8]) patterns.push([`uuid(${version})`, regexes.uuid(version)]);
for (const precision of [null, -1, 0, 3, 6]) {
patterns.push([`time(${precision})`, regexes.time({ precision })]);
for (const local of [false, true]) {
for (const offset of [false, true]) {
patterns.push([`datetime(${precision},${local},${offset})`, regexes.datetime({ precision, local, offset })]);
}
}
}
return patterns;
}
test("no built-in pattern is ReDoS-vulnerable", () => {
const patterns = allPatterns();
// Guards against the reflection above silently going empty if the module layout changes.
expect(patterns.length).toBeGreaterThan(80);
const vulnerable: string[] = [];
for (const [name, pattern] of patterns) {
// Flags are load-bearing. Without "u" the checker reads `\p{...}` as a literal `p{...}` and reports an exponential pattern as safe.
const result = checkSync(pattern.source, pattern.flags);
// "unknown" means the checker gave up, not that the pattern is vulnerable.
if (result.status !== "vulnerable") continue;
// The fuzz checker derives "vulnerable" from how long an attack string runs, so CPU contention from parallel test files can flip a safe pattern. Confirm with a budget large enough that only real backtracking can exhaust it.
const confirmed = checkSync(pattern.source, pattern.flags, { attackTimeout: 10_000 });
if (confirmed.status === "vulnerable") vulnerable.push(`${name}: ${pattern.source}`);
}
expect(vulnerable).toEqual([]);
}, 60000);
test("emoji rejects a backtracking payload in linear time", () => {
// U+1F9B0-U+1F9B3 are the only code points in both \p{Extended_Pictographic} and \p{Emoji_Component}. A failing match over them used to backtrack exponentially: 26 of them took ~1.9s, and each additional character roughly doubled it.
const schema = z.emoji();
const payload = `${"🦰".repeat(26)} `;
// Warm up first, then take the best of three: a cold first call is dominated by JIT and can cost hundreds of times the steady-state match on a loaded machine.
expect(schema.safeParse(payload).success).toBe(false);
let best = Number.POSITIVE_INFINITY;
for (let i = 0; i < 3; i++) {
const start = performance.now();
schema.safeParse(payload);
best = Math.min(best, performance.now() - start);
}
// The linear pattern matches in well under a millisecond, so this budget leaves three orders of magnitude of headroom for CPU contention while still catching the exponential regression.
expect(best).toBeLessThan(500);
});