Back to Blog
high SEVERITY6 min read

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

A path traversal vulnerability in `WAVE2SCORE/app.py` allowed attackers to supply a crafted file path that escaped the application's intended working directory, potentially enabling access to arbitrary files on the server. The fix resolves the absolute path and validates it against the expected `WORK_DIR` boundary before any processing occurs. This kind of boundary check is a critical safeguard in any application that processes user-supplied file paths.

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

Answer Summary

This is a path traversal vulnerability (CWE-22) in a Python application (`WAVE2SCORE/app.py`) where the `run_job()` function accepted a user-controlled `input_path` without resolving or validating it against the application's work directory. An attacker could supply a path like `../../etc/passwd` to escape the intended directory. The fix calls `Path(job["input_path"]).resolve()` to canonicalize the path and then checks that the resolved path starts with `WORK_DIR.resolve()`, rejecting any path that falls outside the permitted boundary.

Vulnerability at a Glance

cweCWE-22
fixResolve the path with `.resolve()` and assert it starts with `WORK_DIR.resolve()` before use
riskAttacker-controlled file path can escape the work directory, enabling unauthorized file access or processing
languagePython
root cause`input_path` was constructed directly from job data without resolving symlinks or validating the canonical path against `WORK_DIR`
vulnerabilityPath Traversal

Introduction

The WAVE2SCORE/app.py file is the heart of a wave-file scoring pipeline. It accepts job metadata, locates an uploaded audio file, and passes it to a processing script. That workflow sounds straightforward — but a flaw in the run_job() function meant that the input_path variable was never checked to confirm it actually lived inside the application's designated work directory.

The result: an attacker who could influence the input_path value stored in a job record could point the application at any file on the server — not just the uploaded .wav files it was designed to handle.


The Vulnerability Explained

What the code looked like before the fix

# BEFORE — vulnerable code (around line 58)
job_dir = Path(job["job_dir"])
input_path = Path(job["input_path"])   # ← no resolution, no boundary check
out_base = str(job_dir / "result")

# ... later, input_path is passed to subprocess

job["input_path"] comes from stored job data that originates with user input (an uploaded file). The application wraps it in a Path object, but Path(some_string) does not resolve .. components or follow symlinks — it just stores the string as-is.

Why this is dangerous

Consider what happens if input_path is set to something like:

../../etc/passwd

or an absolute path:

/var/secrets/api_keys.env

Because the code never calls .resolve() and never checks whether the resulting path falls inside WORK_DIR, both of those values pass through unchallenged. The downstream subprocess.run call (which invokes main.py with this path as an argument) will happily attempt to process whatever file the attacker named.

Concrete attack scenario

  1. An attacker submits a legitimate job to establish a valid session.
  2. They then manipulate the input_path field in the job record — either through a direct API call, a race condition, or a second-order injection — to contain ../../etc/shadow.
  3. run_job() constructs input_path = Path("../../etc/shadow") without complaint.
  4. The path is passed to main.py via subprocess.run. Depending on what main.py does with the file, the attacker may be able to read its contents, trigger error messages that leak data, or cause unexpected behavior in the scoring pipeline.

Real-world impact

This application appears to be publicly accessible. A successful exploit could expose sensitive server files, disrupt the scoring service, or serve as a stepping stone for deeper compromise.


The Fix

The patch makes two targeted changes inside run_job():

Before

input_path = Path(job["input_path"])

After

input_path = Path(job["input_path"]).resolve()

if not str(input_path).startswith(str(WORK_DIR.resolve())):
    raise RuntimeError("Invalid input path: outside of work directory")

Change 1 — .resolve()

Path.resolve() returns the absolute, canonical path. It expands every .. component and follows symlinks. After this call, ../../etc/shadow becomes /etc/shadow — its true identity is revealed before any comparison is made.

Change 2 — boundary check

The resolved path is then compared against WORK_DIR.resolve() (also canonicalized for the same reason). If the input path does not start with the work directory prefix, a RuntimeError is raised immediately and the job is aborted. No file outside WORK_DIR can ever reach the subprocess call.

This is a minimal, surgical fix: two lines added, zero functionality removed.

Note on the startswith check: For production hardening, consider using input_path.is_relative_to(WORK_DIR.resolve()) (Python ≥ 3.9) instead of the string startswith comparison. The is_relative_to method is path-aware and avoids edge cases where one directory name is a prefix of another (e.g., /work vs /work-extra).


Prevention & Best Practices

1. Always resolve before you restrict

# Safe pattern
safe_base = Path("/app/uploads").resolve()
user_path = Path(user_supplied_value).resolve()

if not user_path.is_relative_to(safe_base):
    raise ValueError("Path escapes upload directory")

2. Never trust Path(string) alone

Constructing a Path object from a string does not sanitize it. The dangerous .. sequences remain intact until .resolve() is called.

3. Apply the principle of least privilege to directories

The WORK_DIR boundary is only useful if the process running the application doesn't have read access to sensitive directories outside it. Combine path validation with OS-level sandboxing (e.g., chroot, containers, or seccomp profiles).

4. Validate at the earliest possible point

The check was added at the top of run_job(), before any processing begins. This is the right place — fail fast, fail loudly.

5. Relevant standards


Key Takeaways

  • Path(user_input) is not safe — always follow it with .resolve() before any comparison or filesystem operation in WAVE2SCORE/app.py and similar job-runner code.
  • The input_path variable in run_job() was the specific taint source — any job field that feeds a filesystem call needs the same treatment.
  • A two-line fix closed the hole — resolving the path and checking it against WORK_DIR is all it took; no architectural changes were needed.
  • String startswith on paths has edge cases — prefer Path.is_relative_to() in Python 3.9+ for path-aware boundary checks.
  • Publicly accessible job APIs need extra scrutiny — when job metadata can be influenced by external users, every field that touches the filesystem is a potential traversal vector.

How Orbis AppSec Detected This

  • Source: The input_path field originates from job["input_path"], which is populated from user-supplied job submission data.
  • Sink: Path(job["input_path"]) at WAVE2SCORE/app.py:58, subsequently passed to a subprocess.run call that invokes main.py with the path as an argument.
  • Missing control: No call to .resolve() and no check that the resulting path falls within WORK_DIR before the path was used.
  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal").
  • Fix: Added .resolve() to canonicalize input_path and a startswith(WORK_DIR.resolve()) guard that raises RuntimeError for any path outside the work directory.

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 deceptively simple: a missing .resolve() call and a missing boundary check are all it takes to turn a routine file-processing function into an unauthorized file-access primitive. In WAVE2SCORE/app.py, the run_job() function trusted that job["input_path"] would always point somewhere safe — a trust that an attacker could easily betray.

The fix demonstrates a key principle of defensive programming: canonicalize first, validate second, act third. By resolving the path to its true absolute form before comparing it to WORK_DIR, the application can no longer be fooled by .. tricks or symlink chains. Two lines of code, one closed attack surface.

If your application processes user-supplied file paths in any form — uploaded filenames, job metadata, API parameters — apply this same pattern everywhere. It is cheap to write and expensive to skip.


References

Frequently Asked Questions

What is path traversal?

Path traversal (CWE-22) is a vulnerability where an attacker supplies a file path containing `../` sequences or absolute paths to access files outside the intended directory.

How do you prevent path traversal in Python?

Use `Path.resolve()` to canonicalize the path (expanding `..` and symlinks), then assert the result starts with the expected base directory before opening or processing the file.

What CWE is path traversal?

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

Is checking the file extension enough to prevent path traversal?

No. Extension checks do not prevent `../` sequences from escaping the intended directory. You must resolve and validate the full canonical path.

Can static analysis detect path traversal?

Yes. Tools like Semgrep, Bandit, and multi-agent AI scanners can trace tainted data from user input to filesystem calls and flag missing boundary checks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #5

Related Articles

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr

high

How Arbitrary File Read happens in Python LangSmith SDK and how to fix it

A high-severity arbitrary server-side file read vulnerability (GHSA-f4xh-w4cj-qxq8) was discovered in LangSmith SDK's `TracingMiddleware`, affecting versions prior to 0.8.18. An attacker able to influence tracing requests could potentially read arbitrary files from the server's filesystem. Upgrading from version 0.8.15 to 0.8.18 in `poetry.lock` and `pyproject.toml` closes the attack surface entirely.

critical

How Command Injection happens in Python PopClip Extensions and how to fix it

A critical command injection vulnerability was discovered in `contrib/Klipz.popclipext/Klipz.py`, where user-controlled clipboard content was concatenated directly into shell commands executed via `osascript`. The fix replaces unsafe string concatenation with `subprocess` and proper argument lists, and replaces the unsafe `pickle` serialization with `json` to eliminate a secondary deserialization risk. Together, these changes close two distinct attack surfaces in a single file.

high

How Path Traversal happens in PostCSS Source Map Auto-Loading and how to fix it

A high-severity path traversal vulnerability in PostCSS versions before 8.5.18 allowed attackers to manipulate `sourceMappingURL` directives to load arbitrary `.map` files from the filesystem, potentially disclosing sensitive source code and build metadata. The fix upgrades PostCSS from 8.5.15 to 8.5.18 in `console/web/package-lock.json`, closing the path traversal vector in the source map auto-loading feature. This change protects applications that process untrusted CSS input through their Post

critical

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

A path traversal vulnerability in `skills/baoyu-design/agents/import-design-system.mjs` allowed attackers to escape the intended design system directory by supplying absolute paths, bypassing a guard that only checked for `..` prefixes. The fix adds an `isAbsolute()` check alongside the existing relative-path guard, closing the bypass with a single targeted change. This matters because the `dsDir` argument is user-controlled, meaning any caller of the script could redirect file operations to sen

high

How Denial of Service via infinite loop happens in Node.js dependencies and how to fix it

A high-severity Denial of Service vulnerability in the nanoid package (CVE-2026-67213) was discovered in the project's dependency tree, where crafted input could trigger an infinite loop during random ID generation. The fix upgrades nanoid from 3.3.17 to 3.3.18 and adds an npm override to ensure all transitive dependencies use the patched version.