Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
[package]
name = "path_jail_py"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Python bindings for path_jail - secure filesystem sandbox"
repository = "https://github.com/tenuo-ai/path-jail-python"
homepage = "https://tenuo.dev"
homepage = "https://tenuo.ai"
[lib]
name = "path_jail"
crate-type = ["cdylib"]

[dependencies]
path_jail = "0.2"
path_jail = "0.5"
pyo3 = { version = "0.24", features = ["extension-module", "abi3-py39"] }

[package.metadata]
authors = ["Niki A. <dev@tenuo.dev>"]
authors = ["Niki A. <dev@tenuo.ai>"]
59 changes: 42 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ 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
jail.join("../../etc/passwd") # Path traversal
jail.join("/etc/passwd") # Absolute path
```

## Why path-jail?
Expand Down Expand Up @@ -132,6 +132,10 @@ try:
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}")
Expand All @@ -141,9 +145,12 @@ Creating a jail can also fail:

```python
try:
jail = Jail("/nonexistent")
jail = Jail("/var/uploads")
except OSError as e:
# Root doesn't exist or isn't a directory
# 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}")
```

Expand All @@ -156,17 +163,18 @@ 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)
```
Expand All @@ -176,19 +184,22 @@ def save_upload(user_id: str, filename: str, data: bytes) -> str:
### FastAPI

```python
from fastapi import FastAPI, UploadFile, HTTPException
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}
Expand All @@ -204,13 +215,14 @@ 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}
Expand All @@ -226,12 +238,13 @@ 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():
Expand Down Expand Up @@ -274,21 +287,32 @@ path-jail validates paths at call time. A symlink could be created between valid
```python
safe_path = jail.join("file.txt") # Validated
# Attacker creates symlink here
open(safe_path) # Escapes!
open(safe_path) # Escapes!
```

**Mitigations:**
- Use `O_NOFOLLOW` when opening files
- Open with `O_NOFOLLOW` so a symlink swapped in at the final component is
refused (intermediate directories are still unprotected):
```python
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.

```python
# 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!
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.
Expand Down Expand Up @@ -323,8 +347,8 @@ jail.join("SECRET.TXT") # Not in blocklist, but same file!
Windows silently strips trailing dots and spaces from filenames:

```python
jail.join("file.txt.") # Becomes "file.txt"
jail.join("file.txt ") # Becomes "file.txt"
jail.join("file.txt.") # Becomes "file.txt"
jail.join("file.txt ") # Becomes "file.txt"

# Could bypass extension checks:
if not filename.endswith(".exe"):
Expand Down Expand Up @@ -374,6 +398,7 @@ When comparing paths, always canonicalize your expected values:

```python
import os

assert result == os.path.realpath("/var/uploads/file.txt")
```

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "path-jail"
version = "0.2.0"
version = "0.3.0"
description = "Secure filesystem sandbox. Restricts paths to a root directory, preventing traversal attacks."
readme = "README.md"
license = { text = "MIT OR Apache-2.0" }
Expand All @@ -27,7 +27,7 @@ classifiers = [
]

[project.urls]
Homepage = "https://tenuo.dev"
Homepage = "https://tenuo.ai"
Repository = "https://github.com/tenuo-ai/path-jail-python"
Documentation = "https://github.com/tenuo-ai/path-jail-python#readme"

Expand Down
4 changes: 3 additions & 1 deletion python/path_jail/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
from .path_jail import Jail, join

__all__ = ["Jail", "join"]
__version__ = "0.1.0"
from importlib.metadata import version as _version

__version__ = _version("path-jail")
11 changes: 8 additions & 3 deletions python/path_jail/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class Jail:
root: Path to the jail root directory (must exist)

Raises:
OSError: If root does not exist or is not a directory
ValueError: If root is a filesystem root or not a directory
OSError: If root does not exist or cannot be read
"""
...

Expand All @@ -50,6 +51,8 @@ class Jail:

Raises:
ValueError: If path would escape the jail or is absolute
OSError: If an existing path component cannot be inspected
(e.g. permission denied)
"""
...

Expand All @@ -65,6 +68,7 @@ class Jail:

Raises:
ValueError: If path is outside the jail or not absolute
OSError: If the path cannot be inspected
"""
...

Expand All @@ -79,6 +83,7 @@ class Jail:

Raises:
ValueError: If path is outside the jail
OSError: If the path cannot be inspected
"""
...

Expand All @@ -100,8 +105,8 @@ def join(root: _PathLike, path: _PathLike) -> str:
prefix for paths exceeding 250 characters.

Raises:
ValueError: If path would escape the jail
OSError: If root does not exist
ValueError: If path would escape the jail, or root is not a directory
OSError: If root does not exist, or a path component cannot be inspected

Note:
On Windows, returns standard paths (e.g. ``C:\\...``) when possible.
Expand Down
29 changes: 24 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![allow(clippy::useless_conversion)]

use ::path_jail::{Jail as RustJail, JailError};
use pyo3::exceptions::{PyIOError, PyTypeError, PyValueError};
use pyo3::exceptions::{PyOSError, PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyString;
use std::path::PathBuf;
Expand Down Expand Up @@ -89,6 +89,18 @@ fn extract_path(obj: &Bound<'_, PyAny>) -> PyResult<PathBuf> {
Err(PyTypeError::new_err("expected str or os.PathLike object"))
}

/// Convert an OS error without discarding its numeric error code.
fn io_error_to_py(err: std::io::Error) -> PyErr {
let message = err.to_string();
match err.raw_os_error() {
#[cfg(windows)]
Some(code) => PyOSError::new_err((0, message, Option::<String>::None, code)),
#[cfg(not(windows))]
Some(code) => PyOSError::new_err((code, message)),
None => err.into(),
}
}

/// Convert JailError to Python exception
fn to_py_err(err: JailError) -> PyErr {
match err {
Expand All @@ -104,7 +116,9 @@ fn to_py_err(err: JailError) -> PyErr {
JailError::InvalidPath(reason) => {
PyValueError::new_err(format!("invalid path: {}", reason))
}
JailError::Io(err) => PyIOError::new_err(err.to_string()),
// Filesystem root or non-directory. (A missing root surfaces as Io.)
err @ JailError::InvalidRoot { .. } => PyValueError::new_err(err.to_string()),
JailError::Io(err) => io_error_to_py(err),
// Handle future error variants from path_jail crate
_ => PyValueError::new_err(format!("path_jail error: {}", err)),
}
Expand All @@ -129,7 +143,8 @@ impl Jail {
/// root: Path to the jail root directory (must exist)
///
/// Raises:
/// IOError: If root does not exist or is not a directory
/// ValueError: If root is a filesystem root or not a directory
/// OSError: If root does not exist or cannot be read
#[new]
fn new(root: &Bound<'_, PyAny>) -> PyResult<Self> {
let path = extract_path(root)?;
Expand All @@ -153,6 +168,8 @@ impl Jail {
///
/// Raises:
/// ValueError: If path would escape the jail or is absolute
/// OSError: If an existing path component cannot be inspected
/// (e.g. permission denied)
fn join(&self, path: &Bound<'_, PyAny>) -> PyResult<String> {
let path = extract_path(path)?;
self.inner
Expand All @@ -172,6 +189,7 @@ impl Jail {
///
/// Raises:
/// ValueError: If path is outside the jail or not absolute
/// OSError: If the path cannot be inspected
fn contains(&self, path: &Bound<'_, PyAny>) -> PyResult<String> {
let path = extract_path(path)?;
self.inner
Expand All @@ -191,6 +209,7 @@ impl Jail {
///
/// Raises:
/// ValueError: If path is outside the jail
/// OSError: If the path cannot be inspected
fn relative(&self, path: &Bound<'_, PyAny>) -> PyResult<String> {
let path = extract_path(path)?;
self.inner
Expand Down Expand Up @@ -227,8 +246,8 @@ impl Jail {
/// Absolute path inside the jail
///
/// Raises:
/// ValueError: If path would escape the jail
/// IOError: If root does not exist
/// ValueError: If path would escape the jail, or root is not a directory
/// OSError: If root does not exist, or a path component cannot be inspected
///
/// Example:
/// >>> from path_jail import join
Expand Down
Loading
Loading