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
- An attacker submits a legitimate job to establish a valid session.
- They then manipulate the
input_pathfield in the job record — either through a direct API call, a race condition, or a second-order injection — to contain../../etc/shadow. run_job()constructsinput_path = Path("../../etc/shadow")without complaint.- The path is passed to
main.pyviasubprocess.run. Depending on whatmain.pydoes 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
startswithcheck: For production hardening, consider usinginput_path.is_relative_to(WORK_DIR.resolve())(Python ≥ 3.9) instead of the stringstartswithcomparison. Theis_relative_tomethod is path-aware and avoids edge cases where one directory name is a prefix of another (e.g.,/workvs/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
- OWASP — Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- Python
pathlibdocs: https://docs.python.org/3/library/pathlib.html#pathlib.Path.resolve
Key Takeaways
Path(user_input)is not safe — always follow it with.resolve()before any comparison or filesystem operation inWAVE2SCORE/app.pyand similar job-runner code.- The
input_pathvariable inrun_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_DIRis all it took; no architectural changes were needed. - String
startswithon paths has edge cases — preferPath.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_pathfield originates fromjob["input_path"], which is populated from user-supplied job submission data. - Sink:
Path(job["input_path"])atWAVE2SCORE/app.py:58, subsequently passed to asubprocess.runcall that invokesmain.pywith the path as an argument. - Missing control: No call to
.resolve()and no check that the resulting path falls withinWORK_DIRbefore the path was used. - CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal").
- Fix: Added
.resolve()to canonicalizeinput_pathand astartswith(WORK_DIR.resolve())guard that raisesRuntimeErrorfor 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.