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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b4c566b89351ce799e76393129b0c02affa0ec4e1ff2cf2674ed36820a96c9ff
|
|
| MD5 |
5b6a31f2be169b6d5e7144ed729f4dbd
|
|
| BLAKE2b-256 |
84a3e1c69e0399fd6886c73cf950a751fbaf8ebca4a474afbf89b6b3b3fd03cc
|
Provenance
The following attestation bundles were made for pathward-0.1.0.tar.gz:
Publisher:
release.yml on m2hcz/pathward
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pathward-0.1.0.tar.gz -
Subject digest:
b4c566b89351ce799e76393129b0c02affa0ec4e1ff2cf2674ed36820a96c9ff - Sigstore transparency entry: 1796347339
- Sigstore integration time:
-
Permalink:
m2hcz/pathward@180ceeb7c371d0669ee5922e484e116c27c066f5 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/m2hcz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@180ceeb7c371d0669ee5922e484e116c27c066f5 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
258bfb88daaad0422c54b23a8d01fc1844772c028c4158eec8133fa793304cc2
|
|
| MD5 |
d74c23a6b19299432a768978a1f232dc
|
|
| BLAKE2b-256 |
0cfca5a1694e7fdad548789bdef062a5276fa6661dfd005861bc57987d73d845
|
Provenance
The following attestation bundles were made for pathward-0.1.0-py3-none-any.whl:
Publisher:
release.yml on m2hcz/pathward
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pathward-0.1.0-py3-none-any.whl -
Subject digest:
258bfb88daaad0422c54b23a8d01fc1844772c028c4158eec8133fa793304cc2 - Sigstore transparency entry: 1796347549
- Sigstore integration time:
-
Permalink:
m2hcz/pathward@180ceeb7c371d0669ee5922e484e116c27c066f5 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/m2hcz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@180ceeb7c371d0669ee5922e484e116c27c066f5 -
Trigger Event:
release
-
Statement type: