`cmdVerifyKeyLinks` compiled `must_haves.key_links[].pattern` from plan frontmatter with `new RegExp()` and tested it against whole file contents, so a nested-quantifier pattern such as `(a+)+$` hung `verify-phase` indefinitely (CWE-1333). JavaScript has no regex-execution timeout.
Untrusted patterns now run on RE2 (re2js), whose match time is linear in input length — the class is closed by the engine, not by a heuristic screen. The screen lost in the ADR-0174 consolidation was deliberately NOT restored: it never worked, since `(a|a)*$`, `((a+))+$`, `(a+){2,}$` and `(a{1,3})+$` all evade it. A refused pattern's matcher returns false for every input, so it cannot report a match no matter what the caller does.
The engine is vendored at gsd-core/bin/lib/vendor/re2js.cjs because gsd-core/bin/** is copied into installed trees with no node_modules; runtime dependencies are unchanged. New ESLint rule local/no-external-require-in-bin enforces that invariant, which had been documented in a comment since the #3024/#2071 bug class and enforced nowhere.
Backreferences and look-around are unsupported by RE2 by construction — disclosed in a Changed changeset.
Closes #3477
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,12 @@
|
||||
* migration-equivalence property sweep in tests/pattern.test.cjs (rows 15-17).
|
||||
*/
|
||||
|
||||
// re2js is vendored, not an npm dependency at runtime: gsd-core/bin/** is
|
||||
// copied into installed trees that have no node_modules, so this module must
|
||||
// carry zero external requires (eslint-rules/no-external-require-in-bin.cjs
|
||||
// enforces it). See gsd-core/bin/lib/vendor/README.md.
|
||||
import { RE2JS } from './vendor/re2js.cjs';
|
||||
|
||||
// #3498: RegExp.escape is ES2026 (first shipped in Node 24). The gsd-test
|
||||
// matrix still runs a linux-node22 lane, and the build itself consumes this
|
||||
// module (scripts/gen-loop-host-contract.cjs), so a hard dependency breaks
|
||||
@@ -64,3 +70,66 @@ export function escapeRegex(value: string): string {
|
||||
export function literalPattern(value: string, flags?: string): RegExp {
|
||||
return new RegExp(escapeRegex(value), flags);
|
||||
}
|
||||
|
||||
/** Max length for a user-supplied regex pattern before it is refused (ReDoS/compile-cost mitigation). */
|
||||
export const MAX_USER_PATTERN_LEN = 512;
|
||||
|
||||
/** Reason an untrusted pattern was refused and never compiled, or `null` if it compiled. */
|
||||
export type UserPatternNeutralization = 'empty' | 'too-long' | 'unsupported';
|
||||
|
||||
export interface UserPatternResult {
|
||||
/** Linear-time match via RE2. Returns false for every neutralized pattern, by construction. */
|
||||
test(input: string): boolean;
|
||||
/** null when the pattern compiled; otherwise why it was refused. */
|
||||
neutralized: UserPatternNeutralization | null;
|
||||
}
|
||||
|
||||
/** Always-false matcher shared by every neutralization path — a refused pattern must never be able to report a match. */
|
||||
const NEVER_MATCH: UserPatternResult['test'] = () => false;
|
||||
|
||||
/**
|
||||
* Compile an UNTRUSTED, user-supplied pattern (e.g. plan frontmatter) via RE2
|
||||
* (re2js), whose matching is linear-time in input length by construction —
|
||||
* there is no backtracking engine here to exploit, so the vulnerability class
|
||||
* (catastrophic/exponential backtracking) is closed by the engine rather than
|
||||
* detected by a heuristic scan of the pattern text.
|
||||
*
|
||||
* Never throws. A pattern that is empty, too long, or not valid RE2 syntax
|
||||
* (backreferences and look-around are unsupported by RE2 — those are exactly
|
||||
* the constructs that require backtracking) is REFUSED: `test()` always
|
||||
* returns `false`, and `neutralized` reports why so callers can surface the
|
||||
* refusal (#3477 follow-up: a neutralized pattern must not look like a plain
|
||||
* "not found"). Restores the guards lost with
|
||||
* `sdk/src/query/validate.ts:regexForKeyLinkPattern` (#3477); this revision
|
||||
* (post-#3477-follow-up) replaces the hand-rolled backtracking-shape scanner
|
||||
* with RE2's linear-time guarantee — a refused pattern is never re-attempted
|
||||
* as a literal-escaped match, since guessing at a pattern we could not
|
||||
* compile is what produced the prior false-pass regression.
|
||||
*
|
||||
* `pattern` is `unknown`, not `string`, because callers pull this straight off
|
||||
* parsed plan frontmatter (untrusted YAML) — a non-string value must reach the
|
||||
* `'empty'`/never-match branch rather than being force-cast by the caller.
|
||||
*/
|
||||
export function compileUserPattern(pattern: unknown): UserPatternResult {
|
||||
if (typeof pattern !== 'string' || pattern.length === 0) {
|
||||
return { test: NEVER_MATCH, neutralized: 'empty' };
|
||||
}
|
||||
if (pattern.length > MAX_USER_PATTERN_LEN) {
|
||||
// The cap now bounds compile cost/memory, not backtracking (RE2 has none) —
|
||||
// an over-long pattern is refused outright rather than truncated-and-compiled.
|
||||
return { test: NEVER_MATCH, neutralized: 'too-long' };
|
||||
}
|
||||
try {
|
||||
// translateRegExp accepts JS-flavored syntax (named groups, `/`-escaping,
|
||||
// etc.) that RE2's own grammar doesn't, reducing spurious refusals of
|
||||
// otherwise-safe, JS-authored patterns before compiling under RE2's
|
||||
// linear-time engine.
|
||||
const compiled = RE2JS.compile(RE2JS.translateRegExp(pattern));
|
||||
return { test: (input: string) => compiled.test(input), neutralized: null };
|
||||
} catch {
|
||||
// Backreferences, look-around, or any other RE2-unsupported/malformed
|
||||
// syntax. No literal-escape fallback: a pattern we could not compile is
|
||||
// never guessed at — guessing produced the #3477 false-pass regression.
|
||||
return { test: NEVER_MATCH, neutralized: 'unsupported' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user