CWE-183: Permissive List of Allowed Inputs - Python
Overview
Strict input validation in Python rests on regular expressions that match the whole string, sets for fixed lists of legal values, and path functions that resolve a name before comparing it.
Primary Defence: Match the entire string with re.fullmatch(), or with ^...\Z if you prefer anchors, and cap the length before the pattern runs. Where the input has structure, hand it to the module that understands it - pathlib.Path.resolve() for file paths, ipaddress for IP addresses - rather than describing that structure in a pattern.
Common Vulnerable Patterns
Unanchored Regular Expressions
import re
def validate_email(email):
# VULNERABLE - no anchors, allows extra content
# Attacker: "valid@example.com<script>alert(1)</script>"
if re.match(r'[\w.-]+@[\w.-]+', email):
return True # Matched prefix, ignores suffix!
return False
Why this is vulnerable: re.match() anchors at the start of the string and nowhere else, so the pattern is satisfied by any input that begins with something email-shaped and the rest is never examined. The validator returns True for a value carrying a script tag, a null byte or a second address.
Adding $ is the fix and needs two caveats, both about what $ actually anchors to. It matches at the end of the string or immediately before a newline that ends it, so "a@b.com\n" passes an anchored pattern while "a@b.com\n<script>" does not - the trailing newline is the only extra character it tolerates, which is enough to smuggle a value into anything that later strips whitespace. And under re.MULTILINE, $ matches at every line break instead, so "a@b.com\n<script>alert(1)</script>" passes: the flag turns a whole-string check into a per-line one.
\Z matches only at the true end and is unaffected by MULTILINE, and re.fullmatch() sidesteps the question by requiring the pattern to consume the entire string.
Permissive File Extension Check
def validate_filename(filename):
# VULNERABLE - just checks if extension appears anywhere
# Attacker: "malware.exe.jpg", "file.jpg.php"
if re.search(r'\.(jpg|png|gif)', filename):
return True
return False
Why this is vulnerable: re.search() looks anywhere in the string, so .jpg appearing in the middle satisfies a check meant to describe the end - invoice.jpg.php passes while the file is a PHP script.
The deeper problem is that the extension is not the decision anyone actually wants. What matters is how the server will treat the file, and Apache's AddHandler historically dispatched on any matching extension rather than the last one, so a name the application considers an image can still be executed. Validate the final extension against an allowlist, verify the content type by inspecting the bytes, and store uploads where nothing will execute them.
Path Traversal Allowed
def get_file(filename):
# VULNERABLE - allows path traversal
# Attacker: "../../../etc/passwd"
allowed_chars = re.compile(r'^[a-zA-Z0-9._/-]+$')
if allowed_chars.match(filename):
return open(f'/var/data/{filename}') # Path traversal!
return None
Why this is vulnerable: The character class permits . and /, and ../../ is built entirely from characters on the allowlist - so the pattern is satisfied while the path escapes. Anchoring it changes nothing.
That is the general lesson: a character allowlist constrains the alphabet, not the grammar. It cannot express "no parent-directory segments", because that is a statement about structure. See CWE-22 for the check that does - resolve the path and confirm containment.
Secure Patterns
Strict Email Validation
This is strictly based on xxxxx@yyyyy.zzzzzz. Full RFC5322 can be much more complex.
import re
def validate_email(email):
# Check length before regex
if len(email) > 254:
return False
# Anchored regex ensures entire string matches
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.fullmatch(pattern, email):
return False
# Additional semantic checks
local, domain = email.split('@')
if len(local) > 64: # RFC 5321 limit
return False
return True
Why this works: re.fullmatch() against r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' requires the whole string to be local part, @, domain and TLD, so an address embedded in a larger string such as "user@example.com<script>alert(1)</script>" is rejected rather than matched as a prefix. The 254-character check matches the RFC 5321 address limit and keeps very long inputs away from the regex, bounding what backtracking can cost. The local part check (64 characters) enforces the RFC 5321 mailbox limit, and requiring a TLD of at least two characters (.co, .uk) rejects most typos.
Strict Filename Validation
import re
def validate_filename(filename):
# Length check
if len(filename) > 255:
return False
# Anchored pattern - must END with allowed extension
pattern = r'^[a-zA-Z0-9_-]+\.(jpg|png|gif)$'
if not re.fullmatch(pattern, filename, re.IGNORECASE):
return False
# Additional security checks
if '..' in filename or '/' in filename:
return False
return True
Why this works: The pattern r'^[a-zA-Z0-9_-]+\.(jpg|png|gif)$' uses the $ anchor to ensure the filename ends with an allowed extension, preventing double-extension attacks like "malware.exe.jpg" where the real extension is .exe but .jpg appears in the filename. The character allowlist [a-zA-Z0-9_-] blocks the special characters a path traversal or command injection payload would need. The length check rejects extremely long filenames before any of that runs. The explicit checks for .. and / provide defense-in-depth against path traversal, even though the regex should already block these. Case-insensitive matching with re.IGNORECASE prevents bypasses like "file.JPG" vs "file.jpg".
Path Validation with Canonicalization
from pathlib import Path
def get_file(filename):
# Best: strict allowlist of specific files
allowed_files = {
'report.pdf',
'data.csv',
'summary.txt'
}
if filename not in allowed_files:
raise ValueError('File not allowed')
# Resolve symlinks and verify within allowed directory
base_dir = Path('/var/data').resolve()
file_path = (base_dir / filename).resolve()
# Ensure resolved path is within base directory
try:
file_path.relative_to(base_dir)
except ValueError:
raise ValueError('Path traversal detected')
return open(file_path)
Why this works: The set of specific filenames settles access before any path handling happens: a name that is not in the set never reaches the filesystem. Path.resolve() normalizes the path and follows symbolic links, preventing traversal attacks that use techniques like "../../etc/passwd" or a symlink planted inside the base directory. relative_to() verifies the resolved path is still inside base_dir without relying on a string prefix, so sibling directories such as /var/data-secret do not match accidentally.
Username Validation
import re
def validate_username(username):
# Length check
if not username or len(username) > 20:
return False
# Strict pattern: lowercase letters, numbers, underscore only
pattern = r'^[a-z0-9_]{3,20}$'
if not re.fullmatch(pattern, username.lower()):
return False
# Reject reserved names
reserved = {'admin', 'root', 'system', 'administrator'}
if username.lower() in reserved:
return False
return True
Why this works: re.fullmatch() against r'^[a-z0-9_]{3,20}$' requires the entire string to be 3-20 characters of lowercase letters, digits, and underscores, so a value like "admin'; DROP TABLE users--" has no substring the check will settle for. Converting to lowercase before matching accepts mixed-case input without widening the character set. The length cap runs first, so an outsized value never reaches the pattern. The reserved-name set then rejects admin, root, system and administrator in O(1), which format validation alone would have accepted.
URL Validation
from urllib.parse import urlparse
import ipaddress
def validate_url(url):
try:
parsed = urlparse(url)
# Strict: only allow http and https
if parsed.scheme not in ('http', 'https'):
return False
# Validate host exists
if not parsed.netloc:
return False
# Extract hostname (remove port if present)
hostname = parsed.hostname
if not hostname:
return False
# Optional: reject private/loopback addresses
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback:
return False
except ValueError:
# Not an IP address, that's okay
pass
return True
except Exception:
return False
Why this works: Python's urlparse() separates the scheme, authority, host, path, query, and fragment so the code can validate the parsed components instead of matching URL text with a regex. By validating parsed.scheme against a tuple of allowed protocols, the code prevents dangerous protocols like javascript:, data:, file:, or ftp: that could enable XSS or local file access attacks. Checking for a non-empty netloc and hostname prevents URLs like http:// or http:evil that have a scheme but no network destination. The ipaddress module check rejects IP literals that is_private or is_loopback covers, which is broader than it looks: 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 127.0.0.1, 0.0.0.0, the link-local 169.254.0.0/16 metadata range, and their IPv6 equivalents (::1, fc00::/7, fe80::/10) once parsed.hostname has stripped the brackets. Treat this as a URL-shape check, not a complete SSRF control: it only sees literals, so a hostname that resolves to an internal address passes. Production SSRF defenses also need DNS resolution, redirect handling, and connection-time enforcement.
Enum-Based Validation
def validate_role(role):
# Best practice: use set for known values
allowed_roles = {'user', 'moderator', 'admin'}
# Exact match only (case-insensitive)
return role.lower() in allowed_roles
# Alternative: use Enum
from enum import Enum
class Role(Enum):
USER = 'user'
MODERATOR = 'moderator'
ADMIN = 'admin'
def validate_role_enum(role):
try:
Role(role.lower())
return True
except ValueError:
return False
Why this works: Membership in a set is an exact comparison against a fixed list of values, with none of the partial-match behavior a pattern brings - the value is one of the three or it is not - and the lookup is O(1) where list membership is O(n). Converting to lowercase accepts mixed-case input without widening that list. The Enum alternative makes the same check through the type system: Role(role.lower()) raises ValueError for anything undefined, and the values live in one place where an IDE can flag a typo and refactoring tools can follow a rename.
Numeric ID Validation
import re
def validate_id(id_str):
# Strict: exactly 8 digits
pattern = r'^[0-9]{8}$'
if not re.fullmatch(pattern, id_str):
return False
# Semantic validation: check range
num_id = int(id_str)
return 10000000 <= num_id <= 99999999
Why this works: re.fullmatch() against r'^[0-9]{8}$' requires exactly 8 digits, so "12345678abc" and "abc12345678" are rejected rather than accepted on the digits they contain. Running the pattern before int() means the conversion only ever sees a numeric string, instead of int() raising ValueError on whatever arrived. The range check then adds the semantic constraint the format cannot express: if IDs start at 10000000, "00000001" is eight well-formed digits and still gets rejected.
Python-Specific Best Practices
Use re.fullmatch() for Exact Matching
import re
# RISKY - `$` also matches just before a trailing newline,
# so a username ending in a newline passes this check
if re.match(r'^[a-z0-9]{3,20}$', username):
pass
# SECURE - fullmatch() requires the whole string, newline included
if re.fullmatch(r'[a-z0-9]{3,20}', username):
pass
# Equivalent if you prefer to keep the anchors: use \Z, not $
if re.match(r'^[a-z0-9]{3,20}\Z', username):
pass
Pre-compile Patterns for Performance
import re
# Compile pattern once at module level
USERNAME_PATTERN = re.compile(r'^[a-z0-9_]{3,20}$', re.IGNORECASE)
def validate_username(username):
return bool(USERNAME_PATTERN.fullmatch(username))
Use pathlib for Path Operations
from pathlib import Path
def get_file_safe(filename):
allowed_files = {'report.pdf', 'data.csv', 'summary.txt'}
if filename not in allowed_files:
raise ValueError('File not allowed')
base_dir = Path('/var/data').resolve()
file_path = (base_dir / filename).resolve()
# Check if file_path is relative to base_dir
try:
file_path.relative_to(base_dir)
except ValueError:
raise ValueError('Path traversal detected')
return open(file_path)
Common Pitfalls
- Relying on
$instead ofre.fullmatch(): in Python'sre,$matches at the end of the string or just before a trailing newline, sore.match(r'^admin$', "admin\n<script>")still matches. Usere.fullmatch(), which requires the whole string to match with no trailing content allowed, when that distinction matters. - Using
os.path.normpath()/abspath()alone for path safety: both only textually collapse..segments - neither follows symbolic links, so a symlink planted inside the allowed directory can still resolve to a target outside it. UsePath.resolve()(oros.path.realpath()), which follows symlinks, then verify the result. - Checking
str(path).startswith(str(base_dir))without a separator boundary:/var/data-secretalso starts with the string/var/data, so a naive prefix check on the string form passes it. UsePath.relative_to()(raisesValueErrorfor paths outside the tree) instead of string prefix comparison.