Context
The code-scanning ruleset newly activated on dev (2026-07-17) flagged js/redos (high) at src/form/field/Phone.mjs:29. I confirmed it is a true-positive ReDoS empirically (not a CodeQL false positive), and it is unmitigated by default.
The Problem
inputPattern defaults to:
inputPattern: /^\+?\(?[0-9]+\)?([\-\s\.]?[/0-9]+)*$/
The tail ([\-\s\.]?[/0-9]+)* is an outer * over a group whose separator [\-\s\.]? is optional. A run of consecutive digits can therefore be partitioned across the group's iterations in exponentially many ways. On a failing match — a long digit run followed by one non-matching char ("1111…111!") — the engine explores every partition before rejecting → catastrophic backtracking.
Measured (node, RegExp.test, input '1'.repeat(N)+'!'):
| N (digits) |
time |
| 22 |
17 ms |
| 24 |
69 ms |
| 26 |
278 ms |
| 28 |
1078 ms |
| 30 |
4374 ms |
~4× per +2 chars = 2^N. At ~40 chars this is minutes-to-hours.
Unmitigated: form.field.Text declares maxLength_: null (src/form/field/Text.mjs:216), so a Phone field imposes no input-length bound by default. Neo runs field validation in the App Worker (which owns all UI logic), so a pathological value freezes the UI thread — a real client-side DoS, not merely theoretical.
The Architectural Reality
src/form/field/Phone.mjs:29 — inputPattern is a @reactive config whose default is the vulnerable RegExp. Phone extends form.field.Text; the pattern is applied by Text's validation. The fix is purely the default pattern value; no validation-logic change.
- The accepted language is quirky (independently-optional parens
\(?/\)?, / treated as a digit via [/0-9]). The fix must preserve that language exactly — widening/narrowing it is an out-of-scope behavior change.
The Fix
Replace the default with a linear, language-preserving pattern:
inputPattern: /^\+?\(?[0-9][/0-9]*(?:\)[/0-9]*)?(?:[\-\s\.][/0-9]+)*$/
Mechanism: a single unambiguous leading run [0-9][/0-9]* (since [0-9] ⊂ [/0-9]), a close-paren branch anchored on the literal ), and repeated groups each anchored by a required separator [\-\s\.] — so digit runs can no longer be re-partitioned across iterations.
Validated (script preserved; will ship as the regression test):
- Language: IDENTICAL to the original over 28 valid+invalid cases (
+49 30 1234567, 030/12345678, (123)456-789, 12--34, -12, 12/, …).
- Time: linear — 0.001 ms at small N, 0.219 ms at N=100000, vs. the original's exponential blowup.
Add a spec asserting (a) language-equivalence over the battery and (b) a bounded time / immediate-return on the pathological input (red against the current pattern, green against the fix).
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Neo.form.field.Phone#inputPattern (default value) |
the field's own config |
linear regex, identical accepted language |
none (config default) |
the @member JSDoc default string at Phone.mjs:29 |
28-case accept/reject equivalence + timing table above |
Avoided Traps
- The O(N²) near-fix.
…[0-9]+\)?[/0-9]*(?:[\-\s\.][/0-9]+)*… (append [/0-9]*) kills the exponential but leaves two adjacent digit-matching quantifiers ([0-9]+ and [/0-9]*) → O(N²): 8766 ms at N=100000. It closes js/redos but a large input still hangs and js/polynomial-redos could re-flag it. Rejected in favor of the single-leading-run form. Do not "simplify" back to it.
Decision Record impact
none — no ADR governs field validation patterns.
Out of Scope
- Any change to
form.field.Text validation logic or a default maxLength (defense-in-depth worth considering, but a separate concern).
- The other open high-severity CodeQL alerts (see the triage note below) — this ticket closes exactly the
Phone.mjs ReDoS.
Related
- Broader CodeQL context (newly-active ruleset): 11 high + 9 medium open alerts. Triage I verified while here:
- 5×
js/insecure-randomness in apps/devindex/services/Spider.mjs are FALSE POSITIVES — Math.random() selects a crawl target (stargazer/following walk); no security boundary, no token/nonce. Dismiss candidates, not fixes.
- Remaining highs (covid/table example
js/incomplete-sanitization, webpack config js/incomplete-multi-character-sanitization) need per-alert assessment.
- CodeQL coverage is Grace's lane (
#15353 / #15355); the individual alert fixes are unowned.
Origin Session ID
3f892890-5ce2-4045-8290-dbbdff1b987a
Handoff Retrieval Hints
Retrieval Hint: "Phone field inputPattern ReDoS exponential backtracking linear regex fix"
Live latest-open sweep: checked latest 15 open at 2026-07-17T15:45Z (latest #15365); nearest neighbours #15353 (CodeQL coverage), #15364 (namespace collision) — none equivalent. A2A in-flight claim sweep (last 30, ~45 min window): no [lane-claim]/[lane-intent] overlapping form-field / ReDoS / CodeQL-alert-fix scope.
Context
The code-scanning ruleset newly activated on
dev(2026-07-17) flaggedjs/redos(high) atsrc/form/field/Phone.mjs:29. I confirmed it is a true-positive ReDoS empirically (not a CodeQL false positive), and it is unmitigated by default.The Problem
inputPatterndefaults to:inputPattern: /^\+?\(?[0-9]+\)?([\-\s\.]?[/0-9]+)*$/The tail
([\-\s\.]?[/0-9]+)*is an outer*over a group whose separator[\-\s\.]?is optional. A run of consecutive digits can therefore be partitioned across the group's iterations in exponentially many ways. On a failing match — a long digit run followed by one non-matching char ("1111…111!") — the engine explores every partition before rejecting → catastrophic backtracking.Measured (
node,RegExp.test, input'1'.repeat(N)+'!'):~4× per +2 chars = 2^N. At ~40 chars this is minutes-to-hours.
Unmitigated:
form.field.TextdeclaresmaxLength_: null(src/form/field/Text.mjs:216), so aPhonefield imposes no input-length bound by default. Neo runs field validation in the App Worker (which owns all UI logic), so a pathological value freezes the UI thread — a real client-side DoS, not merely theoretical.The Architectural Reality
src/form/field/Phone.mjs:29—inputPatternis a@reactiveconfig whose default is the vulnerableRegExp.Phone extends form.field.Text; the pattern is applied byText's validation. The fix is purely the default pattern value; no validation-logic change.\(?/\)?,/treated as a digit via[/0-9]). The fix must preserve that language exactly — widening/narrowing it is an out-of-scope behavior change.The Fix
Replace the default with a linear, language-preserving pattern:
inputPattern: /^\+?\(?[0-9][/0-9]*(?:\)[/0-9]*)?(?:[\-\s\.][/0-9]+)*$/Mechanism: a single unambiguous leading run
[0-9][/0-9]*(since[0-9] ⊂ [/0-9]), a close-paren branch anchored on the literal), and repeated groups each anchored by a required separator[\-\s\.]— so digit runs can no longer be re-partitioned across iterations.Validated (script preserved; will ship as the regression test):
+49 30 1234567,030/12345678,(123)456-789,12--34,-12,12/, …).Add a spec asserting (a) language-equivalence over the battery and (b) a bounded time / immediate-return on the pathological input (red against the current pattern, green against the fix).
Contract Ledger
Neo.form.field.Phone#inputPattern(default value)@memberJSDoc default string atPhone.mjs:29Avoided Traps
…[0-9]+\)?[/0-9]*(?:[\-\s\.][/0-9]+)*…(append[/0-9]*) kills the exponential but leaves two adjacent digit-matching quantifiers ([0-9]+and[/0-9]*) → O(N²): 8766 ms at N=100000. It closesjs/redosbut a large input still hangs andjs/polynomial-redoscould re-flag it. Rejected in favor of the single-leading-run form. Do not "simplify" back to it.Decision Record impact
none— no ADR governs field validation patterns.Out of Scope
form.field.Textvalidation logic or a defaultmaxLength(defense-in-depth worth considering, but a separate concern).Phone.mjsReDoS.Related
js/insecure-randomnessinapps/devindex/services/Spider.mjsare FALSE POSITIVES —Math.random()selects a crawl target (stargazer/following walk); no security boundary, no token/nonce. Dismiss candidates, not fixes.js/incomplete-sanitization, webpack configjs/incomplete-multi-character-sanitization) need per-alert assessment.#15353/#15355); the individual alert fixes are unowned.Origin Session ID
3f892890-5ce2-4045-8290-dbbdff1b987aHandoff Retrieval Hints
Retrieval Hint: "Phone field inputPattern ReDoS exponential backtracking linear regex fix"Live latest-open sweep: checked latest 15 open at 2026-07-17T15:45Z (latest
#15365); nearest neighbours#15353(CodeQL coverage),#15364(namespace collision) — none equivalent. A2A in-flight claim sweep (last 30, ~45 min window): no[lane-claim]/[lane-intent]overlapping form-field / ReDoS / CodeQL-alert-fix scope.