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 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 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_NOFOLLOW so 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 guard feature 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/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.

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)

Source distribution for path-jail 0.3.0
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)

Table of built distributions (wheels) for path-jail 0.3.0
File Actions
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.

Release history Release notifications | RSS feed

This release

0.3.0 This release

6 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page