Path Traversal in AI-Generated File Code: The Bug That Ships

os.path.join(upload_dir, user_filename) is syntactically correct and semantically dangerous. Here's why AI assistants generate this pattern and what catches it.

Copper Sun Brass Team · · 6 min read
securityai-code-reviewbenchmarks

os.path.join(upload_dir, user_filename) is syntactically correct. It passes any linter that checks style. It returns a string. The tests pass. And if user_filename is "../../../etc/passwd", it hands an attacker your server’s password file.

This is the gap between code that satisfies a prompt and code that handles adversarial input. AI assistants fall into it reliably, because satisfying the prompt is what they are built to do.

The Safe-Looking Pattern That Isn’t

BrassCoders’s Semgrep taint rule for path traversal (rule ID brass.python.taint.path-traversal, CWE-22) flags the exact dataflow that makes this dangerous: user-controlled input reaching a filesystem operation without sanitization.

The pattern looks like this:

@app.route("/upload", methods=["POST"])
def upload_file():
    user_filename = request.form.get("filename")
    dest = os.path.join(upload_dir, user_filename)
    with open(dest, "wb") as f:
        f.write(request.files["file"].read())
    return "ok"

Nothing here looks wrong if you’re reading for correctness. os.path.join does exactly what the documentation says. The open() call uses the result. The function reads a file and writes it. Every line is idiomatic Python.

Send filename=../../../etc/cron.d/backdoor and you’re writing into the system cron directory. The upload directory constraint evaporated when request.form.get("filename") went directly into the path join.

This is OWASP A01:2021 (Broken Access Control), path traversal sub-category. The control you thought you had — keeping uploads in a specific directory — is absent.

Why AI Assistants Generate This Pattern

When a developer prompts an AI assistant to write file upload code, the model produces the shortest path from the prompt to working output. User input flows into a path. The path goes to a file operation. That is the natural shape of the solution.

The model has no access to runtime context. It doesn’t know whether user_filename arrives from a config file you wrote yourself or from an HTTP request body that a stranger controls. The difference between safe and dangerous is not in the code’s shape — it’s in the provenance of the data. Pattern recognition alone cannot resolve that.

So the model writes os.path.join(upload_dir, filename) because it’s correct, idiomatic, and will work in every test case where filename is a reasonable name. The attacker’s test case — ../../../etc/passwd — never appears in the prompt, so it never informs the output.

This isn’t a failure of the model. It’s the structural gap between code generation and security review. The generator’s job is to satisfy the prompt. The reviewer’s job is to ask what an adversary would do with that code.

What BrassCoders Catches

BrassCoders catches path traversal through two independent static-analysis routes.

The first is a custom Semgrep taint rule that ships with the package. It marks HTTP request parameters as taint sources: request.args, request.args.get(...), request.form, request.form.get(...), request.json, request.GET, request.POST, FastAPI Path(...) and Query(...) parameters, and Django HttpRequest objects. It marks filesystem operations as sinks: open($PATH, ...) focused on the path argument, pathlib.Path.read_text(), pathlib.Path.write_text(), pathlib.Path.read_bytes(), pathlib.Path.write_bytes(), send_file(), send_from_directory(), shutil.copy(), shutil.move(), os.remove(), and os.unlink(). When tainted data reaches any sink without an intervening sanitizer, BrassCoders emits a path_traversal finding at Severity HIGH.

The second route is Pyre/Pysa (taint rule 5005), which independently performs dataflow analysis across the same class of vulnerability.

One important nuance: Bandit, the third scanner BrassCoders runs for Python security, does not have a general path traversal rule for os.path.join with user input. Bandit does flag tarfile.extractall() without a members filter as rule B202 — a path-traversal adjacent risk where a malicious tar archive can write files outside the extraction directory. The BrassCoders benchmark corpus includes one such instance in the published PyCQA Bandit examples scan. But for the upload-handler pattern above, the Semgrep and Pysa routes are doing the work.

What the AI Triage Layer Handles

BrassCoders is a pattern reporter. It traces data from source to sink and flags the reaching dataflow. That’s the right division of labor — specific, bounded claims about what the code does, not guesses about what the developer intended.

Context inference is the AI assistant’s job, not the scanner’s. When BrassCoders emits a path_traversal finding, it tells the reviewing AI: tainted data from request.form.get("filename") reaches open() at line 47. The reviewing AI then applies judgment: is this a real upload path (fix it), a test fixture (probably low priority), or an internal admin route where the data is validated upstream? The scanner cannot distinguish these. The reviewing AI can.

This matters because if BrassCoders tried to infer context — if it demoted the finding when a variable was named safe_filename, for example — it would sometimes suppress a real bug. A variable name is not a guarantee of sanitization. The scanner’s job is to report truthfully; the AI’s job is to triage intelligently.

The Fix: Sanitizing Before the Path

Two steps are both necessary.

import os
from pathlib import Path

def upload_file():
    user_filename = request.form.get("filename", "")

    # Step 1: strip directory components
    safe_name = os.path.basename(user_filename)
    if not safe_name:
        return "invalid filename", 400

    # Step 2: resolve and verify confinement
    final_path = Path(os.path.join(upload_dir, safe_name)).resolve()
    if not str(final_path).startswith(str(Path(upload_dir).resolve())):
        return "invalid path", 400

    with open(final_path, "wb") as f:
        f.write(request.files["file"].read())
    return "ok"

os.path.basename collapses ../../../etc/passwd to passwd. It removes every directory separator, so the traversal sequences disappear. Step 1 alone handles the common case.

Step 2 handles the edge cases: symlinks inside the upload directory that point outside it, platform-specific path behaviors, or any bypass that survives the basename strip. Resolving both paths to their canonical form and checking prefix containment is the definitive test.

Either step alone can be bypassed. Both together are not.

An AI assistant generating initial file upload code will rarely write both. It satisfies the prompt — file goes to upload directory — without modeling the adversarial case. Static analysis that follows the taint path from request.form to open() will catch the gap before the code ships.


BrassCoders is available via PyPI as brasscoders. Run pip install brasscoders and brasscoders scan . to see path traversal findings alongside the other 11 scanner categories. The OSS core is Apache 2.0 licensed with no account required. Paid plan enrichment at $12/month adds semantic deduplication that collapses clusters of similar path-traversal findings down to the one worth fixing first. Contact us at brass@coppersuncreative.com.

Frequently Asked Questions

What is a path traversal vulnerability?

A path traversal vulnerability lets an attacker read or write files outside the directory your application intended to serve. The classic payload is a filename like '../../../etc/passwd' — the '../' sequences walk up the directory tree until the attacker reaches a file they shouldn't see. Python's os.path.join silently honors these sequences, so join('/var/uploads', '../../../etc/passwd') resolves to '/etc/passwd', not something inside /var/uploads.

Does os.path.join prevent path traversal?

No. os.path.join joins path components correctly by POSIX rules, but it does not sanitize or reject '../' sequences. The function's job is path construction, not access control. Calling os.path.join(upload_dir, user_filename) with a malicious user_filename will produce a path that points outside upload_dir. Sanitizing with os.path.basename first, then verifying the resolved path is inside the expected directory, is the correct approach.

Does BrassCoders catch path traversal?

BrassCoders catches path traversal through two independent routes. First, a custom Semgrep taint rule (brass.python.taint.path-traversal, CWE-22) traces user-controlled request data — Flask request.args, Django request.GET and request.POST, FastAPI Query and Form parameters — through to filesystem sinks: open(), pathlib.Path.read_text(), pathlib.Path.write_bytes(), send_file(), shutil.copy(), and several others. Second, Pyre/Pysa (rule 5005) independently performs taint analysis on the same class of dataflow. Both fire at Severity HIGH. Bandit does not have a general path traversal rule for os.path.join patterns, though it does flag tarfile.extractall() (B202) as a path-traversal adjacent risk.

What's the fix for path traversal in file upload code?

Two steps: first, strip directory components from the filename with os.path.basename(user_filename) — this collapses '../../../etc/passwd' to 'passwd', removing the traversal sequences. Second, resolve the final path with Path(os.path.join(upload_dir, safe_name)).resolve() and verify it starts with Path(upload_dir).resolve() — this catches any edge case the basename strip missed, including symlink attacks. Reject the request if the check fails. Both steps are needed; either alone is incomplete.

Why do AI assistants generate path traversal bugs?

An AI assistant generates the simplest code that satisfies the prompt. When a prompt says 'save the uploaded file to the uploads directory,' the model writes os.path.join(upload_dir, filename) because that is syntactically correct, idiomatic Python, and works in the 99% case where filename is a normal name. The model has no runtime context: it doesn't know whether the filename came from a trusted config file or an HTTP request with a malicious payload. Security requires context inference — knowing that user_filename is attacker-controlled. That inference is the reviewer's job, not the generator's.