flawopen.com/Path Traversal/Python

Path Traversal in Python

High Severity CWE-22
ELI5

Imagine an employee badge scanner that unlocks 'Room A/closet'. If a visitor inputs '/master-safe', Python's os.path.join sees the leading slash, assumes the visitor gave an absolute master key, and throws away 'Room A' entirely, opening the master safe.

Key terms on this page
leading-slash discard
In os.path.join(base, input), if input starts with a forward slash (/), os.path.join discards base and treats input as an absolute path from the root filesystem.
path canonicalization
Resolving symbolic links and dot-dot navigation (../) into a definitive, absolute filesystem path using Path.resolve().

What's happening

In Python, developers often rely on os.path.join(UPLOAD_DIR, user_file). However, POSIX path concatenation rules state that if an argument begins with a slash (/etc/passwd), all previous path components are discarded. Additionally, directory traversal sequences (../../) are not evaluated by os.path.join until resolved against the filesystem.

Real-world impact

In 2022, CVE-2007-4559 resurfaced across over 350,000 open-source repositories when security researchers demonstrated that naive tarfile and zipfile extractions in Python allowed attackers to overwrite system binaries via relative path members.

CISA Cybersecurity Advisory & MITRE CVE repository.

Vulnerable vs. fixed

VULNERABLE
# VULNERABLE: os.path.join discards BASE_DIR if filename starts with '/'
import os
from flask import Flask, request, abort, send_file

app = Flask(__name__)
BASE_DIR = '/var/app/public/user_uploads'

@app.route('/download')
def download_file():
    filename = request.args.get('file')
    # If filename is "/etc/passwd", os.path.join returns "/etc/passwd"!
    # If filename is "../../etc/passwd", it traverses outside BASE_DIR
    target_path = os.path.join(BASE_DIR, filename)
    
    if not os.path.exists(target_path):
        abort(404)
    return send_file(target_path)
FIXED
# HARDENED: Resolve canonical path and enforce strict boundary containment
from pathlib import Path
from flask import Flask, request, abort, send_file

app = Flask(__name__)
BASE_DIR = Path('/var/app/public/user_uploads').resolve()

@app.route('/download')
def download_file():
    filename = request.args.get('file', '')
    # 1. Strip leading slashes and resolve relative components
    clean_name = filename.lstrip('/\\')
    target_path = (BASE_DIR / clean_name).resolve()
    
    # 2. Strict containment check: target MUST be inside BASE_DIR
    if not target_path.is_relative_to(BASE_DIR) or not target_path.is_file():
        abort(403)
        
    return send_file(target_path)

Why the fix works

Using pathlib.Path.resolve() resolves all intermediate symbolic links and .. segments to an absolute filesystem path. Calling target_path.is_relative_to(BASE_DIR) mathematically verifies that the canonical path starts with the base folder path, preventing any breakout.

Gotchas

os.path.abspath does not resolve symlinks

os.path.abspath only eliminates '.' and '..'; it does not resolve symbolic links on disk. An attacker can create a symlink pointing to /etc/shadow. Use Path.resolve() or os.path.realpath().

Zip Slip in tarfile.extractall()

Python's tarfile.extractall() historically did not sanitize member names. In Python 3.12+, always specify filter='data'.

Common misconceptions

"Checking for '../' is sufficient"

Attackers bypass naive string replacement using nested sequences (....//), URL encoding (%2e%2e%2f), or absolute paths (/etc/passwd). Canonicalization is mandatory.

How to check if you're affected

grep -rn "os\.path\.join" --include="*.py" . grep -rn "tarfile\.extractall" --include="*.py" . # Bandit SAST rule: bandit -r . -t B108,B202

Prevention checklist

FAQ

Why does os.path.join behave this way?

The POSIX standard defines path joining such that an absolute path parameter resets the root. Python adhered strictly to this standard, making it a common trap for web developers.

References