CWE-41: Improper Resolution of Path Equivalence - Python
Overview
Path equivalence vulnerabilities in Python occur when different representations of file paths (e.g., /app/files vs /app//files vs /app/files/.) bypass validation due to string comparison without canonicalization. pathlib has the pieces for canonical comparison - Path.resolve() and is_relative_to() - but code that filters or compares os.path strings without them stays vulnerable.
Primary Defence: Use pathlib.Path.resolve(strict=True) to canonicalize existing user-supplied targets to absolute form (resolving ., .., and symlinks), then verify the resolved path is within the allowed directory using is_relative_to() (Python 3.9+) or relative_to(). Apply Unicode normalization with unicodedata.normalize('NFC') before path operations, reject filenames containing path separators, and validate file type with is_file() before access.
Common Vulnerable Patterns
Stripping Traversal Sequences Instead of Canonicalizing
# VULNERABLE - Stripping Traversal Sequences Instead of Canonicalizing
@app.route('/download')
def download_file():
# Weak sanitization - removes '../' once, non-recursively
filename = request.args.get('file').replace('../', '')
filepath = f'/app/public/{filename}'
return send_file(filepath)
# Attack: file=....//....//etc/passwd
# Each '....//' loses its inner '../', and the characters either side rejoin as '../'
# Result: '/app/public/../../etc/passwd' -> '/etc/passwd'
Why this is vulnerable:
str.replace('../', '')makes a single pass, so a sequence built to reassemble after its own removal survives -....//becomes../, and repeating it climbs as far as the attacker wants- Rejecting the literal
..instead of stripping it stops this payload, but not the paths that need no..at all: an absolute path, or a symbolic link inside/app/publicthat points outside it - No canonicalization means the string that was validated and the path that is opened are different values, and only the second one matters
- Equivalent spellings such as
/app/public//fileand/app/public/./filereach the same file through a string that does not match any filter written for the plain form
Missing Unicode Normalization
# VULNERABLE - Missing Unicode Normalization
import os
ALLOWED_DIR = '/app/files'
@app.route('/get')
def get_file():
filename = request.args.get('name')
filepath = os.path.join(ALLOWED_DIR, filename)
# Doesn't handle Unicode equivalents
# Attack: Use Unicode combining characters or normalization forms
# Example: 'file' vs 'file' (with combining diacritical marks)
# Example: NFC vs NFD normalization differences
if os.path.exists(filepath):
return send_file(filepath)
Why this is vulnerable:
- Unicode normalization forms (NFC, NFD, NFKC, NFKD) can represent the same filename differently
- Combining diacritical marks can create visually identical but byte-different filenames
- No Unicode normalization allows bypassing string-based allowlists or denylists
- File system may normalize differently than application, creating mismatches
Secure Patterns
Path Canonicalization with Containment Check
from pathlib import Path
ALLOWED_DIR = Path('/app/public').resolve()
@app.route('/download')
def download_file():
filename = request.args.get('file', '')
if not filename:
abort(400, "Filename required")
# Construct path
requested_path = ALLOWED_DIR / filename
try:
# Resolve to canonical absolute path for an existing target
resolved_path = requested_path.resolve(strict=True)
except FileNotFoundError:
abort(404, "File not found")
except (OSError, RuntimeError):
abort(400, "Invalid path")
# Verify resolved path is within allowed directory
# Python 3.9+: use resolved_path.is_relative_to(ALLOWED_DIR)
try:
resolved_path.relative_to(ALLOWED_DIR)
except ValueError:
abort(403, "Access denied")
# Verify file exists and is a file (not directory)
if not resolved_path.is_file():
abort(404, "File not found")
return send_file(resolved_path)
Why this works:
- Uses
resolve(strict=True)to canonicalize an existing target path, resolving.,.., symlinks, and path equivalents relative_to()(oris_relative_to()in Python 3.9+) verifies canonical path remains within allowed directory after resolution- Validates file existence and type after canonicalization
- Compares canonical forms before access, so a path outside the allowed directory is rejected however it was spelled
Unicode Normalization with Path Validation
import unicodedata
from pathlib import Path
ALLOWED_DIR = Path('/app/files').resolve()
@app.route('/get')
def get_file():
filename = request.args.get('name', '')
if not filename:
abort(400, "Filename required")
# Normalize Unicode to NFC (canonical composition)
filename = unicodedata.normalize('NFC', filename)
# Reject null bytes
if '\x00' in filename:
abort(400, "Invalid filename")
# Prevent path separators
if '/' in filename or '\\' in filename:
abort(400, "Invalid filename")
# Construct and resolve path
try:
filepath = (ALLOWED_DIR / filename).resolve(strict=True)
except FileNotFoundError:
abort(404, "File not found")
except (OSError, RuntimeError):
abort(400, "Invalid path")
# Verify within allowed directory
# Python 3.9+: use filepath.is_relative_to(ALLOWED_DIR)
try:
filepath.relative_to(ALLOWED_DIR)
except ValueError:
abort(403, "Access denied")
# Verify file exists and is a file
if not filepath.is_file():
abort(404, "File not found")
return send_file(filepath)
Why this works:
unicodedata.normalize('NFC')converts Unicode to canonical composed form, reducing normalization mismatch risk- Rejects null bytes rather than transforming the requested filename
resolve(strict=True)canonicalizes an existing path, resolving.,.., symlinks, and path equivalentsrelative_to()(oris_relative_to()in Python 3.9+) verifies canonical path is within allowed directory
Additional Resources
- CWE-41: Improper Resolution of Path Equivalence
- OWASP Path Traversal
- Python pathlib Documentation - Path.resolve(), is_relative_to()
- Python os.path Documentation - realpath(), normpath()
- Unicode Normalization in Python