Skip to main content

Secure filesystem sandbox. Restricts paths to a root directory, preventing traversal attacks.

Project description

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.

Project details


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.

Supported by

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