Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix ReDoS in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

A validation regex with a nested quantifier backtracks catastrophically: a 40-character string pins a CPU for minutes and, on Node's single event loop, blocks every request. Match on RE2, a finite-automaton engine whose run time is linear for any pattern; cap input length before matching; never run untrusted input through a backtracking regex on the request thread.

On this page

Steps

  1. Find every regex applied to request data (email, URL and slug validators, and library helpers). Flag nested quantifiers ((X+)+, (X*)*), overlapping alternations ((a|aa)+) and any such group followed by a pattern that can fail - that is where catastrophic backtracking lives.
  2. Replace the engine, not just the pattern: compile the expression with RE2 (new RE2(...)), a finite-automaton engine whose match time is linear in the input for every pattern. The pattern that is catastrophic on RegExp is safe on RE2.
  3. Cap the input length before matching (254 for an email); a bound turns any residual non-linear cost into a constant and is cheap defence in depth.
  4. If you must keep the built-in RegExp, run untrusted input in a worker thread with a deadline so one request cannot freeze the event loop; a timeout at the HTTP layer cannot help because the timer callback cannot run until the synchronous match returns.
  5. Prefer a real parser or a plain non-regex check where you can (the URL constructor, indexOf, split).

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
const EMAIL = /^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

export function isValidEmail(input) {
  return EMAIL.test(input);
}

With IMPL=vulnerable, 2 of 4 tests fail (2 exploit tests).

The fix: linear matching on RE2

Fixedimpl/fixed.jsJavaScript
import RE2 from 're2';

const MAX_LENGTH = 254; // RFC 5321 caps an email address at 254 characters
const EMAIL = new RE2('^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$');

export function isValidEmail(input) {
  if (input.length > MAX_LENGTH) return false;
  return EMAIL.test(input);
}

With IMPL=fixed, 4 of 4 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
const MAX_LENGTH = 100000; // "nobody sends a 100 KB email" - but ~40 characters is plenty
const EMAIL = /^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

export function isValidEmail(input) {
  if (input.length > MAX_LENGTH) return false;
  return EMAIL.test(input);
}

With IMPL=wrong, 2 of 4 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/redos.test.jsJavaScript
// Each payload is a short, benign string - letters and one terminator, with no "@" - that a
// linear validator rejects in well under a millisecond but a backtracking one grinds on for
// minutes. The validator runs in a worker with a deadline; the attack is defeated only if it
// returns a verdict well within the budget. A hang means the deadline fired, which is the
// denial of service.
describe(`exploit (IMPL=${implName})`, () => {
  for (const [i, payload] of inputs.evil.entries()) {
    test(`exploit: crafted input ${i + 1} (${payload.length} chars) stays within the time budget`, async () => {
      const outcome = await validateWithin(implName, payload, BUDGET_MS);
      assert.ok(outcome.completed, `validation did not finish within ${BUDGET_MS}ms - catastrophic backtracking (ReDoS)`);
      assert.equal(outcome.result, false, 'the crafted input is not a valid email');
    });
  }
});

Pitfalls

  • V8's regex engine backtracks. A nested quantifier such as ([a-z]+)+ is exponential on an input that matches the inner part but fails overall, so a few dozen characters is enough - you do not need a long string.
  • A maximum length only helps when it is below the size the pattern chokes on. Backtracking blows up at tens of characters, so a generous cap (1000, 100000) does nothing; that is the 'wrong' fix here.
  • The match blocks the one event loop, so an HTTP request timeout or Promise.race does not stop it: the timer callback is queued behind the synchronous match and cannot run until it returns.
  • re2 is a native addon whose install script fetches or builds the binary. npm 11 gates dependency lifecycle scripts, so allowlist exactly it (allow-scripts[]=re2) and commit package-lock.json; offline test runs do not reinstall.
  • RE2 rejects backreferences and lookaround. That is a feature: those features make a pattern non-regular and are a common source of exponential blowup, so being forced to remove them steers you to a linear design.
  • Static analysers mostly flag dynamically built regexes (Semgrep's detect-non-literal-regexp), not a vulnerable literal. Use a dedicated ReDoS analyser (recheck) or CodeQL js/redos, which inspect the pattern itself.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs node-express does this for you).
  2. Each exploit runs a crafted ~40-character input in a worker thread with a 1s deadline and asserts the validator returns within budget; the vulnerable and wrong validators blow the deadline, which is the denial of service.
  3. Scan the pattern with recheck (or CodeQL js/redos) and expect the nested quantifier to be reported as exponential; confirm RE2 compiles the identical pattern but matches a 1000-character crafted input in well under a millisecond.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix ReDoS in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp0 findings on vulnerable.js, fixed.js and wrong.js. The rule is tagged CWE-1333 but only flags a RegExp built from a non-literal (dynamic) argument; it does not analyse a literal pattern for catastrophic backtracking. All three implementations use a literal regex, so Semgrep CE adds nothing. The control is the regression suite plus a dedicated ReDoS analyser (recheck / CodeQL js/redos).Checked: Semgrep 1.180.0 (CE); semgrep scan --metrics=off --config r/javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp node-express/impl (Ran 1 rule on 3 files)
CodeQLjs/redosExpected to flag vulnerable.js and wrong.js. js/redos ("Inefficient regular expression") statically analyses the pattern for a sub-expression with exponential or super-linear ambiguity; the nested quantifier ([a-zA-Z0-9._%+-]+)+ is exactly that shape. fixed.js builds the same pattern with RE2, which the query does not treat as a backtracking sink. A companion query js/polynomial-redos needs a dataflow path from uncontrolled input.Checked: id/name/path verified in github/codeql main via GitHub API (@id js/redos, @name 'Inefficient regular expression'); query not executed
recheckrecheck (makenowjust-labo)Static + fuzzing ReDoS checkerstatus=vulnerable, complexity=exponential, attack string of the shape '%' + 'A'.repeat(31) + 'AA' for the nested-quantifier pattern. This is the positive control that catches the literal pattern, so it flags vulnerable.js and wrong.js. Caveat: recheck analyses the pattern, not the engine - the fixed validator runs the SAME pattern on RE2 (linear, safe), which recheck cannot see, so pattern-level linting must be read together with the engine and the regression tests.Checked: recheck 4.5.0 (npm); checkSync(pattern, "")
SonarQubeS5852Regular expressions should not cause catastrophic backtrackingS5852 is Sonar's JavaScript rule for regular expressions that can backtrack catastrophically, classed as a vulnerability. Listed for completeness; not run against this card.Checked: Rule listed in SonarSource's public SonarQube instance; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
re21.26.1
node:worker_threads, node:test (built in)Node.js 26.11.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the regex-dos/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs regex-dos/node-express runs all three modes and checks the expected results.

Download the project ZIP, 28 KB

Verified

Machine
macOS 27.0, Apple M5 Max
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • express 5.3.0
  • re2 1.26.1
Matrix
fixed: 4 of 4 tests pass; vulnerable: 2 of 4 tests fail (2 exploit tests); wrong: 2 of 4 tests fail (2 exploit tests)
Test results
fixed: 4/4 pass; vulnerable: 2 fail (2 exploit); wrong: 2 fail (2 exploit)
Notes
re2 1.26.1 (published 2026-07-21, >14 days) installs on Node 26.11.1 via its prebuilt native binary; npm 11 prints an allow-scripts warning unless the package is allowlisted in .npmrc. The crafted inputs are benign strings of 'a' with no '@'; on V8 the vulnerable pattern needs ~40 characters to exceed the 1s budget by orders of magnitude. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP: Regular expression Denial of Service - ReDoS
  2. CWE-1333: Inefficient Regular Expression Complexity
  3. CAPEC-492: Regular Expression Exponential Blowup
  4. node-re2 (RE2 bindings for Node.js)
  5. Google RE2 syntax and linear-time guarantee
  6. Node.js v26 API: worker_threads
  7. CodeQL: Inefficient regular expression (js/redos)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.