69 lines
3.2 KiB
Plaintext
69 lines
3.2 KiB
Plaintext
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);
|
|
});
|