Stop Directory Traversal with Python Path.resolve()

Directory traversal vulnerabilities pose a severe security risk by allowing attackers to manipulate file paths using relative sequences such as ../ to access unauthorized files across the host system. In Python, the pathlib.Path.resolve() method serves as an essential defense by eliminating relative segments, expanding symbolic links, and returning a fully canonical absolute path. This article explains how Path.resolve() processes path structures, why naive path joins fail, and how to implement strict boundary validation to completely neutralize path traversal exploits.

The Mechanism of a Directory Traversal Exploit

Directory traversal (or path traversal) occurs when untrusted user input is concatenated directly with a base storage path. For example, if an application intends to serve files exclusively from /var/www/uploads, an attacker might pass the string ../../etc/shadow.

A basic string concatenation or naive join operation creates /var/www/uploads/../../etc/shadow. If passed directly to an operating system file descriptor without resolution, the filesystem navigates backward out of the intended directory tree, exposing sensitive system data or allowing arbitrary file writes.

How Path.resolve() Neutralizes Relative Paths

The resolve() method in Python's pathlib module processes path tokens at the filesystem level to compute the canonical, absolute path. It prevents directory traversal exploits through two core behaviors:

  1. Path Normalization: It evaluates all relative components (. and ..). A path like /var/www/uploads/../../etc/shadow is calculated and transformed into its actual representation: /etc/shadow.
  2. Symlink Resolution: It resolves symbolic links to their real disk locations. Attackers often attempt to circumvent path filters by creating symlinks inside an allowed directory that point outward to restricted directories. Path.resolve() dereferences these links, exposing the true destination.

Implementing Secure Confinement

Calling Path.resolve() alone does not block an attack; it reveals the true target of the user's input. The security guarantee comes from pairing Path.resolve() with a boundary check to verify that the canonical target resides within the canonical base directory.

In Python 3.9 and later, the recommended approach utilizes Path.is_relative_to():

from pathlib import Path

def get_safe_file_path(base_directory: Path, untrusted_filename: str) -> Path:
    # 1. Resolve the trusted base directory to its canonical absolute path
    resolved_base = base_directory.resolve()
    
    # 2. Join the base and untrusted input, then fully resolve the result
    resolved_target = (resolved_base / untrusted_filename).resolve()
    
    # 3. Verify the target is strictly inside or equal to the base directory
    if not resolved_target.is_relative_to(resolved_base):
        raise PermissionError("Directory traversal detected: access denied.")
        
    return resolved_target

For environments running Python versions prior to 3.9, the same validation can be enforced using Path.relative_to() inside a try/except block:

try:
    resolved_target.relative_to(resolved_base)
except ValueError:
    raise PermissionError("Directory traversal detected: access denied.")

If resolved_target escapes resolved_base, relative_to() raises a ValueError, immediately catching any traversal attempt.

Handling New or Non-Existent Files

When preparing paths for file writing, the target file may not yet exist on disk. By default, calling resolve(strict=False) enables Path.resolve() to resolve existing parent components while canonicalizing the remaining non-existent segments without throwing a FileNotFoundError. This ensures write boundaries are validated before creating new files.