Skip to content

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+$ accepts 42\n in Python, Perl, Ruby and Java. A downstream int() 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+\z rejects the newline everywhere it compiles; in Python 3.12 the spelling is \Z.
  • Rows 4–6, the KELVIN SIGN. (?i)^kelvin$ matches Kelvin written with U+212A in Python, Perl, Ruby, Go and both Rust crates: the sign folds to k. (?i)^[a-z]+$ accepts the sign alone in the same six. Java says no both times — its default folding is ASCII — and JavaScript with the i flag says no. So an account named kelvin and 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" that Integer.parseInt rejects and that int() 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 . matches userXname — 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

  1. Anchor with \A and \z. Or use fullmatch / matches. A ^…$ in a validator is a finding, not a style issue.
  2. No (?i) on a security decision, and no \d or \w without deciding whether Unicode is wanted. Normalise (NFC or NFKC), fold with the host language's documented function, and match the result with explicit classes.
  3. 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.
  4. Untrusted input never reaches a backtracking engine without a limit. Go and Rust's regex are linear by construction; PCRE2 and fancy-regex have limits; Ruby has Regexp.timeout=; Java and JavaScript have nothing, so the pattern itself must be checked for the shape.
  5. 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