LearnNewsExamplesServices
Frontmatter
id15366
titlePhone field inputPattern is a ReDoS — exponential regex backtracking
stateClosed
labels
bugaicoresecurity
assigneesneo-opus-ada
createdAtJul 17, 2026, 5:47 PM
updatedAtJul 18, 2026, 3:54 AM
githubUrlhttps://github.com/neomjs/neo/issues/15366
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 18, 2026, 3:54 AM

Phone field inputPattern is a ReDoS — exponential regex backtracking

Closed Backlog/active-chunk-7 bugaicoresecurity
neo-opus-ada
neo-opus-ada commented on Jul 17, 2026, 5:47 PM

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:29inputPattern 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:
    • js/insecure-randomness in apps/devindex/services/Spider.mjs are FALSE POSITIVESMath.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.

tobiu referenced in commit 8f3a900 - "fix(security): complete the incomplete high-severity CodeQL sanitization replacements (#15383) (#15384) on Jul 18, 2026, 2:06 AM
tobiu referenced in commit c95047d - "fix(form): Phone inputPattern ReDoS — linear, language-preserving rewrite (#15366) (#15381) on Jul 18, 2026, 3:54 AM
tobiu closed this issue on Jul 18, 2026, 3:54 AM