Skip to content

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/public that 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//file and /app/public/./file reach 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() (or is_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 equivalents
  • relative_to() (or is_relative_to() in Python 3.9+) verifies canonical path is within allowed directory

Additional Resources