Path traversal in agent-written code
Joining a folder and a filename looks like it keeps the file inside the folder. With two dots in the filename, it does not.
Aevral,
In plain words
Picture a coat check. You hand over a ticket that says "hook 14" and the attendant brings your coat. Now someone writes "hook 14, then back through the staff door, the manager's desk, top drawer" on the ticket, and an attendant who follows tickets literally brings the contents of the drawer. Path traversal is that ticket: a filename from the user contains ../ segments, and the server walks up out of the folder it meant to serve from.
The same move works for writing: an upload or an unzip that saves to a user-chosen name can overwrite a file somewhere else on the server.
Why agents write it: join looks like a fence
path.join in Node and os.path.join in Python look like they keep a path inside the first folder. They do not. Joining normalizes the path, which means it resolves ../ segments and walks up out of the folder. In Python, joining with an absolute second part discards the folder entirely. An agent that writes join(EXPORT_DIR, name) has written something that reads as safe to both the agent and the reviewer.
The request that produces it is usually a simplification. Serving a file straight from disk by name is shorter than looking it up in the database first, so a refactor that removes a lookup table also removes the only thing that limited which files could be named.
The shapes it takes in a pull request
A filename from the request joined to a folder
path.join or os.path.join with a route parameter, query parameter or form field, then a read, send or delete on the result.
A lookup replaced by the filesystem
A refactor that serves files by name from disk instead of from a database record that listed which files the caller could reach.
An archive extracted as named
Unzipping an uploaded archive and writing each entry to the path stored inside the archive, which can contain ../ (the Zip Slip shape).
An upload saved under the client's filename
The name the browser sent is used as the storage path, so a crafted name writes outside the uploads folder.
Worked example, an illustration
An export download that serves any path under the server.
The request: simplify the export download. The old code looked the export up in the database, scoped to the organization, then sent its stored path. The agent replaced it with a direct read from the organization's export folder by name.
@app.get("/exports/<path:name>")@login_requireddef download_export(name): export = Export.query.filter_by(name=name, org_id=current_user.org_id).first_or_404() return send_file(export.storage_path) return send_file(os.path.join(EXPORT_DIR, str(current_user.org_id), name))What an Aevral finding on it looks like
On a reviewed pull request, a finding is an inline comment pinned to the added line, next to an advisory Check that never blocks the pull request. This is an illustration of that shape for the diff above.
app/exports.py, added line: `return send_file(os.path.join(EXPORT_DIR, str(current_user.org_id), name))`
name comes from the URL and may contain slashes and ../ segments. os.path.join does not keep the result inside the organization's folder, so the handler can send any file the process can read, including other organizations' exports.
Missing control: A join that refuses to leave the base folder, or the removed database lookup that limited downloads to the caller's own exports.
Fix with your agent
In app/exports.py, download_export joins the URL parameter name onto the organization folder and sends the result. Replace send_file(os.path.join(...)) with flask.send_from_directory(os.path.join(EXPORT_DIR, str(current_user.org_id)), name), which refuses paths that leave the folder. Add tests for ../1/x.csv and ../../etc/passwd, each expecting a 404.
Check this finding against the code before changing anything. Make the smallest change that restores the control, add a test for it, and stop before commit or push.A finding is a lead with evidence, not a confirmation. You or your agent check it against the code; Aevral never applies or merges a change. See handing a finding to your coding agent.
How to fix it
Use the framework's helper that refuses to leave the folder. Flask's send_from_directory joins with a safe join that rejects paths escaping the directory and answers 404. The general rule, when no helper exists: resolve the final path, follow symlinks, and check that the result still starts inside the base folder before touching the file.
The database lookup the refactor removed was also a control: it limited downloads to exports that exist for this organization. Restoring it is a fine fix on its own.
from flask import send_from_directory
@app.get("/exports/<path:name>")
@login_required
def download_export(name):
org_dir = os.path.join(EXPORT_DIR, str(current_user.org_id))
return send_from_directory(org_dir, name)What this page does not claim
- Aevral's PR security review, live on install, looks for path traversal on the diff of the pull requests it reviews, as one class among several. Looking for a class does not mean finding each instance of it: a review can miss one, and a quiet review is not proof that the code is clean.
- The whole-repo scan reads authorization, IDOR, and business-logic access control only. It does not read path traversal; on this class, the pull request is where Aevral looks.
- The receipts page discloses a small-corpus observation for this class: how the founding runs did on the two path traversal plants in the eval corpus. Two planted cases are not a reliable estimate of how Aevral performs on path traversal in unseen code, and no class-level estimate is published.
- The diff, the finding and the fix on this page are illustrations written for this page. They are not output from a customer repository.
- This page covers file paths built by application code. Object storage keys (S3 and similar) have a related shape, a user-chosen key prefix, but no ../ walking, and are not the subject here.
Sources
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'); OWASP Path Traversal; Flask API: send_from_directory; Node.js documentation: path.
Read next
Receipts: named runs of the eval suite; How IDOR happens in multi-tenant code; Command injection in agent-written code.
Other classes