Skip to main content

Defensive path handling for Python: guards against path traversal, symlink escape, Zip Slip, and TOCTOU races.

Project description

pathward

Defensive Python library against path traversal, symlink escape, Zip Slip, and TOCTOU races.

Inspired by the File Browser symlink disclosure, where symlinks planted inside a served directory allowed reading arbitrary files on the host. pathward makes the safe thing the easy thing: every API takes a base directory and refuses to touch anything outside it.

  • Zero dependencies, pure standard library
  • Python 3.9 – 3.13, Linux / macOS / Windows
  • 54 tests, CI across all supported versions and platforms

Install

pip install pathward

Or from source:

pip install -e ".[dev]"
pytest

Quick start

from pathward import safe_join, resolve_within, safe_open, safe_extract

base = "/srv/app/uploads"

# Lexical join that cannot escape base — rejects "..", absolute paths,
# drive letters, UNC prefixes and null bytes:
path = safe_join(base, user_supplied_name)        # PathTraversalError on attack

# Join + resolve + containment check (catches symlink escapes too):
real = resolve_within(base, "docs", filename)     # SymlinkEscapeError on escape

# Open a file with symlink refusal, O_NOFOLLOW and post-open fd verification:
with safe_open(base, filename) as f:              # TOCTOU-hardened
    data = f.read()

# Extract zip/tar archives without Zip Slip, link members, or bombs:
safe_extract("upload.zip", base, max_members=1000, max_total_size=100_000_000)

API

Path containment — pathward.resolve

Function Description
safe_join(base, *parts) Lexically join parts onto base. Raises PathTraversalError for .., absolute paths, drives, UNC, null bytes. Treats / and \ as separators on every platform.
is_within(base, target) True if target resolves inside base (symlinks followed).
resolve_within(base, *parts) safe_join + full resolution + containment check. Raises SymlinkEscapeError if a symlink redirects the path outside base.

Symlink defense — pathward.links

Function Description
find_symlink(base, path) Returns the first symlink component between base and path, or None.
contains_symlink(base, path) Boolean form of the above.
assert_no_symlinks(base, path) Raises SymlinkEscapeError if any component is a symlink.
safe_open(base, *parts, mode="r", follow_symlinks=False) Containment-checked open(). By default refuses every symlink, opens with O_NOFOLLOW where available, and cross-checks the opened descriptor against the path (fstat vs lstat) to detect swaps in the race window. With follow_symlinks=True, symlinks are allowed only if the resolved target stays inside base.

Safe extraction — pathward.archive

Function Description
safe_extract_zip(src, dest, *, max_members=None, max_total_size=None) Extract a zip, rejecting Zip Slip member names and symlink members.
safe_extract_tar(src, dest, *, max_members=None, max_total_size=None) Extract a tar, additionally rejecting hardlinks and device nodes. Metadata (modes, owners) is deliberately not preserved.
safe_extract(src, dest, ...) Auto-detects zip vs tar.

All extraction functions validate before writing anything for size/count limits, validate each member name lexically, and return the list of extracted file paths.

Errors — pathward.errors

PathwardError
├── PathTraversalError (also a ValueError)   — lexical escape attempt
├── SymlinkEscapeError                       — symlink leads outside base
├── UnsafeArchiveError                       — Zip Slip / link member / bomb
└── FileChangedError                         — file swapped mid-operation (TOCTOU)

Catch PathwardError at trust boundaries to handle all of them uniformly.

Threat model and guarantees

Threat Defense
../../etc/passwd in user input safe_join rejects lexically, before any filesystem access.
Absolute / drive / UNC paths (/etc/x, C:\x, \\srv\share) Rejected on every platform, regardless of where the code runs.
Separator smuggling (..\ on Linux) Both / and \ are always treated as separators.
Null-byte truncation Rejected.
Symlink planted inside the tree pointing outside (File Browser-style) resolve_within / safe_open detect post-resolution escape; safe_open refuses symlinks entirely by default.
Zip Slip (../ in archive member names) Every member name is validated with safe_join before extraction.
Symlink / hardlink / device archive members Rejected.
Decompression bombs Opt-in max_members / max_total_size limits, checked up front.
TOCTOU file swap at open time O_NOFOLLOW on POSIX plus post-open fstat/lstat identity check.

Honest limitations: on Windows there is no O_NOFOLLOW, so safe_open relies on the pre-check plus the post-open descriptor identity check (best effort). Intermediate-directory races on POSIX are narrowed but not fully eliminated without openat2-style APIs. Treat pathward as a strong layer of defense, not a substitute for OS-level sandboxing.

Development

pip install -e ".[dev]"
pytest -v

CI runs the full suite on Ubuntu, Windows and macOS across Python 3.9, 3.10, 3.11, 3.12 and 3.13.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pathward-0.1.0.tar.gz (13.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pathward-0.1.0-py3-none-any.whl (10.2 kB view details)

Uploaded Python 3

File details

Details for the file pathward-0.1.0.tar.gz.

File metadata

  • Download URL: pathward-0.1.0.tar.gz
  • Upload date:
  • Size: 13.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pathward-0.1.0.tar.gz
Algorithm Hash digest
SHA256 b4c566b89351ce799e76393129b0c02affa0ec4e1ff2cf2674ed36820a96c9ff
MD5 5b6a31f2be169b6d5e7144ed729f4dbd
BLAKE2b-256 84a3e1c69e0399fd6886c73cf950a751fbaf8ebca4a474afbf89b6b3b3fd03cc

See more details on using hashes here.

Provenance

The following attestation bundles were made for pathward-0.1.0.tar.gz:

Publisher: release.yml on m2hcz/pathward

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pathward-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: pathward-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pathward-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 258bfb88daaad0422c54b23a8d01fc1844772c028c4158eec8133fa793304cc2
MD5 d74c23a6b19299432a768978a1f232dc
BLAKE2b-256 0cfca5a1694e7fdad548789bdef062a5276fa6661dfd005861bc57987d73d845

See more details on using hashes here.

Provenance

The following attestation bundles were made for pathward-0.1.0-py3-none-any.whl:

Publisher: release.yml on m2hcz/pathward

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page