fix(tasks): accept optional groups in the regex screen and bound RegexTask matching - #848
Merged
Merged
Conversation
…xTask matching
The ReDoS shape screen read `?` (and a bare `{`) after `)` as "the group is
quantified", so any group whose body contains `+`/`*` and is made optional
tripped the nested-quantifier rule. `(X)?` bounds the group to one repetition,
so backtracking stays linear — `^-?\d+(\.\d+)?$`, `(\w+)?` and `^(#.*)?$` were
all rejected outright across FileGrepTask, FileSedTask and RegexTask.
The predicate is now "does this quantifier permit two or more repetitions",
which keeps `(a+){2}` / `(a+){10}` / `(a+){2,}` rejected. Requiring a real
`{n,}`/`{n,m}` instead would have started accepting `(a+){10}`, measured at
46,318 ms on a 41-character input.
Relaxing the screen makes the second half necessary: RegexTask compiled and
matched on the calling thread with nothing bounding the match, and the screen's
own JSDoc says a `false` is not a safety guarantee. `^(a?b?)*$` passes the
screen and ran 31,714 ms against `"ab".repeat(28) + "!"`. RegexTask now takes
its matcher from a registered `RegexRunnerFactory` — the injection seam this
package already uses for safeFetch, since `TaskRegistry.registerTask` refuses a
second class for one type and so a `.server` subclass is not available. The
Node/Bun/Electron entrypoints install a vm-backed runner under
`SECURITY_LIMITS.regexMatchBatchTimeoutMs`; the browser default stays
unbounded, where a hostile pattern blocks that tab rather than a host process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
Coverage Report
File CoverageNo changed files found. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two coupled issues in the ReDoS defence. They ship together because fixing the first makes the second worse.
1. The shape screen rejected safe, common patterns
scanPattern(packages/tasks/src/util/regexSafety.ts) treated?— and a bare{— after)as "the group is quantified", so any group whose body contains+/*and is then made optional trippednestedQuantifiers.(X)?bounds the group to at most one repetition, so there is nothing for the engine to backtrack over and the match stays linear. A pure false positive, and it gated three user-facing tasks:FileGrepTask,FileSedTask,RegexTask.Confirmed rejected-but-safe before this change:
The correct predicate is "does the quantifier permit two or more repetitions", added as
quantifierAllowsRepeatbesideisUnboundedRepetitionAt(which is left alone — it answers the body question, "can this match variable lengths", whereX{10}is fixed-length and fine).The tempting cheaper variant — reuse
isUnboundedRepetitionAtfor the group test, i.e. require a real{n,}/{n,m}instead of a bare{— was rejected: its regex requires a comma, so it would start accepting(a+){10}, measured at 46,318 ms against a 41-character input, and would flip the existing must-reject case(a+){2}.it.each(["(a+){2}", "(a+){2,3}", "(a+){10}", "(a+){2,}"])pins that.Sticky
execthroughout, nevertest(pattern.slice(...))— slicing per character is the quadratic shape the module opens by explaining it exists to avoid.Newly accepted (all verified against the real module)
^-?\d+(\.\d+)?$,^-?\d+(?:\.\d+)?$,(\w+)?,^(#.*)?$,(\s+)?end,(https?://\S+)?,(ERROR|WARN)\s+(\w+)?,(a+)?,(?<year>\d+)?,(a+){1},(a+){0,1},(a+){foo}— plus the already-passing(\d)?,^(\d{4})-(\d{2})-(\d{2})$,(foo|far)+,([a-z]|[A-Z])*,foo.*bar,(a\+)+,([*+])+,(?:\r?\n)+,^(?:https?://)?(www\.)?example\.com.Still rejected (all verified)
(a+)+,(a*)+,(a+)*,(a+){2},((b|c+))+,(a+)+$,(a*)*$,(x+x+)+y,([a-z]+)+$,((a)*)*$,(\d+)+$,(a|a)*$,(?:a|a)*$,(a|a|a)+$,^(a|ab)+$,(a*|b)+$,(a{2,})*$,([a-z]|[a-z])*,\[(a+)+],([a-z]*|[A-Z])+,(a+){2,3},(a+){10},(a+){2,},((a+)?)+.((a+)?)+is the interesting one: the inner(a+)?no longer trips the rule, but "a quantifying group counts as a quantifier for whatever encloses it" still marks the outer group, so it stays rejected. Pinned by its own test.One intentional expectation flip:
"(a+)?"was removed fromit("rejects nested quantifiers").2.
RegexTaskmatched unboundedRegexTaskcalledassertSafeRegexPatternand thennew RegExp+exec/matchAllon the calling thread. The screen's own JSDoc says afalseis not a safety guarantee and that the enforced containment is the match budget increateBoundedRegexMatcher— whichRegexTasknever used.^(a?b?)*$passes the screen (no group quantifies, no alternation overlaps) and measured 31,714 ms against"ab".repeat(28) + "!".executePreviewran the same unbounded code. Relaxing the screen widens what reaches this path, hence one PR.A
RegexTask.serversubclass is not viable:TaskRegistry.registerTaskthrows on a second class for the sametype, andRegexTaskis registered fromcommon.ts. So this uses the injection seam the package already has forsafeFetch(SafeFetch.ts+SafeFetch.server.tstail + a side-effect import innode.ts):util/BoundedRegexRunner.ts(new, cross-platform) —RegexRunner/RegexRunnerFactory, plusregisterRegexRunnerFactory/getRegexRunnerFactory/resetRegexRunnerFactory, the same three-function shape asSafeFetch.ts.defaultRegexRunnerFactorydoes the plain unbounded exec: the browser default, where a hostile pattern blocks the tab rather than a host process — the trade-off already documented onFileGrepTask.createLineMatcher.util/BoundedRegex.server.ts—createBoundedRegexExecutor(timeoutMs), using one module-levelcreateContext+Scriptwithreassigned per call. Measured: shared context 0.16 ms/call vs 0.74 ms for a fresh one, andRegexTaskevaluates a single value per run, so the per-call-site contextcreateBoundedRegexMatcherbuilds is the wrong shape here. Results are copied out withArray.prototype.slice.call(r)so an unmatched optional group staysundefinedin place rather than being dropped; the zero-length-matchlastIndex++guard is reused fromcreateBoundedRegexExtractor. Timeout throwsTaskInvalidInputErrorwith the existing message shape — deliberately notTaskTimeoutError, which extendsTaskAbortedErrorand would report the run aborted rather than failed by bad input. The file ends with theregisterRegexRunnerFactory(...)call atSECURITY_LIMITS.regexMatchBatchTimeoutMs.node.ts/electron.ts— side-effect import next toSafeFetch.server.common.tsre-exports the runner module.RegexTask.ts—executeRegexnow usescompileSafeRegex(so a bad flag raisesTaskInvalidInputErrorinstead of a rawSyntaxError) and takes its matcher fromgetRegexRunnerFactory(). This also collapses the doublenew RegExpthe global path was doing. BothexecuteandexecutePreviewgo through it.Tests
^((a?)(b?))*$is the budget test pattern precisely because it is invisible to the screen both before and after this change — it proves the budget, not the screen. Unbounded it measures 5,829 ms against"ab".repeat(28) + "!"; the budget is 1,000 ms. It now terminates at ~1,007 ms. Bun honours avmtimeout coarsely, so the assertion leaves headroom at 5,000 ms.The nine existing
RegexTaskassertions are the regression suite for the runner swap and pass unchanged.Full section, against the built
dist(bun run build:packagesthenbun scripts/test.ts task vitest):bunx tsc -p packages/tasks/tsconfig.json --noEmitis clean, andbun run formatreports no changes.🤖 Generated with Claude Code
https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
Generated by Claude Code