CWE-185: Incorrect Regular Expression
Overview
Incorrect regular expressions occur when a pattern's matching logic does not do what the developer intended - missing anchors, unescaped metacharacters, wrong alternation/quantifier scope, or an overly narrow or overly broad character class. When the pattern is used as a security control, that logic gap becomes a validation bypass: input the check was supposed to reject gets accepted, or legitimate input gets wrongly rejected.
Relationship to Other CWEs
- CWE-185 (this page) - a regular expression whose matching logic is wrong, so it accepts or rejects the wrong inputs
- CWE-697 (Incorrect Comparison) - the parent this Class-level page is ChildOf. No page here
- CWE-186 (Overly Restrictive Regular Expression) - a Base-level child: the pattern that wrongly rejects valid input. No page here, so findings in that direction stay on this page for now
- CWE-625 (Permissive Regular Expression) - the other child: the pattern that wrongly accepts invalid input. No page here either
- CWE-1333 (Inefficient Regular Expression Complexity) - a distinct weakness, and the one to use for a regex that is logically correct but computationally expensive under attacker-controlled input. Easy to conflate because both present as a bad regex, but this page is a matching-logic bug and CWE-1333 is a performance and availability bug
- CWE-183 (Permissive List of Allowed Inputs) - the broader case where an allowlist is too permissive for reasons beyond a single regex's logic
OWASP Classification
A06:2025 - Insecure Design
Risk
Medium-High: An incorrect regex used for validation lets attackers submit input the check was supposed to reject, enabling injection, path traversal, or authentication bypass downstream. The reverse failure - wrongly rejecting legitimate input - is primarily a functional bug, but it can also push users or developers toward disabling or loosening validation altogether, reintroducing the same risk from the other direction.
Remediation Steps
Core Principle: Anchor and escape a regex correctly, verify alternation and quantifier scope match intent, and prefer a purpose-built parser over regex for structured formats like URLs, paths, and IP addresses.
Trace the Data Path
- Source: where the input is captured - a request parameter, filename, header value.
- Sink: what decision the regex result gates - accept/reject, extraction, a redirect target.
- Missing controls: the pattern lacks anchors, has an unescaped metacharacter, or has alternation or quantifier scope that does not match what the developer intended.
Anchor and Escape Correctly (Primary Defense)
- Anchor the pattern to the whole string unless a substring match is genuinely intended - and prefer the language's whole-string call (
re.fullmatch,matcher().matches()) over^...$, which is not the same thing in every language. See An end anchor that is not the end below. - Escape every metacharacter (
. * + ? ^ $ \ | ( ) [ ] { }) that should be taken literally - an unescaped.matches any character, not just a literal dot. - Group alternation explicitly with parentheses -
a|bcand(a|b)cmean different things even though they look similar, and an ungrouped|can silently narrow an anchor's scope to only one branch. - Treat negated character classes (
[^...]) as excluding only the listed characters, not "everything unsafe."
Prefer a Structured Parser Over Regex for Structured Formats
- Parse and validate URLs, IP addresses, and paths with the platform's URL/IP/path library rather than a hand-written regex - correctness for edge cases (IPv6, percent-encoding, Unicode) is hard to get right in a regex and already solved in a maintained parser.
- Reserve regex for genuinely simple, fixed-shape formats (short codes, restricted character sets).
Test Against Intended and Adversarial Input
- Confirm the pattern accepts every legitimate input shape, including boundary cases (minimum/maximum length, every allowed special character).
- Confirm it rejects near-miss malicious input: a valid prefix with a malicious suffix, a value that exploits an unescaped metacharacter, and encoded variants.
- Re-scan with the security scanner to confirm the finding is resolved.
Common Vulnerable Patterns
Each of these patterns is valid, compiles without complaint, and accepts every input the author intended. What differs is what else it accepts.
A pattern with no anchors
// VULNERABLE - missing anchors: matches a substring, not the whole input
if match(pattern, filename): // pattern: \.(jpg|png|gif)
accept(filename) // "malware.exe.jpg" matches the substring ".jpg"
Why this is vulnerable: the pattern describes a fragment and the author read it as a description of the whole value. Without ^ and $ it says "an image extension appears somewhere in here", which malware.exe.jpg satisfies, and so does .jpgsomething, and so does a name with a newline in the middle of it. The pattern is not wrong about what it matches - it is answering a different question from the one the if is asking.
Anchoring one end is the half-fix that follows. \.(jpg|png|gif)$ pins the extension to the end and leaves everything before it unconstrained, so a name that traverses directories or starts with a hyphen still passes. Both anchors are needed for the pattern to be a statement about the value rather than a search within it.
An unescaped metacharacter
// VULNERABLE - unescaped metacharacter: "." matches any character, not a literal dot
if match("^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}$", ip):
accept(ip) // "192X168X1X1" also matches
Why this is vulnerable: . is the metacharacter for "any character", so the pattern reads as four groups of digits separated by anything at all. 192X168X1X1 matches, and so does a value with a control character or a newline where the separator should be.
This is the failure mode that testing cannot find, because the pattern is only ever too permissive. Every genuine IP address still matches, every unit test written from real examples still passes, and the check appears to work for as long as nobody sends something it was meant to reject. That is also why the same mistake persists through refactors: the pattern has no history of failing.
Alternation that is wider than it looks
// VULNERABLE - ungrouped alternation narrows the anchors to one branch
if match("^report|invoice\.pdf$", filename):
accept(filename) // "reportXYZ" matches via the left branch (only anchored at start);
// "XYZinvoice.pdf" matches via the right branch (only anchored at end)
Why this is vulnerable: alternation has the lowest precedence of any regular-expression operator, so this parses as (^report) or (invoice\.pdf$) rather than as ^(report|invoice\.pdf)$. Each branch inherits one anchor and neither inherits both. reportXYZ satisfies the left branch, XYZinvoice.pdf satisfies the right, and the pattern accepts a far wider set than the author wrote down.
There is nothing in the pattern's appearance to catch this - it is a precedence rule, not a typo, and it reads correctly to anyone who has not memorised where alternation binds. Grouping the alternation explicitly, ^(report|invoice\.pdf)$, states the intended scope rather than relying on the reader knowing the precedence table.
An end anchor that is not the end
// VULNERABLE - "$" matches before a final newline in several languages
if match("^[a-z]+$", username):
accept(username) // "alice\n" is accepted in Python, .NET and PHP
Why this is vulnerable: $ is not universally an end-of-input anchor. In several engines it also matches immediately before a final newline, so an allowlist written to permit only lowercase letters admits its own permitted value with a newline appended - and a newline is the byte that splits a header, a log line, or a mail command's arguments in whatever the value is passed to next. Measured, feeding alice\n to ^[a-z]+$:
| Runtime | ^[a-z]+$ accepts alice\n |
What refuses it |
|---|---|---|
| Python 3.13 | yes | re.fullmatch() |
| .NET 10 | yes | \A...\z only - \Z accepts it too |
| PHP 8.5 | yes | \A...\z, or the D modifier on the pattern |
| Java 26 | no, via matches() |
but the same pattern via find() accepts it |
| JavaScript | no | unless the m flag is set, which restores the behaviour |
| Go | no | - |
Two things follow. The fix is per-language and the obvious cross-language spelling is wrong twice over: \Z is the strict anchor in Python and the lax one in .NET and Java. And in Java the anchors are not what saves you at all - matches() requires the whole region and find() does not, so swapping the call changes the answer while the pattern stays identical. Ask which call consumes the whole input rather than which anchor is on the end of the pattern.
Secure Patterns
// SECURE - anchored full match, escaped metacharacters
if fullMatch("^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
// still verify each octet is 0-255 semantically - format alone isn't enough
accept(ip)
// SECURE - alternation explicitly grouped so both anchors apply to both branches
if fullMatch("^(report|invoice)\.pdf$", filename):
accept(filename)
// SECURE - prefer a structured parser over a hand-written pattern
parsed = parseUrl(input)
if parsed.scheme in ALLOWED_SCHEMES and parsed.host is not empty:
accept(parsed)
Why this works: Anchoring the pattern to the full string, escaping every literal metacharacter, and grouping alternation explicitly close the most common logic gaps - substring matches, metacharacters standing in for characters they weren't meant to represent, and anchors that silently apply to only one branch of an alternation. Using a maintained parser for structured formats (URLs, IPs, paths) avoids re-deriving edge-case-correct matching logic in regex at all.
Common Pitfalls
- Escaping a metacharacter in one branch of an alternation but not another:
a\.b|c.descapes the dot in the first alternative but not the second - review every alternative in a pattern individually, not just the first one that was written and tested. - Assuming a negated character class excludes everything unsafe:
[^<>]+blocks angle brackets but not other characters that are dangerous in the destination context (quotes, backslashes, null bytes) - a negated class only excludes what's listed, nothing more. - Testing only with valid input: a pattern that accepts every legitimate value can still accept malicious values the author never tried - test with adversarial input (prefixes/suffixes, encoded characters, boundary lengths) before treating a passing "happy path" test as proof the pattern is correct. The cheapest version of this test is mechanical and catches the anchor case above: take every allowlist pattern on the page, feed each one its own permitted value with a newline appended, and assert rejection.
- Writing a regex for a format that has a canonical parser: hand-rolled URL, IP, or path regexes routinely miss edge cases (IPv6 addresses, percent-encoding, Unicode homoglyphs) that a maintained parser already handles correctly.