How Path Traversal Happens in Python Flask Routes and How to Fix It
The xkeen-ui/routes/cores_status.py file manages core status information for the xkeen-ui application — reading and writing JSON data files as part of its normal operation. But a flaw at line 221, where user-controlled input flowed directly into Python's open() call without any boundary validation, created a path traversal vulnerability that could have allowed an attacker to read sensitive files anywhere on the server's filesystem.
This post breaks down exactly what went wrong, how it could be exploited, and what the fix looks like in practice.
The Vulnerability Explained
What Went Wrong at Line 221
Path traversal (CWE-22) is deceptively simple: when your code builds a file path using data that an attacker controls, and you don't verify that the resulting path stays inside an expected directory, the attacker can "traverse" up the directory tree using ../ sequences to reach files you never intended to expose.
In cores_status.py, the vulnerable pattern looked something like this:
# VULNERABLE — before the fix
with open(user_supplied_path, "r") as f:
data = json.load(f)
Here, user_supplied_path is derived from user-controlled input — a route parameter, query string, or request body value — and is handed directly to open(). Python's open() is perfectly happy to follow ../ sequences; it has no built-in concept of a "safe" directory boundary.
A Concrete Attack Against This Route
Imagine the application normally reads a status file at:
/opt/xkeen-ui/data/cores/status.json
An attacker targeting the publicly accessible route in cores_status.py could supply a crafted path value:
../../etc/passwd
Which Python would resolve to:
/opt/xkeen-ui/data/../../etc/passwd → /etc/passwd
The open() call succeeds, and the attacker receives the contents of /etc/passwd. More dangerously, they could target:
/etc/shadow— password hashes- Application configuration files containing database credentials or API keys
- Private keys at
~/.ssh/id_rsa - Any other file readable by the process user
Because the PR description notes this route appears to be publicly accessible, no authentication bypass is needed. The traversal primitive is directly reachable.
Why Symlinks Make It Worse
The new test suite specifically tests for symlink-based escapes:
def test_read_json_rejects_symlink_escaping_trusted_root(tmp_path):
outside = tmp_path.parent / "outside_secret.json"
outside.write_text(json.dumps({"secret": "da...
This matters because even if you check that a path string starts with the right prefix, a symlink inside the trusted directory can point outside it. A proper fix must resolve symlinks before comparing against the trusted root.
The Fix
Introducing _read_json and _write_json_atomic
The fix wraps all file I/O in two new helper functions — _read_json and _write_json_atomic — that enforce a trusted root check before any file is opened. Both functions accept the target path and a trusted root directory as arguments, resolve the canonical absolute path (following symlinks), and reject any path that doesn't fall within the trusted root.
Here's the before/after comparison:
Before (vulnerable):
# Direct open() with user-influenced path — no boundary check
with open(cache_path, "r") as f:
return json.load(f)
After (hardened):
def _read_json(path: str, trusted_root: str) -> dict:
resolved = Path(path).resolve()
root = Path(trusted_root).resolve()
if not str(resolved).startswith(str(root) + "/") and resolved != root:
raise ValueError(f"Path traversal detected: {resolved} is outside {root}")
with open(resolved, "r") as f:
return json.load(f)
def _write_json_atomic(path: str, data: dict, trusted_root: str) -> None:
resolved = Path(path).resolve()
root = Path(trusted_root).resolve()
if not str(resolved).startswith(str(root) + "/") and resolved != root:
raise ValueError(f"Path traversal detected: {resolved} is outside {root}")
# atomic write via temp file + rename
tmp = resolved.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(data, f)
tmp.rename(resolved)
Why Path.resolve() Is the Right Tool
Path.resolve() does two critical things:
1. Expands ../ sequences — so ../../etc/passwd becomes /etc/passwd, not a string that merely contains ../
2. Follows symlinks — so a symlink inside the trusted directory that points outside it is caught
Only after resolving the canonical path does the code compare it against the trusted root. This closes both the ../ traversal vector and the symlink escape vector.
The Atomic Write Bonus
_write_json_atomic also improves write safety by writing to a .tmp file and renaming it into place. This prevents partial writes from corrupting the JSON file if the process is interrupted — a nice defensive improvement that came along with the security fix.
Test Coverage
The PR includes a dedicated test file, tests/test_cores_status_path_hardening.py, with tests covering:
| Test | Purpose |
|---|---|
test_read_json_normal_path_succeeds |
Valid paths still work correctly |
test_write_json_atomic_normal_path_succeeds |
Valid writes still work correctly |
test_read_json_rejects_symlink_escaping_trusted_root |
Symlink escapes are blocked |
This test suite ensures the fix doesn't break legitimate use cases while confirming the attack vectors are closed.
Prevention & Best Practices
1. Always Validate Against a Canonical Base Path
The gold standard for path validation in Python:
from pathlib import Path
def safe_open(user_path: str, base_dir: str):
base = Path(base_dir).resolve()
target = (base / user_path).resolve()
if not str(target).startswith(str(base)):
raise ValueError("Path traversal attempt blocked")
return open(target, "r")
Note the use of base / user_path — joining the base with the user input before resolving, rather than resolving the user input independently.
2. Never Trust Path Strings Alone
Avoid these common but insufficient defenses:
# ❌ Insufficient — bypassed by URL encoding or double dots
if ".." in user_path:
raise ValueError("Invalid path")
# ❌ Insufficient — doesn't catch symlinks
if not user_path.startswith("/allowed/dir/"):
raise ValueError("Invalid path")
3. Apply the Principle of Least Privilege
Run your application process with the minimum filesystem permissions needed. If cores_status.py only needs to read files in /opt/xkeen-ui/data/, the process user should have no read access to /etc/ or home directories.
4. Use Static Analysis in CI
This vulnerability was detected by Semgrep's utils.custom.path-traversal-open rule. Add Semgrep to your CI pipeline to catch taint flows from user input to open() before they reach production:
# .github/workflows/semgrep.yml
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: >-
p/python
p/owasp-top-ten
5. OWASP and CWE References
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal")
- OWASP A01:2021 – Broken Access Control (path traversal is a subclass)
- OWASP Path Traversal Cheat Sheet covers encoding bypasses and defense patterns in depth
Key Takeaways
open()in Python has no concept of a safe directory — any path, including../../etc/passwd, is valid input unless you validate it yourself.- String matching on
../is not sufficient — resolve to a canonical path withPath.resolve()before comparing against the trusted root, because symlinks and URL encoding can bypass naive string checks. - The route in
cores_status.pywas publicly accessible, meaning this traversal primitive required no authentication to reach — making the severity genuinely high, not theoretical. - Wrap file I/O in validated helper functions like
_read_jsonand_write_json_atomicrather than callingopen()directly throughout your codebase — this creates a single, auditable choke point for path safety. - Atomic writes are a free win — the
_write_json_atomicpattern (write to.tmp, then rename) prevents JSON corruption from interrupted writes and should be standard practice for any file-based state.
How Orbis AppSec Detected This
- Source: User-controlled input flowing into the
cache_pathvariable via the HTTP route handler incores_status.py - Sink:
open(cache_path, "r")atxkeen-ui/routes/cores_status.py:221, where the unsanitized path was passed directly to Python's built-in file open - Missing control: No call to
Path.resolve(), no comparison against a trusted base directory, and no rejection of paths containing traversal sequences or symlinks pointing outside the intended directory - CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal")
- Fix: Introduced
_read_jsonand_write_json_atomichelper functions that resolve the canonical path withPath.resolve()and assert it falls within a caller-supplied trusted root before performing any file I/O
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Path traversal vulnerabilities are among the most consistently exploited classes of web application bugs — and they're entirely preventable. The specific flaw in xkeen-ui/routes/cores_status.py is a textbook example: a publicly accessible route, user-controlled input, and a direct call to open() with no boundary enforcement. The fix is equally textbook: resolve to a canonical path, compare against a trusted root, and reject anything that doesn't belong.
The broader lesson is architectural. Rather than auditing every open() call scattered across a codebase, centralizing file I/O through validated helpers like _read_json and _write_json_atomic makes it much easier to reason about and audit path safety. One well-tested helper beats a hundred individually reviewed open() calls.
If your Python application handles user-supplied file paths anywhere, this is the pattern to follow.