Tags: web-dev concept

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 URL constructor
  • CSV. Quoting rules defeat it — Serialisation Formats
  • Anything a split, includes or startsWith does. 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}/ accepts abc1234xyz
  • Prefer a timeout or a length cap on anything matching untrusted input