Regular Expressions
Date: 2026-08-17
A pattern language for matching text. Powerful, dense, and carrying one genuine hazard — a pattern that looks fine can take exponential time on an input an attacker chooses.
A regular expression is a pattern describing a set of strings. The engine walks the input attempting to match, backtracking when a branch fails.
The model
The classical model is a finite automaton — a state machine that consumes one character at a time and either accepts or rejects. That version is linear-time and has no hazards.
The regexes in every mainstream language are not that. They add backreferences, lookaround and lazy quantifiers, which need a backtracking engine — one that tries a possibility, and on failure returns to try another. That’s where the power and the danger both come from — State Machines.
The syntax that covers most use
CHARACTERS
. any character except newline
\d \w \s digit, word char, whitespace
\D \W \S their negations
[abc] one of these
[^abc] not one of these
QUANTIFIERS
* zero or more greedy
+ one or more greedy
? zero or one
{2,5} between 2 and 5
*? +? the lazy versions
ANCHORS AND GROUPS
^ $ start, end of string
\b word boundary
(...) capture group
(?:...) group without capturing
(?<n>..) named group
a|b alternation
LOOKAROUND
(?=...) followed by
(?!...) not followed by
(?<=..) preceded by
Greedy versus lazy
The distinction behind most “why does it match too much” confusion:
input <b>one</b> <b>two</b>
<.+> greedy → <b>one</b> <b>two</b>
matches the whole line
<.+?> lazy → <b>
stops at the first >
Greedy takes as much as possible then gives back. Lazy takes as little as possible then extends. Neither is right by default; they answer different questions.
Catastrophic backtracking
The hazard, and it deserves the space.
When a pattern can match the same text in many ways, a failing input forces the engine to try all of them — and the count can be exponential.
pattern ^(a+)+$
input aaaaaaaaaaaaaaaaaaaaaaaaX
↑ fails here
the engine must try every way of
splitting those a's between the inner
and outer quantifier before giving up
the count doubles with each added
character — 2ⁿ⁻¹ partitions
24 a's → ~8 million attempts
30 a's → ~540 million
One request hangs a thread. A handful hang the server. This is ReDoS — regular expression denial of service — and it’s a real vulnerability class, not a curiosity.
The shape to recognise: nested quantifiers, or alternation where the branches can match the same thing.
DANGEROUS SAFER
(a+)+ a+
(a|a)* a*
(\w+\s?)+ [\w\s]+
(.*),(.*),(.*) [^,]+,[^,]+,[^,]+
The fix is usually to make the inner parts mutually exclusive, so there’s only one way to match — often by replacing . with a negated character class.
Where you shouldn’t use one
- HTML and XML. Nesting is not regular; a parser exists for a reason
- Email validation. The correct pattern is enormous and still wrong. Check for
@, then send a confirmation email — that’s the only real validation - URLs. Use the
URLconstructor - CSV. Quoting rules defeat it — Serialisation Formats
- Anything a
split,includesorstartsWithdoes. They’re faster and readable
Practical rules
- Never build a regex from user input without escaping. It’s injection with a different payload
- Test the failure path, not just matches. Backtracking only explodes when a match fails
- Comment non-trivial patterns, or use the extended/verbose flag where the language offers it. A regex is write-once and read-never otherwise
- Anchor where you mean to. An unanchored pattern matches anywhere, which is a common validation hole —
/\d{4}/acceptsabc1234xyz - Prefer a timeout or a length cap on anything matching untrusted input