Security¶
One line: Three regex mistakes are vulnerabilities rather than bugs — an anchor that lets a second line through, a case-fold that lets one character impersonate another, and user text pasted into a pattern unescaped — and a fourth, catastrophic backtracking, is a denial of service reachable from a text field; the first three are in one table below, and every engine here gets at least one of them wrong.
Level: 301 · untrusted input reaches your pattern
The four¶
| Vulnerability | The mistake | The fix |
|---|---|---|
| Validation bypass | ^…$ — a line test; $ steps over a final newline in four engines, any newline in Ruby |
\A…\z or the whole-string call — validating input |
| Homograph / confusable acceptance | (?i) folding U+212A KELVIN SIGN to k, U+017F LONG S to s; \d accepting non-ASCII digits |
explicit classes; fold in the host language and match the folded form — case-insensitive matching |
| Pattern injection | user text concatenated into a pattern | the escape function — escaping and literal text |
| ReDoS | a nested quantifier over overlapping text, and a subject that fails | an automaton engine, an atomic group, a limit, a timeout — catastrophic backtracking |
Measured¶
Verified output of topic_security_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
construct python perl node ruby java go rust fancy
$ lets a newline through 42 42 no 42 42 no no no
Ruby: $ lets a line through no no no 42 no no no no
\A…\z does not - no no no no no no no
(?i) folds KELVIN to k \u{212A}elvin \u{212A}elvin - \u{212A}elvin no \u{212A}elvin \u{212A}elvin \u{212A}elvin
(?i)[a-z] admits KELVIN \u{212A} \u{212A} - \u{212A} no \u{212A} \u{212A} \u{212A}
[a-z] admits KELVIN, node i no no no no no no no no
\d admits non-ASCII digits \u{661}\u{662} \u{661}\u{662} no no no no \u{661}\u{662} \u{661}\u{662}
unescaped user text userXname userXname userXname userXname userXname userXname userXname userXname
escaped user text no no no no no no no no
Reading the table¶
- Row 1.
^\d+$accepts42\nin Python, Perl, Ruby and Java. A downstreamint()succeeds; a downstream== "42"fails; a downstream shell command gets a newline. JavaScript, Go and Rust reject it. - Row 2.
42\nrm -rf /passes^\d+$in Ruby, and only Ruby — the lesson has the full payload set. A Ruby validator with^and$validates one line of whatever it is given. - Row 3.
\A\d+\zrejects the newline everywhere it compiles; in Python 3.12 the spelling is\Z. - Rows 4–6, the KELVIN SIGN.
(?i)^kelvin$matchesKelvinwritten with U+212A in Python, Perl, Ruby, Go and both Rust crates: the sign folds tok.(?i)^[a-z]+$accepts the sign alone in the same six. Java says no both times — its default folding is ASCII — and JavaScript with theiflag says no. So an account namedkelvinand one named with a KELVIN SIGN are "the same" to a Python validator and different to a Java one, and whichever engine the uniqueness check runs in, the other one disagrees. Whether that is a bug depends on which of the two you meant; that it is undecided by the pattern is the vulnerability. - Row 7.
^\d+$accepts U+0661 U+0662 in Python, Perl and Rust — "digits" thatInteger.parseIntrejects and thatint()accepts, so the same input is valid or invalid depending on which layer looks at it. - Rows 8–9, injection. A pattern built as
"^user" + input + "name$"where the input is.matchesuserXname— in all eight — because the dot was never escaped. Escaped, it matches nothing but the literal. The user chose the metacharacter; with|or(they choose the pattern's structure, and with(a+)+$they choose row four of the table above.
The rules¶
- Anchor with
\Aand\z. Or usefullmatch/matches. A^…$in a validator is a finding, not a style issue. - No
(?i)on a security decision, and no\dor\wwithout deciding whether Unicode is wanted. Normalise (NFC or NFKC), fold with the host language's documented function, and match the result with explicit classes. - Escape every external string that becomes part of a pattern or a replacement, with the language's function, in the language that will run the pattern. Never build a pattern from user input without one.
- Untrusted input never reaches a backtracking engine without a limit. Go and Rust's
regexare linear by construction; PCRE2 andfancy-regexhave limits; Ruby hasRegexp.timeout=; Java and JavaScript have nothing, so the pattern itself must be checked for the shape. - Test the negative cases, with a newline, a second line, a KELVIN SIGN and a metacharacter in them — testing patterns.
Keywords on this page¶
^ · $ · \A \z \Z · Inline flags · Shorthand classes · \Q…\E · Atomic group · *, +, ?.
See also¶
$is not the end of the string · Case-insensitive matching · Catastrophic backtracking — the three lessons this page compresses.- Validating input · Escaping and literal text · Choosing an engine.