Secure filesystem sandbox. Restricts paths to a root directory, preventing traversal attacks.
Project description
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 TypeError as e:
# Invalid type (not str or PathLike)
print(f"Bad input: {e}")
Creating a jail can also fail:
try:
jail = Jail("/nonexistent")
except OSError as e:
# Root doesn't exist or isn't a directory
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 fastapi import FastAPI, UploadFile, HTTPException
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:
- Use
O_NOFOLLOWwhen opening files - Use container/chroot isolation for strong guarantees
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.
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 Distributions
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 path_jail-0.2.0.tar.gz.
File metadata
- Download URL: path_jail-0.2.0.tar.gz
- Upload date:
- Size: 22.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f5eb38e9810383a555dc69ce4ff23fa1ef6ec5df6a9a48f440b72982649a944
|
|
| MD5 |
49107756b0c8c07dbc6f5e7243630d17
|
|
| BLAKE2b-256 |
0e453e00acbcbce36aef691b0d3cbc9716eb17579863bf3820a6285ba5dcd2f9
|
File details
Details for the file path_jail-0.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: path_jail-0.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 281.3 kB
- Tags: PyPy, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b72a9f60305632d4186483eda5f770acd94c3dd41a24fa1f3c7c9eb3d066f9af
|
|
| MD5 |
946861069ec486a8b480e9f502547cd2
|
|
| BLAKE2b-256 |
e60459f8d135c329ed13bd2fac20f591dc6c8db71106ca3ad48d2e09e3af6e94
|
File details
Details for the file path_jail-0.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: path_jail-0.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 282.2 kB
- Tags: PyPy, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1e82ae338e6bd693697f9dc8c9017f45bda7871200274b8c5de72411f99d8ecb
|
|
| MD5 |
b4c43d0ac521eddaa15f333f34bc6f2e
|
|
| BLAKE2b-256 |
a34410fbdca93e6183f44cb48143d2d665a676bd464f9617932f5870c1a7e080
|
File details
Details for the file path_jail-0.2.0-cp39-abi3-win_amd64.whl.
File metadata
- Download URL: path_jail-0.2.0-cp39-abi3-win_amd64.whl
- Upload date:
- Size: 152.7 kB
- Tags: CPython 3.9+, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a589716a89cf7e8f6cd04324fd51510bf0e642b56f2e607532b0f1b83e59ad37
|
|
| MD5 |
ced593268855828eaa3bd693e886f5c5
|
|
| BLAKE2b-256 |
ccf657c10231ea4cc16226fcdf6ae9e0bc7b82fb6f521673f570ce01435566bc
|
File details
Details for the file path_jail-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: path_jail-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 288.3 kB
- Tags: CPython 3.9+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bec2cad5be34f9172d6a85515b7f08877d6b4307a4f8d88c897b3a933502a793
|
|
| MD5 |
cdf23bdadccd716691e14ea78541e351
|
|
| BLAKE2b-256 |
75a43362189fa576e51c056a08d6aa8df41a708b5280781736c73f7b7c2e6158
|
File details
Details for the file path_jail-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: path_jail-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 283.5 kB
- Tags: CPython 3.9+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
31759d38db12965e72768c91e05ef12b54c8540295e972ca6cb70e25df2aa1ff
|
|
| MD5 |
6685820f2aa3065d8601a9697bc2933a
|
|
| BLAKE2b-256 |
99e52c7fb91f58044509e4cbd8c18bc3d260ecd0d05a2148af4b4262ef7c815a
|
File details
Details for the file path_jail-0.2.0-cp39-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: path_jail-0.2.0-cp39-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 253.2 kB
- Tags: CPython 3.9+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b1435a50cb697c8a6afe8f1ee133ac5dc2146517cf92af27d6917e76e79bf0a8
|
|
| MD5 |
ab05e5700f8c963b88a056ee30207fea
|
|
| BLAKE2b-256 |
1e163f949e8d06fe816a2ba9a88d8a0284c9ecc5d18c262523cef92bb504c34e
|
File details
Details for the file path_jail-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: path_jail-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 257.5 kB
- Tags: CPython 3.9+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c2b89548c828a56a5a14fdbc31d3be7699b822f08ec0bc15a80b921427c41529
|
|
| MD5 |
85e56d536dd16448bf320bd76bb08898
|
|
| BLAKE2b-256 |
e3c6e8dddcbceedecd932f1ff701f61fbd6f040b03777fd0724182a25af5c58f
|