Back to Blog
high SEVERITY7 min read

How Path Traversal happens in Python Flask routes and how to fix it

A high-severity path traversal vulnerability was discovered in `xkeen-ui/routes/cores_status.py` at line 221, where user-controlled input was passed directly to Python's `open()` function without sanitization. An attacker could exploit this to read arbitrary files on the server by supplying crafted path strings like `../../etc/passwd`. The fix introduces strict path validation using a trusted root directory, ensuring only files within the intended directory can be accessed.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a path traversal vulnerability (CWE-22) in Python, found in `xkeen-ui/routes/cores_status.py` at line 221. User-controlled input was passed directly to `open()` without sanitization, allowing attackers to read arbitrary files via sequences like `../../etc/passwd`. The fix adds two helper functions, `_read_json` and `_write_json_atomic`, that resolve the final path and verify it stays within a trusted root directory before any file operation is performed.

Vulnerability at a Glance

cweCWE-22
fixIntroduced _read_json and _write_json_atomic helpers that resolve and validate paths against a trusted root before opening files
riskAttackers can read arbitrary files on the server, including secrets and configuration files
languagePython
root causeUser-controlled path passed directly to open() in cores_status.py:221 without boundary validation
vulnerabilityPath Traversal via unsanitized user input in open()

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 with Path.resolve() before comparing against the trusted root, because symlinks and URL encoding can bypass naive string checks.
  • The route in cores_status.py was 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_json and _write_json_atomic rather than calling open() directly throughout your codebase — this creates a single, auditable choke point for path safety.
  • Atomic writes are a free win — the _write_json_atomic pattern (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_path variable via the HTTP route handler in cores_status.py
  • Sink: open(cache_path, "r") at xkeen-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_json and _write_json_atomic helper functions that resolve the canonical path with Path.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.


References

Frequently Asked Questions

What is a path traversal vulnerability?

A path traversal vulnerability occurs when user-controlled input is used to construct a file path without validation, allowing attackers to escape the intended directory and access arbitrary files using sequences like `../`.

How do you prevent path traversal in Python?

Resolve the full absolute path using `Path.resolve()` and verify it starts with the trusted base directory before passing it to `open()`. Never rely on user input alone to construct file paths.

What CWE is path traversal?

Path traversal is classified as CWE-22: Improper Limitation of a Pathname to a Restricted Directory.

Is stripping `../` sequences enough to prevent path traversal?

No. Simple string stripping can be bypassed with URL encoding, double encoding, or null bytes. The safest approach is to resolve the canonical path and compare it against an allowlisted root directory.

Can static analysis detect path traversal?

Yes. Tools like Semgrep can detect taint flows from user-controlled input to dangerous sinks like `open()`. This specific vulnerability was detected by the Semgrep rule `utils.custom.path-traversal-open`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #29

Related Articles

high

How Path Traversal happens in Node.js PostCSS and how to fix it

A high-severity path traversal vulnerability in PostCSS versions before 8.5.18 allowed attackers to exploit the `sourceMappingURL` auto-loading mechanism to read arbitrary `.map` files from the filesystem. The fix upgrades PostCSS from 8.5.8 to 8.5.18 and pins the dependency via an npm `overrides` entry, closing the attack surface entirely. Any project using PostCSS as a direct or transitive dependency should apply this upgrade immediately.

critical

How Path Traversal happens in JavaScript i18n loaders and how to fix it

A path traversal vulnerability in `beta/js/i18n-chatrd.js` allowed attackers to manipulate the `lang` URL query parameter to load arbitrary JSON files from the web server by injecting payloads like `../../sensitive-file`. The fix adds input validation to ensure only safe, expected language codes are accepted before they are interpolated into the fetch URL. This type of vulnerability is especially dangerous in internationalization loaders because they are often publicly accessible and designed to

high

How Path Traversal happens in Python FastAPI and how to fix it

A critical path traversal vulnerability was discovered in `SovitsTest/GSVI.py`, a FastAPI-based TTS inference server, where the `/upload` endpoint accepted user-supplied filenames without sanitization. An unauthenticated remote attacker could exploit this to write arbitrary files anywhere on the filesystem — including sensitive system directories like `/etc/cron.d`. The fix adds path validation to prevent filenames from escaping the intended upload directory.

critical

How Path Traversal happens in Vitest UI Server and how to fix it

CVE-2026-47429 is a critical path traversal vulnerability in Vitest's UI server that allows unauthenticated attackers to read and execute arbitrary files on the host system when the UI server is active. The vulnerability was fixed by upgrading Vitest from the vulnerable `^4.0.0` range to the pinned safe release `4.1.0`. Any project running Vitest's UI mode during development or CI is potentially exposed until this upgrade is applied.

critical

How Local File Inclusion/Path Traversal happens in JavaScript PDF generation and how to fix it

CVE-2025-68428 is a critical Local File Inclusion/Path Traversal vulnerability in jsPDF versions prior to 4.0.0 that could allow attackers to read arbitrary files from the server's filesystem through unsanitized path inputs during PDF generation. The vulnerability was present in the `jspdf` dependency declared in `frontend/package-lock.json`, and was resolved by upgrading from version 3.0.4 to 4.0.0. Left unpatched, this flaw could expose sensitive server-side files to unauthorized access via cr

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left this Node.js library without a cooldown period on dependency updates, meaning Dependabot could immediately propose upgrades to newly published — potentially malicious or unstable — package versions. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, introducing a mandatory waiting period before any newly released version is surfaced as an update candidate. Because this project