path-jail
A secure filesystem sandbox for Python. Restricts paths to a root directory, preventing traversal attacks while supporting files that don't exist yet.
Built with Rust via PyO3 for native performance. Python bindings for path_jail.
Installation
pip install path-jail
Quick Start
from path_jail import Jail, join
# One-shot validation
safe_path = join("/var/uploads", "user/report.pdf")
# Reusable jail (better for multiple paths)
jail = Jail("/var/uploads")
path1 = jail.join("2025/report.pdf")
path2 = jail.join("data.csv")
# These raise ValueError:
jail.join("../../etc/passwd") # Path traversal
jail.join("/etc/passwd") # Absolute path
Why path-jail?
Python's standard library is treacherous for path sandboxing:
| Function | Problem |
|---|---|
os.path.abspath() |
Lexical only. Does not touch disk. ../../etc/passwd becomes /etc/passwd. |
os.path.realpath() |
Resolves symlinks but does not jail. You must manually check startswith(). |
pathlib.Path.resolve() |
Same as realpath(). No sandboxing. |
path-jail handles the edge cases:
- Resolves
..safely (no escape) - Follows symlinks and verifies they stay inside the jail
- Rejects broken symlinks (cannot verify target)
- Works with non-existent paths (for creating new files)
API
join(root, path) -> str
One-shot validation. Creates a jail and joins a path in one call.
from path_jail import join
safe = join("/var/uploads", "user/file.txt")
# Returns: "/var/uploads/user/file.txt"
Jail(root)
Create a reusable jail rooted at root (must exist).
from path_jail import Jail
jail = Jail("/var/uploads")
print(jail.root) # Canonicalized root path
Jail.join(path) -> str
Join a relative path to the jail root. Returns the absolute path.
safe = jail.join("subdir/file.txt")
Jail.contains(path) -> str
Verify an existing absolute path is inside the jail.
canonical = jail.contains("/var/uploads/file.txt")
Jail.relative(path) -> str
Get the relative path from an absolute path inside the jail.
rel = jail.relative("/var/uploads/2025/report.pdf")
# Returns: "2025/report.pdf"
pathlib Support
All methods accept str or os.PathLike (including pathlib.Path):
from pathlib import Path
from path_jail import Jail
jail = Jail(Path("/var/uploads"))
safe = jail.join(Path("user") / "file.txt")
Type Hints
path-jail is fully typed. Your IDE will provide autocompletion and type checking:
# mypy and pyright will catch this:
jail.join(123) # error: Argument 1 has incompatible type "int"
Error Handling
from path_jail import Jail
jail = Jail("/var/uploads")
try:
safe_path = jail.join(user_input)
except ValueError as e:
# Path escapes jail, broken symlink, or invalid path
print(f"Rejected: {e}")
except OSError as e:
# An existing component couldn't be inspected (e.g. permission denied).
# Fail closed: treat it as a rejection, not as a missing file.
print(f"Cannot verify: {e}")
except TypeError as e:
# Invalid type (not str or PathLike)
print(f"Bad input: {e}")
Creating a jail can also fail:
try:
jail = Jail("/var/uploads")
except OSError as e:
# Root doesn't exist or can't be read
print(f"Invalid root: {e}")
except ValueError as e:
# Root is a file, or a filesystem root like "/"
print(f"Invalid root: {e}")
Example: File Uploads
from pathlib import Path
from path_jail import Jail
UPLOAD_DIR = "/var/uploads"
jail = Jail(UPLOAD_DIR)
def save_upload(user_id: str, filename: str, data: bytes) -> str:
"""Safely save an uploaded file."""
# Validate and build path
safe_path = jail.join(f"{user_id}/{filename}")
# Create parent directories
Path(safe_path).parent.mkdir(parents=True, exist_ok=True)
# Write file
Path(safe_path).write_bytes(data)
# Return relative path for database storage
return jail.relative(safe_path)
Framework Integration
FastAPI
from pathlib import Path
from fastapi import FastAPI, HTTPException, UploadFile
from path_jail import Jail
app = FastAPI()
uploads = Jail("/var/uploads")
@app.post("/upload/{filename:path}")
async def upload(filename: str, file: UploadFile):
try:
safe_path = uploads.join(filename)
except ValueError:
raise HTTPException(400, "Invalid filename")
Path(safe_path).parent.mkdir(parents=True, exist_ok=True)
Path(safe_path).write_bytes(await file.read())
return {"path": filename}
Flask
from pathlib import Path
from flask import Flask, request, abort
from path_jail import Jail
app = Flask(__name__)
uploads = Jail("/var/uploads")
@app.route("/upload/<path:filename>", methods=["POST"])
def upload(filename):
try:
safe_path = uploads.join(filename)
except ValueError:
abort(400, "Invalid filename")
Path(safe_path).parent.mkdir(parents=True, exist_ok=True)
request.files["file"].save(safe_path)
return {"path": filename}
Django
from pathlib import Path
from django.conf import settings
from django.http import JsonResponse, HttpResponseBadRequest
from path_jail import Jail
uploads = Jail(settings.MEDIA_ROOT)
def upload(request, filename):
try:
safe_path = uploads.join(filename)
except ValueError:
return HttpResponseBadRequest("Invalid filename")
Path(safe_path).parent.mkdir(parents=True, exist_ok=True)
with open(safe_path, "wb") as f:
for chunk in request.FILES["file"].chunks():
f.write(chunk)
return JsonResponse({"path": filename})
Security Considerations
path-jail provides strong protection against path traversal attacks, but there are edge cases to be aware of:
What path-jail Protects Against
- Path traversal (
../,..\\) - Blocked - Symlink escapes - Symlinks pointing outside the jail are rejected
- Broken symlinks - Rejected (cannot verify target)
- Absolute paths - Rejected in
join() - Null bytes - Rejected (prevents C-library truncation attacks)
Known Limitations
Hard Links
Hard links cannot be detected by path inspection. If an attacker has shell access and creates a hard link to a sensitive file inside your jail directory, path-jail will allow access to it.
# Attacker with shell access:
ln /etc/passwd /var/uploads/innocent.txt
Mitigations:
- Use a separate partition for the jail (hard links cannot cross partitions)
- Don't give untrusted users shell access
- Use container isolation
TOCTOU Race Conditions
path-jail validates paths at call time. A symlink could be created between validation and use.
safe_path = jail.join("file.txt") # Validated
# Attacker creates symlink here
open(safe_path) # Escapes!
Mitigations:
- Open with
O_NOFOLLOWso a symlink swapped in at the final component is refused (intermediate directories are still unprotected):import os fd = os.open(safe_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644) with os.fdopen(fd, "wb") as f: f.write(data)
- Use container/chroot isolation for strong guarantees
- The Rust crate's
guardfeature makes validate-and-open a single kernel-checked step on Linux x86_64/aarch64 with kernel 5.6+; it is not exposed in these bindings yet.
Windows Reserved Device Names
On Windows, filenames like CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9 are special device names. For paths under 250 characters, we strip the \\?\ prefix for usability, which re-enables this legacy behavior.
# If an attacker uploads "CON.txt":
safe_path = jail.join("CON.txt") # Returns "C:\uploads\CON.txt"
open(safe_path) # Opens console device, not file!
Impact: Denial of Service (thread hangs or data vanishes). Not a filesystem escape.
Mitigations:
- Validate filenames against a blocklist before calling path-jail
- Use UUIDs for stored filenames instead of user-provided names
Unicode Normalization (macOS)
macOS automatically converts filenames to NFD (decomposed) form. A file saved as cafe.txt (with composed e) may be stored as cafe.txt (with decomposed e + combining accent).
Impact: Not a security issue, but may cause "file not found" errors if comparing filenames byte-for-byte. Python's os.path handles this transparently for most cases.
Case Sensitivity (Windows/macOS)
Windows and macOS (by default) have case-insensitive filesystems:
jail = Jail("/var/uploads")
jail.join("FILE.txt") # Points to same file as "file.txt"
# Attacker could bypass naive blocklists:
blocklist = ["secret.txt"]
jail.join("SECRET.TXT") # Not in blocklist, but same file!
Mitigation: Normalize case (e.g., filename.lower()) before blocklist checks.
Trailing Dots and Spaces (Windows)
Windows silently strips trailing dots and spaces from filenames:
jail.join("file.txt.") # Becomes "file.txt"
jail.join("file.txt ") # Becomes "file.txt"
# Could bypass extension checks:
if not filename.endswith(".exe"):
jail.join("malware.exe.") # Passes check, becomes .exe!
Mitigation: Strip trailing dots/spaces before validation.
Alternate Data Streams (Windows NTFS)
NTFS supports alternate data streams that hide data from directory listings:
jail.join("file.txt:hidden") # Creates alternate stream
Impact: Not an escape, but can hide data. Consider rejecting filenames containing :.
Special Filesystems (Linux)
Avoid using path-jail with special filesystem roots:
/proc-/proc/self/rootis a symlink to filesystem root/dev-/dev/fd/Nare symlinks to open file descriptors
These are unlikely scenarios but worth noting for completeness.
Path Encoding
Returned paths are converted to Python strings using lossy UTF-8 conversion. On rare filesystems with non-UTF8 filenames, invalid bytes are replaced with � (U+FFFD). This affects only the returned string; the security check uses the original bytes.
Path Canonicalization
All returned paths are canonicalized (symlinks resolved, .. eliminated). This is essential for security but may surprise you:
# macOS: /var is a symlink to /private/var
jail = Jail("/var/uploads")
print(jail.root) # "/private/var/uploads"
# Windows: Long paths (>250 chars) keep the \\?\ prefix
jail = Jail("C:\\data")
print(jail.join("a" * 300)) # "\\?\C:\data\aaa..."
When comparing paths, always canonicalize your expected values:
import os
assert result == os.path.realpath("/var/uploads/file.txt")
Performance
path-jail crosses the Python/Rust boundary once per call. The tight syscall loop runs at native speed, making it significantly faster than equivalent pure-Python implementations for deep paths.
Thread Safety
Jail instances are thread-safe and can be shared across threads without locks.
Development
git clone https://github.com/tenuo-ai/path-jail-python.git
cd path-jail-python
pip install maturin pytest ruff mypy
maturin develop
pytest
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Metadata
Release files for path-jail 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| path_jail-0.3.0.tar.gz | 23.3 kB | Without provenance | Details for path_jail-0.3.0.tar.gz |
Built distributions (wheels)
| File | Actions Reset | |||
|---|---|---|---|---|
| path_jail-0.3.0-cp39-abi3-win_amd64.whl | CPython 3.9 | abi3 | Windows x86-64 | Details for path_jail-0.3.0-cp39-abi3-win_amd64.whl |
| path_jail-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details for path_jail-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
| path_jail-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details for path_jail-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
| path_jail-0.3.0-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details for path_jail-0.3.0-cp39-abi3-macosx_11_0_arm64.whl |
| path_jail-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details for path_jail-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl |
Total release size: 1.3 MB
Release files / path_jail-0.3.0.tar.gz
| Download URL | path_jail-0.3.0.tar.gz |
|---|---|
| Size | 23.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
be624ef41b1c6f2dcd3488b53b78a35f39e6efdf9b58d89b7b744c3ce3710aeb
|
|
BLAKE2b-256 checksum How to use checksums |
81eb6fb0489c78697054f5b0b3d40a63972d516552fcb5c398beb3ad802c446d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Release files / path_jail-0.3.0-cp39-abi3-win_amd64.whl
| Download URL | path_jail-0.3.0-cp39-abi3-win_amd64.whl |
|---|---|
| Size | 162.3 kB |
| Tags | CPython 3.9 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
df7caadba102a7d71016b30221f7823e9f26cdee88e241139d1c921c1d10214b
|
|
BLAKE2b-256 checksum How to use checksums |
9324be210da61b9c0fd360dc163efa40b001c9febf4850b6f3675a74ff378751
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Release files / path_jail-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | path_jail-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 286.6 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
84e8fe0a25c93001b630ca731f625430cb81a3806f5dc0e09ec41aff1ce543fb
|
|
BLAKE2b-256 checksum How to use checksums |
98552852c67a39ec994d941a9eb1747685282e130388a70c7acfb0a80e5a2c64
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Release files / path_jail-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | path_jail-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 284.5 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
3daa25092738c7f9f47408f6420527cde5f235867905943b7df9b55f8193d33e
|
|
BLAKE2b-256 checksum How to use checksums |
a05730be4d022c881824f9553a610d44641dc29c96d4483fadf33c07d89baad5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Release files / path_jail-0.3.0-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | path_jail-0.3.0-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 253.5 kB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
6a49fd84c9aebcbba1273dac0db2065a57884aaf9b93f5273f09e7cf0d54bb43
|
|
BLAKE2b-256 checksum How to use checksums |
bcb3ea0f0bd9d8d04343c6960dccedd8166fde88149297d3295300663177b6fb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Release files / path_jail-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | path_jail-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 256.2 kB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
2298936401599e27f3abb445f5a99d45562336c4331ca88307defbf7ea8c23d1
|
|
BLAKE2b-256 checksum How to use checksums |
fb709b14ff69ada0b48e99aa0c9d1571b8edf2ce417231b002a421d3bbbbb02a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Security for path-jail 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.