Safeguard
Industry Analysis

Path Traversal Prevention in Python with os.path.realpath

os.path.normpath() and abspath() don't stop symlink-based path traversal. Here's how os.path.realpath() closes the gap, with real CVEs and a secure pattern.

Aman Khan
AppSec Engineer
7 min read

In February 2024, a security researcher reported a path traversal flaw in a popular Python file-upload library that let attackers write files anywhere on disk by sending a filename like ../../../../var/www/html/shell.py. The library was validating filenames with a regex and calling os.path.join(), but never resolved the final path before writing to it. This is one of the most common mistakes in Python web applications: developers assume string-level checks are enough to stop directory traversal, when in reality the filesystem doesn't care what a path string looks like — only what it resolves to. os.path.realpath() is the standard library function built to answer that question, and it remains one of the most misunderstood tools in the Python security toolkit. This post explains why naive path handling fails, how realpath() actually works, and how to build a validation pattern that survives adversarial input, symlinks, and Windows/POSIX quirks alike.

What is path traversal, and how common is it still in Python codebases?

Path traversal (tracked as CWE-22) is a vulnerability class where an attacker manipulates file path input — typically with sequences like ../, absolute paths, or symlinks — to make an application read, write, or execute a file outside its intended directory. MITRE's 2023 CWE Top 25 Most Dangerous Software Weaknesses ranked path traversal 8th overall, and OWASP's 2021 Top 10 folded it into Broken Access Control (A01), the single largest category, present in 94% of tested applications. In Python specifically, the problem is persistent because so many core building blocks — open(), os.path.join(), shutil.copy(), tarfile.extractall(), zipfile.extractall() — accept arbitrary strings and will happily follow .. segments or symlinks without complaint. A 2023 audit of open-source Python packages on PyPI found path traversal among the top three reported vulnerability categories in file-handling and archive libraries, alongside deserialization and injection flaws.

Why do os.path.normpath() and os.path.abspath() fail to stop traversal attacks?

os.path.normpath() and os.path.abspath() fail because they operate purely on the path string — they collapse .. and . segments syntactically, but they never touch the filesystem, so they cannot see symlinks. Consider os.path.abspath("/var/uploads/../../etc/passwd"): it correctly normalizes to /etc/passwd, so a naive check like path.startswith("/var/uploads") catches this case. But now consider /var/uploads/link, where link is a symlink an attacker created (or that got planted via a prior upload) pointing to /etc. normpath() and abspath() leave that path exactly as /var/uploads/link/passwd — which passes the startswith() check — even though the file the OS will actually open is /etc/passwd. This is precisely the mechanism behind "Zip Slip," the archive-extraction vulnerability class that Snyk's security research team disclosed in 2018 after finding it in more than 30 libraries and projects across Java, Go, JavaScript, and Python ecosystems. Extraction code that trusted normalized paths, rather than resolved ones, wrote files clear outside the target directory.

How does os.path.realpath() close the gap that normpath leaves open?

os.path.realpath() closes the gap because it resolves every symlink in a path and returns the actual canonical location on disk, not just a syntactically tidied string. Where normpath() only rewrites .. and . tokens, realpath() walks the filesystem, follows every symbolic link it encounters (including chained and relative symlinks), and returns the final real path. That means the symlink attack described above stops working: os.path.realpath("/var/uploads/link/passwd") returns /etc/passwd, and a comparison against the resolved, canonical base directory immediately reveals that the target sits outside it. This is why the standard security-review guidance — from OWASP's Path Traversal cheat sheet to Python's own tarfile security advisory (published alongside the fix for CVE-2007-4559) — is consistent: never trust abspath() or normpath() alone for security boundaries; always resolve with realpath() (or pathlib.Path.resolve(), its equivalent) before making an access-control decision.

What does a production-ready realpath validation pattern look like?

A production-ready pattern resolves both the base directory and the target path with realpath(), then confirms the target is a genuine descendant of the base using path-aware comparison — never plain string prefix matching. Here's the pattern used in Safeguard's own internal file-handling utilities:

import os

def safe_join(base_dir: str, user_path: str) -> str:
    base_real = os.path.realpath(base_dir)
    target_real = os.path.realpath(os.path.join(base_dir, user_path))

    # os.path.commonpath avoids the classic /var/uploads
    # vs /var/uploads-evil startswith() false positive
    if os.path.commonpath([base_real, target_real]) != base_real:
        raise ValueError(f"Path traversal attempt blocked: {user_path!r}")

    return target_real

Two details matter here. First, use os.path.commonpath() (or, in Python 3.9+, pathlib.Path.is_relative_to()) rather than target.startswith(base) — a bare startswith() check incorrectly allows /var/uploads-evil to pass a check against base /var/uploads, since the string happens to start with that prefix. Second, resolve base_dir too, not just the user-supplied path; if the base directory itself is reached through a symlink, comparing an unresolved base against a resolved target produces false negatives. For archive extraction specifically, don't hand-roll this at all: since Python 3.12 (released October 2, 2023), tarfile.extractall() accepts a filter argument, and passing filter='data' — now the default behavior for data_filter, formalized in PEP 706 — rejects absolute paths, .. traversal, and symlinks escaping the target directory automatically.

What do real CVEs teach us about the cost of getting this wrong?

Real CVEs show that even the Python standard library got this wrong for over a decade. CVE-2007-4559, a path traversal flaw in the tarfile module's extract() and extractall() methods, was first disclosed in August 2007 and remained unpatched in the default configuration for fifteen years — Trellix researchers re-flagged it in 2022 after finding it silently present in an estimated 350,000+ open-source repositories that used tarfile.extractall() on untrusted archives. The eventual fix came in stages: security-only backports landed in Python 3.8.17, 3.9.17, 3.10.12, and 3.11.4 in June 2023, and PEP 706 made the safe 'data' filter the default in Python 3.12 that October. Separately, the 2018 Zip Slip disclosure showed the same class of bug in zipfile-based extraction code across dozens of Python packages, several of which shipped CI/CD tools and package managers — meaning a malicious archive could overwrite files on a build server outside any sandbox. Both cases share a root cause: code trusted a path string instead of verifying where it actually resolved on disk.

How Safeguard Helps

Path traversal bugs like these are exactly the kind of issue that hides in dependencies you didn't write and don't regularly audit — a tarfile.extractall() call three layers deep in a transitive dependency, or a file-upload helper published years ago that never got the realpath() fix. Safeguard's software supply chain security platform scans your Python dependency tree for known CVEs including CWE-22 path traversal findings like CVE-2007-4559, flags packages still calling extraction APIs without safe filters, and surfaces exactly which of your services import affected code paths — before an attacker finds the same gap. Beyond dependency scanning, Safeguard's static analysis rules catch unsafe path-handling patterns directly in your own codebase: string-only startswith() checks, missing realpath() calls before file operations, and extractall() invocations without a filter argument all get flagged in CI, with the corrected pattern shown inline. For teams under SOC 2 or similar compliance obligations, Safeguard also maintains an auditable record of when each finding was detected, triaged, and remediated — turning a single-function fix like the one in this post into evidence you can hand to an auditor. If your Python services handle user-supplied filenames, extract uploaded archives, or serve files from disk, it's worth running that code path through Safeguard to confirm realpath() — not just normpath() — is standing between your filesystem and the internet.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.