← Todas las noticias
security5 min de lectura

Path traversal in file upload handlers: a common AI codegen bug

Por Seaworthy · 8 de mayo de 2026

Metal file cabinet with open drawer full of hanging folders

Photo by Maksym Kaharlytskyi on Unsplash

If you've ever asked an LLM to write a file upload endpoint, you've probably received code that does something like path.join(uploadDir, req.file.originalname). That line looks fine. It is not fine. It is, in fact, one of the most reliable ways to hand an attacker the keys to your server.

How path traversal works

The attack is simple. A user uploads a file named ../../../../etc/cron.d/backdoor. Your code dutifully joins that string with the upload directory and writes the file to /etc/cron.d/backdoor. Game over.

path.join does not protect you here. It resolves the segments but does not constrain the result to any particular directory. So path.join('/var/uploads', '../../../../etc/passwd') gives you /etc/passwd, clean as a whistle.

The reason this keeps appearing in AI-generated code is structural. LLMs learn from millions of code samples where the filename comes from a trusted internal source, so sanitisation never appears. Paste that pattern into an HTTP handler where the filename comes from a user, and you have a vulnerability.

Three AI-generated patterns that are directly exploitable

Here is roughly what you get when you prompt a model to "write a Node.js file upload handler":

// Pattern 1: raw originalname joined to upload dir
app.post('/upload', upload.single('file'), (req, res) => {
  const dest = path.join(__dirname, 'uploads', req.file.originalname);
  fs.renameSync(req.file.path, dest);
  res.json({ path: dest });
});

That originalname value comes from the HTTP request. The attacker controls it.

// Pattern 2: query param used as a filename for file serving
app.get('/files', (req, res) => {
  const filePath = path.join(__dirname, 'uploads', req.query.name as string);
  res.sendFile(filePath);
});

Now it is not just a write primitive. It is a read primitive. /files?name=../../../.env will happily serve your environment file to anyone who asks.

// Pattern 3: user-controlled subdir with no validation
app.post('/upload/:folder', upload.single('file'), (req, res) => {
  const dest = path.join('/var/uploads', req.params.folder, req.file.originalname);
  fs.mkdirSync(path.dirname(dest), { recursive: true });
  fs.renameSync(req.file.path, dest);
  res.sendStatus(200);
});

The mkdirSync with recursive: true here means an attacker can create directories anywhere on the filesystem, not just write files.

Sanitising filenames

The first line of defence is path.basename. It strips everything up to and including the last slash, so ../../../../etc/passwd becomes passwd. That alone closes most traversal attacks.

import path from 'path';
import { randomUUID } from 'crypto';

function safeFilename(originalname: string, ext?: string): string {
  // Strip path components entirely
  const base = path.basename(originalname);

  // Optionally: replace the name with a UUID to avoid collisions
  // and prevent filename-based enumeration
  const id = randomUUID();
  const extension = ext ?? path.extname(base);

  // Enforce a simple allow-list on the extension
  const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.csv'];
  if (!allowed.includes(extension.toLowerCase())) {
    throw new Error(`File type not permitted: ${extension}`);
  }

  return `${id}${extension}`;
}

UUID renaming is worth doing even if you trust the extension allow-list. It eliminates filename collisions, stops users from overwriting each other's files, and makes enumeration attacks useless.

Verifying the resolved path stays inside the upload directory

basename handles the common case, but there are edge cases: null bytes, Unicode tricks, symlinks that escape the directory. The robust fix is to resolve the full path and then check that it starts with the upload root.

import path from 'path';
import fs from 'fs';

const UPLOAD_ROOT = path.resolve('/var/app/uploads');

function safeDestination(filename: string): string {
  const sanitised = path.basename(filename);
  const resolved = path.resolve(UPLOAD_ROOT, sanitised);

  if (!resolved.startsWith(UPLOAD_ROOT + path.sep)) {
    throw new Error('Path traversal detected');
  }

  return resolved;
}

Note the + path.sep on the check. Without it, a directory named /var/app/uploads-evil would pass the startsWith test.

For symlinks, you need fs.realpathSync after writing: resolve the actual on-disk path and repeat the bounds check. This is only necessary if your upload directory is on a filesystem where symlinks can escape it, but it costs almost nothing.

Serving uploaded files safely

The cleanest solution for serving user-uploaded files is to not serve them from your application server at all. Put them in an object storage bucket (S3, R2, GCS) and issue signed URLs. The storage service handles access control, your application never touches the raw path, and you get CDN delivery for free.

If you need to serve files directly, use a static file server (nginx, Caddy) pointed at the upload directory, not res.sendFile with a user-controlled path. Configure the static server with autoindex off and restrict by MIME type at that layer. Your application handles the authorisation check; the static server handles the bytes.

If none of that is possible and you genuinely need res.sendFile, use the root option:

app.get('/files/:id', async (req, res) => {
  // Look up the real filename from your database using the ID
  const record = await db.files.findById(req.params.id);
  if (!record) return res.sendStatus(404);

  // sendFile with a root option will refuse to serve anything outside it
  res.sendFile(record.storedFilename, { root: UPLOAD_ROOT });
});

The root option tells Express to resolve the path relative to that directory and to reject traversal attempts outright. The filename comes from your database, not from the request, so the user never controls the path directly.

Path traversal is not an exotic vulnerability. It is a boring, well-documented class of bug that has been exploited for decades. The reason it keeps showing up in AI-generated code is that the models are pattern-matching on code where trust is implicit. Your job is to notice when that assumption breaks. Seaworthy flags this pattern automatically.

Este artículo fue generado por IA y resume fuentes públicamente disponibles.

De los creadores de Seaworthy

Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.

Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.

$ npx seaworthycode