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/exports.py+1 -2
@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))
Illustration written for this page, not from a customer repository. The route accepts slashes in name. A request for ../other-org-id/customers.csv, sent with a client that does not tidy the URL or with the dots percent-encoded, reads another organization's export, and enough ../ segments reach application config and secrets files on the same disk.

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.

AevralIllustration

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.

app/exports.py (safe version)
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


Security review, handled.

One GitHub App. Reviews start when the App is installed. A Check on each pull request it reviews, with inline comments when there is a grounded finding. Free tier live: public repos free, 500/org/month, 25 private reviews a month. Paid plans are live in the console.

Install the GitHub AppLog in

For professional use. By installing, you confirm you can act for the account or organization that owns it, and you accept the Terms and DPA on its behalf.