Skip to main content

path-jail

CI PyPI Python License

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_NOFOLLOW when 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/root is a symlink to filesystem root
  • /dev - /dev/fd/N are 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:

at your option.

Download files

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

Source Distribution

path_jail-0.2.0.tar.gz (22.3 kB view details)

Uploaded Source

Built Distributions

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

path_jail-0.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (281.3 kB view details)

Uploaded PyPymanylinux: glibc 2.17+ ARM64

path_jail-0.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (282.2 kB view details)

Uploaded PyPymanylinux: glibc 2.17+ ARM64

path_jail-0.2.0-cp39-abi3-win_amd64.whl (152.7 kB view details)

Uploaded CPython 3.9+Windows x86-64

path_jail-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (288.3 kB view details)

Uploaded CPython 3.9+manylinux: glibc 2.17+ x86-64

path_jail-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (283.5 kB view details)

Uploaded CPython 3.9+manylinux: glibc 2.17+ ARM64

path_jail-0.2.0-cp39-abi3-macosx_11_0_arm64.whl (253.2 kB view details)

Uploaded CPython 3.9+macOS 11.0+ ARM64

path_jail-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl (257.5 kB view details)

Uploaded CPython 3.9+macOS 10.12+ x86-64

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

Hashes for path_jail-0.2.0.tar.gz
Algorithm Hash digest
SHA256 9f5eb38e9810383a555dc69ce4ff23fa1ef6ec5df6a9a48f440b72982649a944
MD5 49107756b0c8c07dbc6f5e7243630d17
BLAKE2b-256 0e453e00acbcbce36aef691b0d3cbc9716eb17579863bf3820a6285ba5dcd2f9

See more details on using hashes here.

File details

Details for the file path_jail-0.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for path_jail-0.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 b72a9f60305632d4186483eda5f770acd94c3dd41a24fa1f3c7c9eb3d066f9af
MD5 946861069ec486a8b480e9f502547cd2
BLAKE2b-256 e60459f8d135c329ed13bd2fac20f591dc6c8db71106ca3ad48d2e09e3af6e94

See more details on using hashes here.

File details

Details for the file path_jail-0.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for path_jail-0.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 1e82ae338e6bd693697f9dc8c9017f45bda7871200274b8c5de72411f99d8ecb
MD5 b4c43d0ac521eddaa15f333f34bc6f2e
BLAKE2b-256 a34410fbdca93e6183f44cb48143d2d665a676bd464f9617932f5870c1a7e080

See more details on using hashes here.

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

Hashes for path_jail-0.2.0-cp39-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 a589716a89cf7e8f6cd04324fd51510bf0e642b56f2e607532b0f1b83e59ad37
MD5 ced593268855828eaa3bd693e886f5c5
BLAKE2b-256 ccf657c10231ea4cc16226fcdf6ae9e0bc7b82fb6f521673f570ce01435566bc

See more details on using hashes here.

File details

Details for the file path_jail-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for path_jail-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 bec2cad5be34f9172d6a85515b7f08877d6b4307a4f8d88c897b3a933502a793
MD5 cdf23bdadccd716691e14ea78541e351
BLAKE2b-256 75a43362189fa576e51c056a08d6aa8df41a708b5280781736c73f7b7c2e6158

See more details on using hashes here.

File details

Details for the file path_jail-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for path_jail-0.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 31759d38db12965e72768c91e05ef12b54c8540295e972ca6cb70e25df2aa1ff
MD5 6685820f2aa3065d8601a9697bc2933a
BLAKE2b-256 99e52c7fb91f58044509e4cbd8c18bc3d260ecd0d05a2148af4b4262ef7c815a

See more details on using hashes here.

File details

Details for the file path_jail-0.2.0-cp39-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for path_jail-0.2.0-cp39-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 b1435a50cb697c8a6afe8f1ee133ac5dc2146517cf92af27d6917e76e79bf0a8
MD5 ab05e5700f8c963b88a056ee30207fea
BLAKE2b-256 1e163f949e8d06fe816a2ba9a88d8a0284c9ecc5d18c262523cef92bb504c34e

See more details on using hashes here.

File details

Details for the file path_jail-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for path_jail-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 c2b89548c828a56a5a14fdbc31d3be7699b822f08ec0bc15a80b921427c41529
MD5 85e56d536dd16448bf320bd76bb08898
BLAKE2b-256 e3c6e8dddcbceedecd932f1ff701f61fbd6f040b03777fd0724182a25af5c58f

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.2.0 This release

8 files

Supported by

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