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

Fix ReDoS in Python and Flask

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 on CPython's re: a 30-character string runs for tens of seconds and, while it holds the GIL, freezes the whole process. Rewrite the pattern to drop the nested quantifier so matching is linear, cap the input length, and keep untrusted input off any backtracking regex.

On this page

Steps

  1. Find every regex applied to request data (email, URL and slug validators). 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. Rewrite the pattern so each character is consumed once: drop the outer '+' so the local part is a single [a-zA-Z0-9._%+-]+ rather than ([a-zA-Z0-9._%+-]+)+. The accepted set for real addresses is unchanged; the ambiguity that drives backtracking is gone.
  3. Cap the input length before matching (254 for an email). CPython's re has no match timeout, so a length bound is the simplest guarantee that any residual cost stays constant.
  4. Keep untrusted input off a backtracking match on the request worker. If you cannot make a pattern linear, run it in a separate process with a deadline - a thread will not help, because re holds the GIL for the whole match and no other Python code (or signal handler) can run until it returns.
  5. Prefer email-validator, urllib.parse or a plain str method over a hand-rolled regex where you can.

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.pyPython
EMAIL = re.compile(r"^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")


def is_valid_email(value: str) -> bool:
    return EMAIL.search(value) is not None

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

The fix: linear pattern + length cap

Fixedimpl/fixed.pyPython
MAX_LENGTH = 254  # RFC 5321 caps an email address at 254 characters
EMAIL = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")


def is_valid_email(value: str) -> bool:
    if len(value) > MAX_LENGTH:
        return False
    return EMAIL.search(value) is not None

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
MAX_LENGTH = 100_000  # "nobody sends a 100 KB email" - but ~30 characters is plenty
EMAIL = re.compile(r"^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")


def is_valid_email(value: str) -> bool:
    if len(value) > MAX_LENGTH:
        return False
    return EMAIL.search(value) is not None

With IMPL=wrong, 2 of 9 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.

Testtests/test_redos.pyPython
# Each payload is a short, benign string - letters only, no "@" - that a linear validator
# rejects in well under a millisecond but a backtracking one grinds on for tens of seconds.
# The validator runs in a child process with a deadline; the attack is defeated only if it
# returns a verdict within the budget. A hang means the deadline fired - the denial of
# service.
@pytest.mark.parametrize("payload", INPUTS["evil"])
def test_exploit_stays_within_time_budget(payload):
    outcome = validate_within(IMPL, payload, BUDGET_SECONDS)
    assert outcome["completed"], (
        f"validation did not finish within {BUDGET_SECONDS}s - catastrophic backtracking (ReDoS)"
    )
    assert outcome["result"] is False, "the crafted input is not a valid email"

Pitfalls

  • CPython's re backtracks and has no timeout. A nested quantifier such as ([a-z]+)+ is exponential on input that matches the inner part but fails overall, so a few dozen characters is enough.
  • 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.
  • A thread-based timeout (concurrent.futures, threading.Timer, signal.alarm) does not interrupt a running match: re holds the GIL and never reaches a bytecode boundary, so the timer thread or signal handler cannot run until the match finishes. Use a process.
  • The third-party regex module accepts a timeout= argument and google-re2 gives a linear engine; either is a valid alternative, but the simplest fix for a known validator is to rewrite the pattern.
  • Static analysers mostly flag dynamically built patterns, not a vulnerable literal. Use regexploit or CodeQL py/redos, which analyse the pattern itself.
  • Backreferences and lookaround make a pattern non-regular and are a frequent source of blow-up; prefer a plain, anchored, single-pass pattern.

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 python-flask does this for you).
  2. Each exploit runs a crafted ~40-character input in a child process 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 regexploit (or CodeQL py/redos) and expect the nested quantifier to be reported; confirm the rewritten pattern 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 Python and Flask
ToolRuleWhat it found here
Semgrepn/a0 findings. No Semgrep CE rule analyses a literal Python regex for catastrophic backtracking; its regex-related Python rules target dynamically built patterns (re.compile of a variable), not a vulnerable constant. The control is the regression suite plus a dedicated ReDoS analyser (regexploit / CodeQL py/redos).Checked: Semgrep 1.180.0 (CE); semgrep scan --metrics=off --config p/security-audit python-flask/impl (Ran 79 rules on 8 files)
CodeQLpy/redosExpected to flag vulnerable.py and wrong.py. py/redos ("Inefficient regular expression") statically detects a sub-expression with super-linear/exponential ambiguity; the nested quantifier ([a-zA-Z0-9._%+-]+)+ is that shape. fixed.py's single-quantifier pattern is not ambiguous. A companion query py/polynomial-redos needs a dataflow path from an untrusted source (request.form) into the match.Checked: id/name/path verified in github/codeql main via GitHub API (@id py/redos, @name 'Inefficient regular expression'); query not executed
regexploitregexploit (Doyensec)ReDoS finder for Python regexesReports the vulnerable/wrong pattern as 'Worst-case complexity: 11 (exponential)' with repeated character class [%+-._0-9A-Za-z] and example '0' * 3456. It prints nothing for the rewritten fixed.py pattern (linear). regexploit is the recommended lint for Python validation regexes.Checked: regexploit 1.0.0; patterns piped on stdin
Banditn/aNo ReDoS pluginBandit ships no CWE-1333 / ReDoS plugin (its plugin index has no catastrophic-backtracking check), so it does not evaluate this weakness. Listed for honesty; the enforcement evidence is the three-mode regression suite.Checked: Bandit plugin list reviewed (not run on these files)

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
werkzeug3.1.9
re, multiprocessing (standard library)Python 3.14.8
pytest9.1.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/python-flaskfolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

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

Download the project ZIP, 18 KB

Verified

Machine
macOS 27.0, Apple M5 Max
Toolchain
  • python 3.14.8
  • flask 3.1.3
  • pytest 9.1.1
Matrix
fixed: 9 of 9 tests pass; vulnerable: 2 of 9 tests fail (2 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
Test results
fixed: 9/9 pass; vulnerable: 2 fail (2 exploit); wrong: 2 fail (2 exploit)
Notes
The crafted inputs are benign strings of 'a' with no '@'; on CPython's re the vulnerable pattern needs only ~30 characters to exceed the 1s budget by orders of magnitude. The exploit runs in a spawned process because re holds the GIL during a match, so a thread or signal cannot bound it. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

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. Python re documentation (backtracking engine, no timeout)
  5. regexploit: find ReDoS in regular expressions
  6. CodeQL: Inefficient regular expression (py/redos)
  7. CWE-1333 mitigation: limit input length (CWE entry, Implementation)

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.