Designing a Safe Path‑Joining Helper with Python's pathlib
A concise architecture note showing how to build a minimal `safe_join` function that prevents directory‑traversal attacks, defines trust boundaries, and includes operational checks for a backend service.
04 Mar 2026, 16:12 UTC

Requirements
The service must accept arbitrary string components from users and safely combine them with a configured base directory. The resulting path must never escape the base, work identically on POSIX and Windows, and avoid manual string concatenation that can introduce subtle bugs.
Smallest Suitable Design
Expose a pure helper function that:
- Joins the trusted
base(apathlib.Path) with untrustedpartsusingPath / part. - Calls
resolve()on the joined path to obtain an absolute, symlink‑followed representation. - Verifies the resolved path is inside the resolved base using
Path.is_relative_to()(available from Python 3.9). - Returns the resolved
Pathif the check passes; otherwise raisesValueError.
from pathlib import Path
def safe_join(base: Path, *parts: str) -> Path:
"""Return a resolved path inside base or raise ValueError.
Parameters
----------
base: Trusted base directory (must exist or be creatable).
*parts: Untrusted string components supplied by callers.
"""
if not base.is_absolute():
raise ValueError("base must be an absolute path")
base_resolved = base.resolve()
target = base_resolved
for part in parts:
target = target / part
target_resolved = target.resolve()
if not target_resolved.is_relative_to(base_resolved):
raise ValueError(f"Attempted path traversal: {target_resolved}")
return target_resolved
Trust and Data Boundaries
The base argument is considered trusted because it is set at service startup from a configuration file or environment variable that only privileged administrators can modify. All parts arguments are treated as untrusted user input. By resolving both the base and the candidate path and then checking the is_relative_to relation, the function establishes a clear boundary: any path that would escape the trusted base is rejected before it can be used for file operations.
Operational Checks
To observe and react to blocked attempts in production, add the following instrumentation around the helper:
- Logging: Emit a warning log whenever
ValueErroris raised, including the offendingpartsand the resolved base. - Metrics: Increment a counter (e.g.,
path_traversal_blocked_total) for each blocked join attempt. - Unit Tests: Verify the boundary with a test suite that covers:
- Normal relative components (
"subdir", "file.txt"). - Traversal attempts (
"..","../etc/passwd"). - Absolute parts that try to replace the base.
- Empty strings and components containing only separators.
- Symlink scenarios (see limitations).
Example Unit Test (pytest)
import pytest
from pathlib import Path
def test_safe_join_blocks_traversal(tmp_path: Path):
base = tmp_path / "data"
base.mkdir()
# Allowed join
assert safe_join(base, "sub", "file.txt") == (base / "sub" / "file.txt").resolve()
# Blocked traversal
with pytest.raises(ValueError):
safe_join(base, "..", "outside.txt")
# Absolute part should be ignored relative to base
with pytest.raises(ValueError):
safe_join(base, "/etc/passwd")
Run the test suite in the project’s CI pipeline:
# In the repository root, assuming pytest is installed
pytest -q tests/test_safe_join.py
Failure Modes and Design Change Triggers
- Missing
Path.is_relative_to()(Python < 3.9): Replace the check with a manual prefix comparison:str(target_resolved).startswith(str(base_resolved) + sep)wheresepis the OS‑specific separator. This reintroduces the need to handle trailing separators carefully. - Intentional symlink following: If the service later requires dereferencing symlinks that may point outside the base (e.g., for plugin loading), the current design would block those accesses. The trust boundary would need to be revisited, possibly by separating the base into a
readonlyzone and afollow_symlinkszone, or by adding an explicit symlink whitelist. - Runtime‑changeable base from untrusted source: Should the base become configurable via an API endpoint accessible to regular users, the trusted/untrusted split collapses. The function would then need to re‑validate the base on each change, or the service must enforce that only privileged actors can update the base.
Limitations and Practical Verification
The helper follows symlinks via resolve(). If an attacker can create a symlink inside the base that points to a location outside the base, the resolved path will escape and be blocked—but the symlink itself remains present, which might be undesirable in some threat models. To mitigate:
- Ensure the directory containing user‑supplied components is not writable by untrusted users, or
- Add an explicit symlink check before resolution:
if any(part.is_symlink() for part in target.parents): raise ValueError.
To confirm the helper behaves as expected in a test environment:
- Create a base directory.
- Inside it, create a symlink
bad_link -> /tmp. - Call
safe_join(base, "bad_link", "somefile"). - Verify that a
ValueErroris raised (or that the logged warning/metric appears). - Check that no file outside the base can be opened using the returned path.
This practical check validates both the traversal prevention and the symlink‑following behavior without requiring production deployment.
When the Design Would Change
If any of the following conditions become true, revisit the design:
- The service must support user‑provided symlinks that are allowed to point outside the base.
- The base directory is no longer static and can be altered by untrusted callers.
- The runtime environment lacks Python 3.9 and the team decides not to maintain a fallback implementation.
- Performance profiling shows that
resolve()is a bottleneck and a lighter‑weight check (e.g., usingos.path.commonpath) is proven safe for the specific threat model.
Each scenario shifts the trust boundary or the assumptions about the filesystem, necessitating a re‑evaluation of the helper’s implementation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.