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.
Metadata
Release files for path-jail 0.2.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 | |
|---|---|---|---|
| path_jail-0.2.0.tar.gz | 22.3 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| path_jail-0.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | PyPy 3.10 | PyPy 3.10 7.3 | Linux glibc 2.17+ ARM64 | Details |
| path_jail-0.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | PyPy 3.9 | PyPy 3.9 7.3 | Linux glibc 2.17+ ARM64 | Details |
| path_jail-0.2.0-cp39-abi3-win_amd64.whl | CPython 3.9 | abi3 | Windows x86-64 | Details |
| path_jail-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| path_jail-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| path_jail-0.2.0-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| path_jail-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 1.8 MB
Release files / path_jail-0.2.0.tar.gz
| Download URL | path_jail-0.2.0.tar.gz |
|---|---|
| Size | 22.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9f5eb38e9810383a555dc69ce4ff23fa1ef6ec5df6a9a48f440b72982649a944
|
|
BLAKE2b-256 checksum How to use checksums |
0e453e00acbcbce36aef691b0d3cbc9716eb17579863bf3820a6285ba5dcd2f9
|
| 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.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | path_jail-0.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 281.3 kB |
| Tags | Linux glibc 2.17+ ARM64 PyPy 3.10 PyPy 3.10 7.3 |
|
SHA-256 checksum How to use checksums |
b72a9f60305632d4186483eda5f770acd94c3dd41a24fa1f3c7c9eb3d066f9af
|
|
BLAKE2b-256 checksum How to use checksums |
e60459f8d135c329ed13bd2fac20f591dc6c8db71106ca3ad48d2e09e3af6e94
|
| 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.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | path_jail-0.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 282.2 kB |
| Tags | Linux glibc 2.17+ ARM64 PyPy 3.9 PyPy 3.9 7.3 |
|
SHA-256 checksum How to use checksums |
1e82ae338e6bd693697f9dc8c9017f45bda7871200274b8c5de72411f99d8ecb
|
|
BLAKE2b-256 checksum How to use checksums |
a34410fbdca93e6183f44cb48143d2d665a676bd464f9617932f5870c1a7e080
|
| 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.2.0-cp39-abi3-win_amd64.whl
| Download URL | path_jail-0.2.0-cp39-abi3-win_amd64.whl |
|---|---|
| Size | 152.7 kB |
| Tags | CPython 3.9 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
a589716a89cf7e8f6cd04324fd51510bf0e642b56f2e607532b0f1b83e59ad37
|
|
BLAKE2b-256 checksum How to use checksums |
ccf657c10231ea4cc16226fcdf6ae9e0bc7b82fb6f521673f570ce01435566bc
|
| 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.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | path_jail-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 288.3 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
bec2cad5be34f9172d6a85515b7f08877d6b4307a4f8d88c897b3a933502a793
|
|
BLAKE2b-256 checksum How to use checksums |
75a43362189fa576e51c056a08d6aa8df41a708b5280781736c73f7b7c2e6158
|
| 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.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | path_jail-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 283.5 kB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
31759d38db12965e72768c91e05ef12b54c8540295e972ca6cb70e25df2aa1ff
|
|
BLAKE2b-256 checksum How to use checksums |
99e52c7fb91f58044509e4cbd8c18bc3d260ecd0d05a2148af4b4262ef7c815a
|
| 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.2.0-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | path_jail-0.2.0-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 253.2 kB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
b1435a50cb697c8a6afe8f1ee133ac5dc2146517cf92af27d6917e76e79bf0a8
|
|
BLAKE2b-256 checksum How to use checksums |
1e163f949e8d06fe816a2ba9a88d8a0284c9ecc5d18c262523cef92bb504c34e
|
| 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.2.0-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | path_jail-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 257.5 kB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
c2b89548c828a56a5a14fdbc31d3be7699b822f08ec0bc15a80b921427c41529
|
|
BLAKE2b-256 checksum How to use checksums |
e3c6e8dddcbceedecd932f1ff701f61fbd6f040b03777fd0724182a25af5c58f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|