CWE-183: Permissive List of Allowed Inputs
Overview
A permissive allowlist is input validation that accepts a wider range of values than it should. Edge cases, encoding variations, and inputs that are technically valid but never intended pass the check and go on to cause injection, path traversal, or logic bypass.
Relationship to Other CWEs
- CWE-183 (this page) - an allowlist check that runs and returns a result, but compares too loosely to reject unsafe input
- CWE-697 (Incorrect Comparison) - the parent this page sits under. No page here
- CWE-20 (Improper Input Validation) - where no comparison happens at all, which is what separates it from this page
- CWE-942 (Permissive Cross-domain Security Policy with Untrusted Domains) - this page's child, the narrower case where the permissive allowlist governs which origins a CORS policy trusts
- CWE-185 (Incorrect Regular Expression) - where the finding is really about regex correctness rather than allowlist scope
- CWE-597 (Use of Wrong Operator in String Comparison) -
where the comparison is too loose because the operator does not mean value
equality in that language: Java
==on strings, PHP==coercing numeric-looking values. MITRE records no relationship between the two, but both end in a check that matches more than its author intended
OWASP Classification
A06:2025 - Insecure Design
Risk
Medium-High: The check runs and reports success, so an input the design never meant to permit reaches its sink already marked as validated. What that costs depends on the sink: a filename the server resolves to a handler the check never considered, a path that resolves outside the intended base directory, or a redirect or outbound request aimed at a host the allowlist was meant to exclude.
Remediation Steps
Core Principle: Use strict allowlists with default-deny; permissive allowed-input lists are security bugs.
Trace the Data Path
- Source: Where the input originates - request parameters, headers, file uploads, redirect targets, uploaded filenames.
- Sink: Where the "validated" value is used - file access, a redirect, a SQL/command argument, a role assignment.
- Data Flow / Missing Controls: Look for validation that checks something about the input (a substring, a prefix, a loose character class) without anchoring the match to the entire string or without a semantic check on top of the format check.
Use Strict, Minimal Allowlists (Primary Defense)
- Anchor every pattern match to the full string (
^...$or an equivalent full-match API), never a substring search. - Use the narrowest character set that satisfies the legitimate use case, not a broad class like "any word character" or "any character."
- Set explicit minimum and maximum length limits before or as part of the match.
- For a fixed set of legal values (roles, HTTP methods, file extensions, country codes), use an enum or set with exact matching instead of a pattern.
- Default to reject: anything not explicitly allowed is rejected, not merely "not obviously bad."
// VULNERABLE - unanchored, over-broad match
function isValidFilename(name):
return name.contains(".jpg") // "malware.exe.jpg" passes
// SECURE - full match against a narrow, anchored pattern
function isValidFilename(name):
return fullMatch(name, "^[a-zA-Z0-9_-]{1,50}\.(jpg|png|gif)$")
Why this works: A full-string match against a narrow character class rejects any input that doesn't consist entirely of allowed characters in the allowed shape, closing off prefix/suffix attacks and double-extension tricks that a substring or unanchored check would miss.
Validate Format AND Semantics (Defense in Depth)
Format validation alone (does this look like a number, does this look like an email) is not enough - pair it with a semantic check on top:
// Format-only: accepts any digit string
function isValidAge(input):
return isNumeric(input) // accepts "0", "999", "10000"
// SECURE - format + semantic range check
function isValidAge(input):
if not fullMatch(input, "^\d{1,3}$"):
return false
age = toInt(input)
return age >= 0 and age <= 150
For values used as file paths, resolve to a canonical path and verify it still falls under the intended base directory rather than only checking the input string for .. or a leading slash - canonicalization catches encoding and symlink tricks that character-based checks miss.
Test with Bypass Payloads
- Valid-shaped-but-unsafe values: a prefix or suffix appended to an otherwise valid value (
admin<script>,report.pdf.exe). - Boundary lengths: empty, one below the minimum, one above the maximum.
- Encoding variations: percent-encoded, double-encoded, or null-byte-terminated versions of a blocked value.
- Case variations, when the check is meant to be case-sensitive.
- Re-scan with the security scanner to confirm the finding is resolved.
Common Vulnerable Patterns
An unanchored pattern
// VULNERABLE - the pattern is matched against a substring, not the whole input
if match(pattern, input): // pattern has no ^ or $
accept(input)
Why this is vulnerable: the pattern may well be exactly right. What is wrong is the question being asked of it - most regular-expression APIs search by default, so this answers "does an allowed value appear somewhere in the input" when the intended question was "is the input an allowed value". An attacker keeps the acceptable fragment and puts the payload on either side of it. Reviewing the pattern finds nothing, because the pattern is not the defect.
Anchoring is the fix, and it carries a trap worth knowing before writing the check. In several widely used engines - Python, Java, .NET and PCRE among them - $ matches at the end of the input or immediately before a final newline, so ^[a-z]+$ accepts admin followed by a newline. Where the value then reaches a header, a filename, or a second parser, that trailing byte is often the entire attack. The absolute end-of-input anchor, or the API's full-match entry point, is the one that means what the check intends.
A substring test standing in for a position test
// VULNERABLE - asks whether the token appears, not where
if input.contains(".jpg"): // "malware.exe.jpg" also contains ".jpg"
accept(input)
Why this is vulnerable: the test is satisfied by the token appearing anywhere, and what the code needs to know is whether the name ends with it. Both malware.exe.jpg and shell.jpg.php pass, each chosen for a server that will resolve a different extension from the one the check found.
The deeper problem is that the check asks the wrong component about the wrong thing. An extension is a naming convention, not a statement about content, and it is not what decides how a file gets handled - the server's handler mapping does, and it may key on a different extension within the same name, on a configured default, or on the content type the upload declared. A rule that infers what a file is from its name is guessing, whichever end of the name it inspects.
A prefix test standing in for a scheme test
// VULNERABLE - a string test applied to a value that has structure
if input.startsWith("http"): // "httpx://attacker" or "http:evil" can slip through
accept(input)
Why this is vulnerable: a URL is a structured value and this treats it as a string. http is a prefix of https, which is intended, and equally of httpx: or any other scheme someone registers; http:evil satisfies the test while being nothing like the URL the code had in mind. Lengthening the prefix does not repair the approach, because the string form of a URL has many spellings a parser resolves to the same target and many more it resolves to a different one.
The check belongs on the parsed value: parse the input once, compare the resulting scheme against a list of permitted schemes, and pass the parsed result to whatever acts on it. Parsing and then handing the original string on to be parsed again downstream reopens the gap, because the two parsers need not agree - which is the mechanism behind most open-redirect and server-side request forgery findings that survived a validation fix.
Secure Patterns
// SECURE - full match, narrow character set, explicit length bounds
if fullMatch(input, "^[a-zA-Z0-9_]{3,20}$"):
accept(input)
// SECURE - extension anchored to the end of the string
if fullMatch(input, "^[a-zA-Z0-9_-]{1,50}\.(jpg|png|gif)$"):
accept(input)
// SECURE - exact match against an explicit allowlist, no pattern matching
if input in ALLOWED_PROTOCOLS: // {"http", "https"}
accept(input)
Why this works: Full-string matching against a narrow, explicit allowlist leaves no room for a value that matches technically but was never intended. For values drawn from a known, fixed set, an exact-match allowlist (a set or enum) removes the pattern-matching bypass surface entirely.
Common Pitfalls
- Anchoring only one end of the pattern: using
^prefixorsuffix$alone still lets an attacker append or prepend arbitrary content on the unanchored side - both anchors are required for a full-string match. - Checking format without checking position: confirming an allowed extension or keyword appears somewhere in the input (
contains()/unanchored search) instead of confirming it's at the required position still acceptsmalware.exe.jpgorfile.jpg.php. - Validating syntax but not semantics: a value that matches the expected shape (a number, an email-like string, a URL) can still be out of range, disposable, or point at internal infrastructure - format validation alone doesn't catch a redirect to
http://169.254.169.254or an age of10000. - Path checks that inspect the raw string instead of the resolved path: rejecting literal
..or a leading/misses encoded traversal sequences and symlink tricks; resolving to a canonical path and checking it against the intended base directory catches both.
Language-Specific Guidance
- Java - Pattern.matches with anchors, enums, jakarta.validation
- JavaScript/Node.js - Regex with ^ and $, validator.js, path validation
- Python - re.fullmatch, pathlib.Path.resolve, ipaddress module