Regular Expressions Module 11 – Performance, ReDoS and security
Regex with untrusted patterns and input
Design a search feature safe against user patterns and user input: linear engines, deadlines, match limits, input caps, escaping and patched engines.
What you will learn
- Design a search feature that accepts user patterns without risking a slow-regex denial of service
- Recognise when a user pattern is code you must not run on a backtracking engine
- Choose between a linear engine, a hard deadline, engine limits and an input cap for a feature
- Protect a feature by escaping user text that is not a pattern and by keeping the engine patched
Before you start
What you’ll learn · Before you start
What you will learn
- Design a search feature that accepts user patterns without risking a slow-regex denial of service
- Recognise when a user pattern is code you must not run on a backtracking engine
- Choose between a linear engine, a hard deadline, engine limits and an input cap for a feature
- Protect a feature by escaping user text that is not a pattern and by keeping the engine patched
Before you start
On this page
A search box that compiles whatever a visitor types is running their code on your server. Give your own box, backed
by Node’s default engine, the pattern ^(a+)+$ and a line of 34 letters a followed by a !, which it cannot match,
and the match does not finish: it is still running after three seconds, on the one thread that serves every other
request.
Untrusted regex reaches your code two ways: a user supplies the untrusted pattern: A regular expression that comes from outside your team, such as one a visitor types into a search or filter box. It must be treated as code, because the wrong pattern can make a backtracking engine run for a very long time., or a user supplies the input your own pattern runs on. This lesson designs one search feature that is safe against both. You will treat a user pattern as code, choose between a linear engine, a hard deadline, engine limits and an input cap, escape text that was never meant to be a pattern, and keep the engine patched so its own bugs do not become yours.
Two ways untrusted regex reaches your code
The first question to ask of any regex that touches the outside world is which part came from outside. There are only two answers, and they need different defences. Either the pattern is untrusted, as in a search box, a filter builder or a product that lets customers write their own alert rules; or the pattern is yours and the text is untrusted, as in a form field, a request header, an uploaded file or a log line. The table names each case and the defence that fits it:
| Where the regex comes from | What is untrusted | The defence that fits |
|---|---|---|
| A user types the pattern (search, filters, alert rules) | the pattern | a linear engine, or a deadline |
| A user supplies the input your pattern runs on (forms, headers, files) | the text | an input cap, a safe pattern, escaping |
Both cases share one failure: a slow match that blocks the thread, a denial of service from a regex, which catastrophic backtracking and ReDoS covers in full. The defences below each remove or cap that risk in one of the two cases, and a real feature combines several.
Checkpoint
Check: The two threat cases
A user’s pattern is code you agreed to run
When a visitor types a pattern, you are not reading data; you are accepting a tiny program and promising to run it.
Most such programs are harmless, but you cannot assume so, because one pattern is enough. The program below hands a
pattern a visitor might type, ^(a+)+$, to Node’s default engine on a line of 34 letters a and a final !.
A linear engine would answer false at once; the default engine does not answer at all before the session stops it:
"""A user types the pattern ^(a+)+$ into a search box, and someone later searches a
line of 34 letters a followed by one that cannot match. On Node's default engine
the match keeps going; this session stops the program after three seconds and says
so, instead of printing a time that depends on the computer."""
from v8_session import run
run([], r'''
const userPattern = /^(a+)+$/; // a pattern a visitor typed
const line = "a".repeat(34) + "!"; // a line it cannot match
console.log(userPattern.test(line)); // never reaches here in time
''', limit=3) Output
$ node probe.mjs (stopped after 3 s: the program was still running)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cost.py
The output is the whole point: the match is still running when the session ends it, because on a backtracking: How a backtracking engine reacts to a failure: it returns to its most recent choice point, restores the text position it had there, and tries the next alternative. More in “How an engine finds a match, step by step” engine this shape tries a number of ways to split the run that grows explosively with the line length. Catastrophic backtracking and ReDoS names the four shapes that do this; here it is enough to know that a user can type one, so a user pattern needs a bound on how long it may run before you let it near a request thread.
Key idea
Running a pattern a user typed is running untrusted code: on a backtracking engine a small pattern can block the thread for a very long time, so a user pattern needs a time bound, not just a successful compile.
Checkpoint
Check: A user pattern is code
A linear engine takes the risk away
The cleanest fix for an untrusted pattern is an engine that no pattern can push past linear time. A
RE2: A regular-expression library and syntax from Google whose primary guarantees include match time linear in the input, which is why it leaves out backreferences and lookaround. Go's regexp package accepts its syntax. More in “Regex in Go, RE2 and re2js”-style engine follows every path through the pattern at once, so its match time is linear in
the length of the text whatever the pattern does. Go’s regexp package is one, and it checks every pattern when it
compiles it. The program below
accepts a few filters a visitor might save, refuses the two that need features RE2 does not have, and then runs the
nested quantifier ^(a+)+$ on a line of 100,000 letters a and a final !, which it cannot match:
// A search service lets visitors save their own filter patterns. Go's regexp
// accepts only RE2 syntax, so a pattern that would need backtracking is refused
// when the filter is saved, not while a request runs. The refusal is the guard.
package main
import (
"fmt"
"regexp"
"strings"
)
func saveFilter(p string) {
if _, err := regexp.Compile(p); err != nil {
fmt.Printf("refused %-14s %v\n", p, err)
return
}
fmt.Printf("accepted %-14s compiles\n", p)
}
func main() {
// Filters a visitor might type. Two of them need features RE2 does not have.
for _, p := range []string{
`error|warn`, // plain alternation
`\b\d{3,5}\b`, // a bounded number
`(\w+) \1`, // a backreference
`(?=.*fail)`, // a lookahead
`^(a+)+$`, // a nested quantifier
} {
saveFilter(p)
}
// A saved filter run on a long line that cannot match. RE2 answers within a
// bound it fixes before it starts, so no input can make this search slow.
re := regexp.MustCompile(`^(a+)+$`)
line := strings.Repeat("a", 100000) + "!"
fmt.Printf("match on %d characters: %v\n", len(line), re.MatchString(line))
} Output
accepted error|warn compiles
accepted \b\d{3,5}\b compiles
refused (\w+) \1 error parsing regexp: invalid escape sequence: `\1`
refused (?=.*fail) error parsing regexp: invalid or unsupported Perl syntax: `(?=`
accepted ^(a+)+$ compiles
match on 100001 characters: false
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run filters.go
Two filters are refused when they are saved, not while a request runs: the backreference (\w+) \1 with
invalid escape sequence, and the lookahead (?=.*fail) with invalid or unsupported Perl syntax. The nested
quantifier ^(a+)+$ compiles and answers false on 100,001 characters, because RE2 runs even that shape in
linear-time matching: A guarantee that, for a fixed pattern, the time a match takes grows at most in proportion to the length of the text, whatever the text holds. Go's regexp gives it by following every path of the pattern at once. More in “Regex in Go, RE2 and re2js”. The cost is the two refusals: a
non-backtracking engine: A regex engine that matches without backtracking, so its run time stays proportional to the length of the input. .NET's RegexOptions.NonBacktracking is one; in exchange it refuses lookaround, backreferences, atomic and balancing groups, conditionals and the start anchor \G. More in “Regex in Java, .NET, Ruby and Rust” refuses a backreference: A pattern element that names a previously captured group and matches exactly the text that group holds at that moment, written \1 for the first group, \k<name> or (?P=name) for a named one depending on the flavour. More in “Backreferences: matching the same text again”,
and RE2 refuses a lookaround: A family of zero-width assertions that test the text to one side of the current position without consuming it: lookahead looks forward and lookbehind looks back. A pattern can require or forbid what surrounds a match this way. More in “Lookahead: conditions without consuming text” as well, so a feature that needs one cannot use this engine.
Backtracking engines versus automata engines explains why and covers re2js,
a JavaScript port of RE2, and Regex in Java, .NET, Ruby and Rust
covers Rust’s regex crate and .NET’s NonBacktracking option.
Read each platform’s own terms before you hand it visitor patterns, though. A linear engine’s time still grows with
the size of the pattern as well as the text, so Rust’s documentation advises keeping patterns from outside short and
setting a small limit on their compiled size. Microsoft says .NET’s NonBacktracking option and its match timeouts
protect patterns your team wrote from expensive input and are no security boundary against hostile patterns; for
searches typed by users it suggests a restricted search syntax or Regex.Escape instead of compiling what they typed.
V8 has an experimental linear engine too, reached through Node’s flags. The run below turns it on two ways: a fallback
after too much backtracking, and the l flag, which runs a pattern on the linear engine from the start and refuses one
it cannot run there:
"""V8 has an experimental linear-time engine, reached two ways through Node's V8
flags. These flags are experimental: the behaviour here is Node 24.21.0's, not a
promise about every release, so test it before you rely on it.
The first flag moves a match that backtracks too often onto the linear engine. The
second turns on the l flag, which runs a pattern on the linear engine from the
start and refuses one that cannot run there, such as a backreference."""
from v8_session import run
# Fall back to the linear engine after too many backtracks.
run(["--enable-experimental-regexp-engine-on-excessive-backtracks"], r'''
const line = "a".repeat(34) + "!";
console.log(/^(a+)+$/.test(line));
''', limit=3)
# The l flag: linear from the start, and a refusal for a backreference.
run(["--enable-experimental-regexp-engine"], r'''
const line = "a".repeat(34) + "!";
console.log(/^(a+)+$/l.test(line));
try {
new RegExp("^(a+)\\1$", "l");
} catch (e) {
console.log(e.name + ": " + e.message);
}
''', limit=3) Output
$ node --enable-experimental-regexp-engine-on-excessive-backtracks probe.mjs false $ node --enable-experimental-regexp-engine probe.mjs false SyntaxError: Invalid regular expression: /^(a+)\1$/l: Cannot be executed in linear time
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 linear.py
The fallback flag answers false at once on the same shape that hung a moment ago, and the l flag does too; a
backreference under l is refused with Cannot be executed in linear time. These are experimental flags, so test
them on your own patterns and your own Node version before you rely on them rather than treating them as settled.
Key idea
A linear-time engine ends the explosive case of a slow user pattern, because its time stays linear in the text whatever the pattern and grows only with the pattern’s own size, at the price of the features it drops: backreferences always, and lookaround in RE2, Go, Rust and .NET.
Checkpoint
Check: A linear-time engine
A hard deadline when the engine still backtracks
Sometimes you cannot change the engine: the feature needs lookaround, or the platform has no linear option. Then the next defence is a hard deadline. Run the match somewhere you can stop, and stop it when it passes a time limit, so a slow match cannot block the thread that serves other requests. In Node that place is a worker thread. The program runs a user pattern in a worker and terminates the worker at a deadline; it prints the outcome, never a measured time, because a real runaway would take far longer than the limit:
// A hard deadline when the engine can still backtrack: run the visitor's pattern
// in a worker thread, and stop the worker when it runs past its deadline. The main
// thread stays free to answer every other request. The outcome is printed, never a
// measured time, because a real runaway match would take far longer than this.
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
if (!isMainThread) {
// In the worker: compile and run the visitor's pattern. On a line it cannot
// match this call does not return before the deadline.
const re = new RegExp(workerData.pattern);
parentPort.postMessage(re.test(workerData.line));
} else {
const DEADLINE_MS = 200;
const pattern = '^(a+)+$'; // a pattern a visitor typed
const line = 'a'.repeat(42) + '!'; // a line it cannot match
const worker = new Worker(new URL(import.meta.url), { workerData: { pattern, line } });
const timer = setTimeout(() => {
worker.terminate();
console.log(`stopped after ${DEADLINE_MS} ms: the search ran past its deadline`);
}, DEADLINE_MS);
worker.on('message', (answer) => {
clearTimeout(timer);
console.log(`answered in time: ${answer}`);
});
} Output
stopped after 200 ms: the search ran past its deadline
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node worker.mjs
The worker is stopped and the main thread stays available. The MySmartCoPilot regex tester works the same way: it runs the visitor’s pattern in a Web Worker and stops it after 1.5 seconds, with a message that the pattern is too slow on that text.
Regex Tester Its match runs in a Web Worker on your browser's own engine and stops after 1.5 seconds, a worked example of a deadline.Several engines offer a deadline without a worker. The third-party Python regex module takes a timeout= in
seconds, which turns a runaway match into an error you can catch:
"""The third-party regex module for Python takes a timeout= in seconds: a match that
runs longer raises TimeoutError, which you can catch and turn into a clear reply. A
timeout fires only for the shapes that run long, and only when you set it, so it is
a backstop, not a reason to skip the other defences."""
from regex_session import regex
line = "a" * 34 + "!" # a line neither pattern can match
# One shape runs long enough for the cap to fire.
try:
print("(a|aa)+ :", regex.match(r"^(a|aa)+$", line, timeout=1.0))
except TimeoutError as error:
print("(a|aa)+ : TimeoutError:", error)
# Another answers at once, well inside the same cap.
print("(a+)+ :", regex.match(r"^(a+)+$", line, timeout=1.0))
print("regex", regex.__version__) Output
(a|aa)+ : TimeoutError: regex timed out (a+)+ : None regex 2026.9.29
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 timeout.py
One shape raises TimeoutError; the other answers before the limit, so the cap never fires on it. That is the shape of
a match timeout: A limit on how long one matching call may run, after which the engine gives up with an error instead of an answer. Python's re has none; the third-party regex module takes timeout= in seconds. More in “Python regex techniques and the regex module” everywhere: .NET takes one on the constructor, and Microsoft lists it
among the defences for untrusted input; Ruby has Regexp.timeout, which is nil by default. But a timeout
only helps when it is set, and it fires only after the time is spent, so never promise that one will catch every slow
pattern: it is a backstop, not a guarantee.
Key idea
A deadline worker: A separate thread or process that runs a match and is stopped when it passes a fixed time limit, so a slow or runaway match cannot block the thread that serves other requests. or a match timeout caps the damage rather than preventing it: the engine still spends the time up to the limit, so pair a deadline with a length cap and a safe pattern where you can.
Checkpoint
Check: A hard deadline
Engine limits that count the work, and a cap on the input
A deadline measures time; an engine limit counts steps, which is deterministic and does not depend on the machine’s
load. PCRE2 caps the steps of its matching loop at a match limit: A cap on the steps of the matching loop of PCRE2 that turns a runaway match into an error instead of an endless run. pcre2test reports error -47 when it is reached, and PHP returns false. More in “Regex in PCRE2, PHP and Perl”, and the verb
(*LIMIT_MATCH=n) at the start of a pattern lowers it. The run below first reports how many steps a worst-case line
needs as the line grows, then shows a low cap turning a worst-case line into an error instead of a runaway:
"""PCRE2 counts the steps of its matching loop and stops at a limit instead of running
away. The subject modifier find_limits_noheap makes pcre2test report the smallest match
and depth limits a subject needs, and the verb (*LIMIT_MATCH=n) at the start of a pattern
lowers the cap, so a worst-case line fails as an error. Subjects are kept short on purpose:
\\[a]{8} stands for eight letters a, and the count climbs fast with the length of the line."""
from pcre2_session import run
run(r'''
# The match-loop steps a worst-case line needs, as the line grows.
/^(a+)+$/
\[a]{8}!\=find_limits_noheap
\[a]{12}!\=find_limits_noheap
\[a]{16}!\=find_limits_noheap
# The same pattern with a low cap: a worst-case line ends as an error, not a runaway.
/(*LIMIT_MATCH=1000)^(a+)+$/
\[a]{20}!
''') Output
$ pcre2test -q patterns.txt (PCRE2 10.49)
# The match-loop steps a worst-case line needs, as the line grows.
/^(a+)+$/
\[a]{8}!\=find_limits_noheap
Minimum match limit = 640
Minimum depth limit = 19
No match
\[a]{12}!\=find_limits_noheap
Minimum match limit = 10240
Minimum depth limit = 27
No match
\[a]{16}!\=find_limits_noheap
Minimum match limit = 163840
Minimum depth limit = 35
No match
# The same pattern with a low cap: a worst-case line ends as an error, not a runaway.
/(*LIMIT_MATCH=1000)^(a+)+$/
\[a]{20}!
Failed: error -47: match limit exceeded
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 limits.py
The minimum match limit climbs from 640 at eight letters to 10,240 at twelve and 163,840 at sixteen, sixteenfold for
every four more letters, which is why a short worst-case line already shows the risk. With (*LIMIT_MATCH=1000) a line
of twenty letters fails with error -47: match limit exceeded, a clear error rather than a hung thread. PHP wraps the
same PCRE2 behind pcre.backtrack_limit, and reports it through a return value a caller must read carefully:
<?php
// PHP's PCRE caps backtracking at pcre.backtrack_limit. Past the cap, preg_match
// returns false (not 0), and preg_last_error_msg says why. A caller that reads only
// 0 or 1 cannot tell a non-match from a cap, so always check for false and the error.
ini_set('pcre.backtrack_limit', '1000');
$line = str_repeat('a', 20) . '!';
$result = preg_match('/^(a+)+$/', $line);
var_dump($result);
echo preg_last_error_msg(), "\n";
// A plain match on the same input still answers and reports no error.
$ok = preg_match('/^a+$/', str_repeat('a', 20));
var_dump($ok);
echo preg_last_error_msg(), "\n";
echo 'PCRE2 ', explode(' ', PCRE_VERSION)[0], "\n"; Output
bool(false) Backtrack limit exhausted int(1) No error PCRE2 10.44
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php limit.php
Past the limit, preg_match returns false, not 0, and preg_last_error_msg says Backtrack limit exhausted.
A caller that reads only 0 or 1 cannot tell a stopped match from a clean no-match, so always check for false and
the error. All of these cap the work; none of them caps the input. The cheapest guard of all is an
input length cap: A limit on the length of the text, or pattern, a feature will accept, checked before matching begins, which shrinks the worst case of every pattern. A short input can still be slow on a backtracking engine, so a cap works beside a safe pattern or a deadline. checked before matching: OWASP’s input validation guidance is to bound
the input’s length before matching, and to check on the server as well as in the browser, because a request can skip
the browser. A cap shrinks the worst case for every pattern, but it cannot make a slow pattern fast: twenty
letters were already enough to pass the 1,000-step cap above.
Checkpoint
Check: Engine limits and input caps
Escape text that is not a pattern, and keep engines patched
The second threat case, a user supplying text rather than a pattern, has its own defence. When a visitor types search
text, you do not want it read as a pattern at all: a dot should be a dot and an asterisk an asterisk.
text escaping: Turning a piece of text into a pattern that matches it literally, by escaping every character that would otherwise be special, so that user search text is never read as regex syntax. the text returns a pattern that matches it literally. RegExp.escape does
this in JavaScript, and the same text goes in both the browser and Node:
// A visitor types search text, not a pattern. RegExp.escape turns every character
// into one that matches itself, so the search finds that exact text and treats
// 1.5*(x) as six ordinary characters, not as a quantifier and a group.
const term = "1.5*(x)";
const escaped = RegExp.escape(term);
console.log(escaped);
const re = new RegExp(escaped);
console.log(re.test("y = 1.5*(x) + 2"));
console.log(re.test("y = 15xxx")); Output
\x31\.5\*\(x\) true false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node escape.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
import re
# A visitor types search text, not a pattern. re.escape makes every character match
# itself, so the search finds the exact text and never reads * or ( as syntax.
term = "1.5*(x) a-b"
escaped = re.escape(term)
print(escaped)
pattern = re.compile(escaped)
print(bool(pattern.search("value 1.5*(x) a-b here")))
print(bool(pattern.search("1 5 x a b"))) Output
1\.5\*\(x\)\ a\-b True False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 escape.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each turns the term into a pattern where every special character is escaped, so the search finds the exact text and a
crafted term cannot reshape the pattern or slow it down. RegExp.escape also writes the leading 1 as \x31: it
escapes a first digit or letter in hex, so that, joined after an escape such as \1 in a bigger pattern, the
character is not read as part of that escape. Other platforms have the same tool: Go’s
regexp.QuoteMeta, PHP’s preg_quote, Java’s Pattern.quote and .NET’s Regex.Escape.
A regex engine is itself software that can have bugs. The PCRE2 advisory GHSA-r9hj-j2rw-4q3m describes an out-of-bounds write in the engine’s JIT matching code, a memory-safety bug in versions 10.48 and earlier that is fixed in 10.49, and it notes that a service which runs only patterns it trusts is not exposed: one more reason to treat a user pattern as code. The response to any such advisory is to update to the fixed version and to find every bundled copy, because a language runtime often ships its own build of the engine. PHP bundles its own PCRE2, and the copy in PHP 8.5.11 here reports version 10.44, older than that fix and not the system’s.
Find every copy of the engine, not just the system one
When an advisory names a fixed version, the engine your language links is often a separate, older build. Check the
version your runtime reports (PHP’s PCRE_VERSION, Node’s process.versions) as well as the system package, and
update both. Describe the bug by its class and its fixed version; never work out how to trigger it.
Patching is the one defence on this list that no pattern, engine or limit can replace, because the flaw is in the code that runs every pattern. Treat the engine as a dependency with a version you track, and the two threat cases plus a patched engine cover the whole surface a regex exposes.
Key idea
Escape user text with RegExp.escape, re.escape, QuoteMeta, preg_quote or Pattern.quote so it is matched
literally, and keep every copy of the engine patched, because the engine’s own bugs become your service’s bugs.
Checkpoint
Check: Escaping user text, patched engines
Designing one safe search feature
No single defence fits every feature, so the design question is which to combine. The choice turns on three facts: who writes the pattern, whether it needs lookaround or a backreference, and whether your platform offers a linear engine. Answer them in the table, and it marks the defences that fit your case and the ones to drop:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Defence | Users type the pattern | Users type only text | Pattern needs lookaround |
|---|---|---|---|
| A linear-time engine What it gives you: a time guarantee | Good no pattern a visitor types can run worse than linear in the length of the line | Good linear on any text, though escaping is what makes the text itself safe | Poor RE2, Go, Rust and .NET refuse lookaround and backreferences, so such a pattern will not compile |
| A worker with a deadline What it gives you: a time limit | Mixed stops a runaway match, but only after the worker has spent the time | Mixed caps the cost; for plain text a length cap is cheaper and simpler | Good limits the time, not the pattern, so lookaround and backreferences still work |
| Escaping the search text What it gives you: literal text | Poor makes every character literal, so it cannot be a pattern feature at all | Good the one defence that lets a visitor type a search term safely | Good your own pattern runs, so it may use any feature it needs |
| A cap on the input length What it gives you: a smaller worst case | Mixed caps pattern and line, which bounds a linear engine; a backtracking one can still be slow on a short line | Good a short bound keeps even a backtracking match cheap on a text search | Good changes nothing about the pattern, so it fits every feature |
How the answers decide
- “Who writes the pattern your feature runs?” Only your team writes it: adds a caveat to A linear-time engine and A worker with a deadline. When your team owns the pattern, the risk is the input: escape the search text and cap its length, and the heavier nets become optional.
- “Who writes the pattern your feature runs?” A visitor types it: rules out Escaping the search text. Escaping makes every character literal, which removes the pattern feature a visitor came to use.
- “Who writes the pattern your feature runs?” A visitor types it and “Does the pattern need lookaround or a backreference?” Yes, it needs one: rules out A linear-time engine. A backreference compiles on no linear engine, and lookaround on none of RE2, Go, Rust or .NET, so for such a pattern a deadline and a cap carry the load.
- “Who writes the pattern your feature runs?” A visitor types it and “Does the pattern need lookaround or a backreference?” No, it stays plain and “Does your platform offer a linear-time engine you can use?” Yes, one is available: adds a caveat to A worker with a deadline. A plain visitor pattern on a linear engine cannot run away, so a deadline is only a backup; keep the length cap, since the time still grows with the size of the pattern.
- “Does your platform offer a linear-time engine you can use?” No, only a backtracking one: rules out A linear-time engine. No linear engine is on the platform, so every pattern runs on a backtracking one: cap the input, and put a deadline on any pattern a visitor writes.
A linear engine is ruled out the moment a user pattern needs a backreference or lookaround, or the platform has none; escaping is ruled out the moment the user means the text as a pattern; a deadline and an input cap fit almost anywhere, because they limit the cost rather than the features. With the defences chosen, they run in a fixed order on every request, cheapest guard first. Step through one safe search, from the request to a reply or a clear error:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A search arrives. A visitor sends a search term or a pattern. Every byte of it is untrusted, so the request meets a guard before any matching begins.
- Cap the length first. The cheapest guard runs first. A term or pattern longer than the limit is refused, and a line longer than the line cap is skipped, before the engine sees either.
- Too long ends here. An over-long input takes the short branch straight to the reply, an error that names the limit, so the engine never starts on it.
- Make the input safe. A search term is escaped so every character is literal; a pattern is compiled on a linear engine, which checks it before it runs on any line.
- A refused pattern ends here. A pattern that needs a feature the engine will not take, such as a backreference, is refused with a message that names the feature.
- Run under a deadline. The match runs in a worker with a hard stop, a backstop in case a match still runs long, so the main thread is never blocked past the deadline.
- Past the deadline ends here. A match still running at the deadline is stopped, and the reply is the same kind of clear error, not a hung request.
- Answer the search. A match that finished returns the lines it found, or none. Every path ends at the reply, with an answer or an error the visitor can act on.
The length cap runs first, because it is a plain length check and the cheapest way to reject a huge input before the engine does any work. Only inputs within the limits go on to be escaped (search text) or compiled on a linear engine (a pattern), and the match itself runs under a deadline, so every path ends in an answer or an error the visitor can act on.
Key idea
A safe feature stacks defences in order: cap the length, then escape text or compile a pattern on a linear engine, then run under a deadline, so an over-long or runaway input becomes a clear error and never a blocked thread.
Checkpoint
Check: Designing the feature
Practise
The exercise builds the feature this lesson designed: a safeSearch that a visitor can call in a text mode and a
pattern mode. It escapes search text, refuses a pattern that holds a backreference, a lookaround or a quantifier inside
a quantifier, caps the pattern and the line length, refuses a mode it does not know, and returns every error as a
value rather than throwing. The starter returns every line, so the first test fails at once until you add the guards:
Exercise · Medium · JavaScript, Python
A log search that accepts user input safely
A log viewer lets a visitor search lines two ways: a text search for an exact piece of text, and a pattern search that runs a regular expression they type. Both reach the server, so both are untrusted, and the pattern search can be a slow one on the wrong line.
Finish safeSearch(mode, query, lines) so that it is safe to call on anything a visitor sends:
- text mode: treat
queryas literal text, so1.5*(x)finds that exact string and the dot is just a dot. Escape it before you build the pattern. - pattern mode: refuse a
querythat holds a backreference (\1to\9), a lookaround ((?=,(?!,(?<=,(?<!), or a group repeated around another quantifier such as(a+)+; refuse one longer than the length cap; then compile it. A pattern that does not compile is an error you return, never one you throw. - both modes: skip any line longer than the line cap so a huge line cannot cost a match, and return matching lines.
- any other mode: refuse it like an unsafe query, so a mistyped mode never runs a search.
Return { ok: true, matches: [...] } when the search ran, or { ok: false, error: "..." } when the query or the mode was refused or invalid. The tests send safe and unsafe queries, an unknown mode and one very long line; none of them depends on timing. The starter returns every line, so the text-mode test fails at once until you escape the query and filter.
JavaScript · Starter code · search.mjs
// A log search a visitor can call in two modes. Finish it so that text mode treats
// the query as literal text, pattern mode refuses the unsafe shapes and caps the
// length, both modes skip over-long lines, and an invalid pattern is returned as an
// error rather than thrown.
export const MAX_PATTERN = 200; // the longest pattern a visitor may send
export const MAX_LINE = 2000; // the longest line that is worth matching
export function safeSearch(mode, query, lines) {
// TODO: in text mode, escape the query with RegExp.escape before you build it.
// TODO: in pattern mode, refuse backreferences, lookaround, a nested quantifier
// and a pattern over MAX_PATTERN, then compile with errors returned as values.
// TODO: in both modes, skip any line longer than MAX_LINE.
return { ok: true, matches: lines };
} The sample tests · search.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { safeSearch } from './search.mjs';
const LINES = ['error 500', 'warn 1.5*(x)', 'ok', '12,34 done'];
test('text mode treats the query as literal text', () => {
const r = safeSearch('text', '1.5*(x)', LINES);
assert.ok(r.ok);
assert.deepEqual(r.matches, ['warn 1.5*(x)']);
});
test('text mode reads a dot as a dot, not as any character', () => {
const r = safeSearch('text', 'error.500', LINES);
assert.ok(r.ok);
assert.deepEqual(r.matches, []);
});
test('pattern mode runs a safe pattern', () => {
const r = safeSearch('pattern', '\\d+,\\d+', LINES);
assert.ok(r.ok);
assert.deepEqual(r.matches, ['12,34 done']);
});
test('pattern mode refuses a backreference', () => {
assert.equal(safeSearch('pattern', '(\\w+) \\1', LINES).ok, false);
});
test('pattern mode refuses a lookahead', () => {
assert.equal(safeSearch('pattern', '(?=.*fail)', LINES).ok, false);
});
test('pattern mode refuses a quantifier inside a quantifier', () => {
assert.equal(safeSearch('pattern', '(a+)+$', LINES).ok, false);
});
test('a pattern over the length cap is refused', () => {
assert.equal(safeSearch('pattern', 'a'.repeat(300), LINES).ok, false);
});
test('an invalid pattern is an error value, not a thrown error', () => {
const r = safeSearch('pattern', '(', LINES);
assert.equal(r.ok, false);
assert.ok(typeof r.error === 'string');
});
test('an unknown mode is refused, never searched', () => {
const r = safeSearch('regex', 'error', LINES);
assert.equal(r.ok, false);
assert.ok(typeof r.error === 'string');
});
test('a line over the length cap never matches', () => {
const long = 'x'.repeat(5000) + ' error';
const r = safeSearch('text', 'error', [long, 'error here']);
assert.ok(r.ok);
assert.deepEqual(r.matches, ['error here']);
}); Python · Starter code · search.py
# A log search a visitor can call in two modes. Finish it so that text mode treats
# the query as literal text, pattern mode refuses the unsafe shapes and caps the
# length, both modes skip over-long lines, and an invalid pattern is returned as an
# error rather than raised.
MAX_PATTERN = 200 # the longest pattern a visitor may send
MAX_LINE = 2000 # the longest line that is worth matching
def safe_search(mode, query, lines):
# TODO: in text mode, escape the query with re.escape before you build it.
# TODO: in pattern mode, refuse backreferences, lookaround, a nested quantifier
# and a pattern over MAX_PATTERN, then compile with errors returned as values.
# TODO: in both modes, skip any line longer than MAX_LINE.
return {"ok": True, "matches": lines} The sample tests · test_search.py
"""Sample tests for safe_search: safe and unsafe queries, an unknown mode and one very long line. None depends on timing."""
from search import safe_search
LINES = ["error 500", "warn 1.5*(x)", "ok", "12,34 done"]
def test_text_mode_is_literal():
r = safe_search("text", "1.5*(x)", LINES)
assert r["ok"] is True
assert r["matches"] == ["warn 1.5*(x)"]
def test_text_mode_dot_is_a_dot():
r = safe_search("text", "error.500", LINES)
assert r["ok"] is True
assert r["matches"] == []
def test_pattern_mode_runs_safe_pattern():
r = safe_search("pattern", r"\d+,\d+", LINES)
assert r["ok"] is True
assert r["matches"] == ["12,34 done"]
def test_pattern_mode_refuses_backreference():
assert safe_search("pattern", r"(\w+) \1", LINES)["ok"] is False
def test_pattern_mode_refuses_lookahead():
assert safe_search("pattern", r"(?=.*fail)", LINES)["ok"] is False
def test_pattern_mode_refuses_nested_quantifier():
assert safe_search("pattern", r"(a+)+$", LINES)["ok"] is False
def test_pattern_over_cap_is_refused():
assert safe_search("pattern", "a" * 300, LINES)["ok"] is False
def test_invalid_pattern_is_an_error_value():
r = safe_search("pattern", "(", LINES)
assert r["ok"] is False
assert isinstance(r["error"], str)
def test_unknown_mode_is_refused():
r = safe_search("regex", "error", LINES)
assert r["ok"] is False
assert isinstance(r["error"], str)
def test_long_line_never_matches():
long_line = "x" * 5000 + " error"
r = safe_search("text", "error", [long_line, "error here"])
assert r["ok"] is True
assert r["matches"] == ["error here"] A hint
Start with the two caps, because they are the cheapest guards and they protect both modes. Keep only the lines that are at most MAX_LINE long, and in pattern mode return an error straight away when the query is longer than MAX_PATTERN.
For text mode, the whole job is one call: wrap query in RegExp.escape (JavaScript) or re.escape (Python) before you build the pattern, then keep the lines it matches. For pattern mode, test the raw query string for the three unsafe shapes with small patterns of your own (a backslash and a digit for a backreference; (? with =, !, <= or <! for a lookaround; a group that ends in a quantifier and is itself repeated for the nested one) before you ever compile it, and wrap the compile in a try so an invalid pattern becomes { ok: false, error }.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the self-check. It starts from the idea you have shown least and explains every choice as soon as you make it:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: name the two ways untrusted regex reaches a service, and the defence that fits each.
Untrusted regex is either a pattern a user supplies, as in a search box, a filter builder or customer-written alert rules, or input a user supplies to a pattern you wrote, such as a form field, a header or an uploaded file. For an untrusted pattern the defences are a linear-time engine, whose time stays linear in the text whatever the pattern, or a hard deadline on a worker or a match timeout when the engine must still backtrack. For untrusted input to your own pattern, cap the length before matching, keep the pattern clear of the slow shapes, and escape any user text that is meant as text rather than as a pattern. Both cases share one failure, a slow match that blocks the thread, so in both the goal is a bound on how long a match may run.
Two design questions take these defences into features this lesson did not build. Decide your first move on each, then open its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Senior to staff
A security-monitoring product runs customers' saved detection rules in a Node service on eight worker threads, stopping any match after 50 ms. One customer's rule hits the stop on 400 lines a second: every customer's alerts arrive minutes late, and the stopped lines raise no alert. What is wrong, and what do you change?
-
Mid to senior
A Python importer splits each row of a supplier's CSV with
,(?=(?:[^"]*"[^"]*")*[^"]*$)to find the commas outside quotes, then caps every cell at 256 characters. One supplier's 5 MB export arrives as a single line, and the import worker runs for many minutes. Why, and how do you make the importer safe?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the two ways untrusted regex reaches a service?
A user supplies the pattern, as in a search box, a filter builder or alert rules, or a user supplies the input your own pattern runs on, such as a form field, a header or an upload. Each needs its own defence.
-
Why is compiling a pattern a visitor typed like running their code?
On a backtracking engine a small pattern can block the thread for a very long time: Node's default engine was still running ^(a+)+$ on 34 a's and ! after three seconds. A user pattern needs a time bound, not just a successful compile.
-
What does a linear-time engine guarantee for user patterns, and what does it refuse?
Its time stays linear in the text whatever the pattern, which removes the explosive case. It always refuses backreferences, and lookaround too in RE2, Go, Rust and .NET's non-backtracking mode.
-
What does a deadline worker or a match timeout do, and what does it not do?
It caps the damage rather than preventing it: the engine still spends the time up to the limit. Pair the deadline with an input length cap and a safe pattern where you can.
-
Does an input length cap make a slow pattern cheap?
No. It shrinks the worst case for any pattern by bounding how much text a match can meet, but the pattern itself stays as slow as it was.
-
How do you match a user's text literally, and why keep the engine patched?
Escape it with RegExp.escape, re.escape, QuoteMeta, preg_quote or Pattern.quote. Keep every bundled copy of the engine patched as well, because the engine's own bugs become your service's bugs.
-
In what order does a safe search feature stack its defences?
Cheapest first: cap the length, then escape the text or compile the pattern on a linear engine, then run under a deadline, so every request ends in an answer or a clear error rather than a blocked thread.
Key takeaways
- Untrusted regex reaches a service two ways, a user-supplied pattern and user-supplied input, and each needs its own defence, so the first question is always which part came from outside.
- A pattern a user typed is code you agreed to run, and on a backtracking engine a small pattern can block the thread, so a user pattern needs a bound on its time, not just a successful compile.
- A linear-time engine removes the explosive case of a slow user pattern, because its time stays linear in the text whatever the pattern, at the cost of refusing backreferences and, in most such engines, lookaround.
- When the engine must still backtrack, a hard deadline on a worker, a match timeout or an engine limit such as PCRE2’s
(*LIMIT_MATCH)caps the damage, and an input length cap shrinks the worst case for any pattern. - Escape user text with a tool such as
RegExp.escapeorre.escapeso it is matched literally, and keep every bundled copy of the engine patched, because the engine’s own bugs become your service’s. - A safe feature stacks these defences in a fixed order, cheapest guard first, so every request ends in an answer or a clear error rather than a blocked thread.
Terms in this lesson
- deadline worker
- A separate thread or process that runs a match and is stopped when it passes a fixed time limit, so a slow or runaway match cannot block the thread that serves other requests.
- input length cap
- A limit on the length of the text, or pattern, a feature will accept, checked before matching begins, which shrinks the worst case of every pattern. A short input can still be slow on a backtracking engine, so a cap works beside a safe pattern or a deadline.
- text escaping
- Turning a piece of text into a pattern that matches it literally, by escaping every character that would otherwise be special, so that user search text is never read as regex syntax.
- untrusted pattern
- A regular expression that comes from outside your team, such as one a visitor types into a search or filter box. It must be treated as code, because the wrong pattern can make a backtracking engine run for a very long time.
References
- Super-linear backtracking: V8's non-backtracking RegExp engine and its experimental flags (The V8 project)
- Node.js command-line API: the V8 options a program can pass (OpenJS Foundation)
- Regular expression options (.NET): NonBacktracking and the match timeout (Microsoft)
- Best practices for regular expressions in .NET: trusted patterns and time-out values (Microsoft)
- Regexp (Ruby 4.0): Regexp.timeout, Regexp.linear_time? and the default of nil (Ruby community)
- regex: the third-party Python module and its timeout parameter (Matthew Barnett (PyPI))
- pcre2api: the match limit, the depth limit and (*LIMIT_MATCH) (The PCRE2 project)
- pcre2test: the find_limits subject modifier (The PCRE2 project)
- PCRE2 security advisory GHSA-r9hj-j2rw-4q3m (The PCRE2 project)
- PCRE configuration: pcre.backtrack_limit, and preg_last_error_msg (The PHP Documentation Group)
- RegExp.escape(): escaping text for use in a pattern (MDN Web Docs)
- re.escape: escaping text so it matches literally (Python Software Foundation)
- regexp: QuoteMeta, and the linear-time guarantee of Go's engine (The Go Authors)
- Crate regex: untrusted patterns, the size limit and the O(m * n) bound (The regex crate authors)
- Input Validation Cheat Sheet: length limits and server-side validation (OWASP)
- Regular expression Denial of Service - ReDoS (OWASP)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress