Web Security

Path Traversal Attacks: How to Prevent Directory Traversal

Understand how ../ tricks and URL encoding bypass naive path checks, and learn safe file-serving patterns in Node.js and Python that stop traversal cold.

March 9, 20266 min readShipSafer Team

Path Traversal Attacks: How to Prevent Directory Traversal

Path traversal — also called directory traversal — is one of the oldest vulnerabilities on the web and still appears in production systems every year. The attack is straightforward: an application builds a file path using user-controlled input without properly sanitizing it, allowing an attacker to navigate outside the intended directory and read (or sometimes write) arbitrary files.

The Mechanics of ../

Consider an endpoint that serves user-uploaded files:

GET /files?name=report.pdf

If the server builds the path naively:

const filePath = path.join('/var/app/uploads', req.query.name);
fs.readFile(filePath, ...);

An attacker submits:

GET /files?name=../../etc/passwd

The resolved path becomes /var/app/etc/passwd... wait, that is wrong. path.join in Node.js does collapse ../ sequences, so the resolved path actually becomes /etc/passwd. The attacker reads the system password file.

On Windows the separators change but the principle is identical:

GET /files?name=..\..\Windows\System32\drivers\etc\hosts

Bypass Techniques

Naive defenses check for ../ in the raw input. Attackers bypass them with encoding:

URL encoding

%2e%2e%2f  →  ../
%2e%2e/    →  ../
..%2f      →  ../

When the web framework URL-decodes the parameter before the application reads it, the dot-dot-slash appears after the check has already passed.

Double encoding

%252e%252e%252f  →  (after first decode) %2e%2e%2f  →  (after second decode) ../

Some middleware decodes twice, or the application decodes manually after the framework has already decoded once.

Unicode and overlong UTF-8 (older systems)

..%c0%af   →  ../ on non-validating parsers
..%c1%9c   →  ..\ on Windows

Modern runtimes reject overlong sequences, but legacy applications on older JVMs or PHP versions may still be vulnerable.

Null byte injection

../../etc/passwd%00.jpg

In languages where file operations are backed by C strings (older PHP, some native extensions), a null byte truncates the path. The suffix .jpg satisfies a file extension check, and the OS opens /etc/passwd.

Mixed separators

On Windows, both / and \ are valid path separators:

..\/..\/Windows/win.ini

Canonicalization: The Correct Defense

The reliable fix is to resolve the full canonical path of the requested file and then verify that it starts with the canonical path of the allowed directory. Do this after all decoding has occurred — use the runtime's path resolution functions, not string manipulation.

Node.js

import path from 'path';
import fs from 'fs/promises';

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

async function serveFile(name: string): Promise<Buffer> {
  // path.resolve handles .., ., mixed separators, and redundant slashes
  const requested = path.resolve(UPLOAD_DIR, name);

  // Verify the resolved path is inside the allowed directory
  if (!requested.startsWith(UPLOAD_DIR + path.sep) && requested !== UPLOAD_DIR) {
    throw new Error('Access denied');
  }

  return fs.readFile(requested);
}

Two critical points:

  1. Use path.resolve(), not path.join(). path.resolve() produces an absolute path regardless of the input. path.join('/uploads', '../etc/passwd') produces /etc/passwd — still dangerous if you then do a prefix check without realising the result left the directory.
  2. Include the trailing separator in the prefix check (UPLOAD_DIR + path.sep). Without it, a directory named /var/app/uploads-extra would pass the check.

Python

import os

UPLOAD_DIR = os.path.realpath('/var/app/uploads')

def serve_file(name: str) -> bytes:
    # realpath resolves symlinks as well as ../
    requested = os.path.realpath(os.path.join(UPLOAD_DIR, name))

    if not requested.startswith(UPLOAD_DIR + os.sep):
        raise PermissionError('Access denied')

    with open(requested, 'rb') as f:
        return f.read()

Use os.path.realpath() rather than os.path.abspath(). realpath also resolves symbolic links, which matters because an attacker who can create a symlink inside your upload directory could otherwise point it outside.

Safe File Serving in Express

A common pattern for user-facing file downloads:

import express from 'express';
import path from 'path';
import fs from 'fs/promises';

const router = express.Router();
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR ?? '/var/app/uploads');

router.get('/download/:filename', async (req, res) => {
  const { filename } = req.params;

  // Reject filenames containing path separators outright
  if (filename.includes('/') || filename.includes('\\') || filename.includes('\0')) {
    return res.status(400).json({ error: 'Invalid filename' });
  }

  const filePath = path.resolve(UPLOAD_DIR, filename);

  if (!filePath.startsWith(UPLOAD_DIR + path.sep)) {
    return res.status(403).json({ error: 'Access denied' });
  }

  try {
    await fs.access(filePath);
    res.download(filePath);
  } catch {
    res.status(404).json({ error: 'File not found' });
  }
});

The early rejection of separators is defense-in-depth — it catches the obvious case before canonicalization. The canonicalization check is what actually makes this safe.

Database-Driven File References

The cleanest architecture for file serving is to never expose file paths to users at all. Instead, store files with opaque identifiers:

// When uploading, generate an ID and store path server-side
const fileId = nanoid();
await File.create({ fileId, path: absolutePath, userId: user.userId });

// When serving, look up by ID — never accept a path from the client
const record = await File.findOne({ fileId, userId: user.userId });
if (!record) return res.status(404).end();
res.download(record.path);

This approach eliminates path traversal entirely because the user never controls any part of the path.

Archive Extraction (Zip Slip)

Path traversal appears in a different form when extracting ZIP or TAR archives submitted by users. Archive entries can contain ../ sequences in their stored filenames:

archive.zip
├── ../../../../etc/cron.d/malicious
└── legitimate-file.txt

When extracted naively, the first entry writes outside the target directory. This is known as "Zip Slip" and has affected numerous libraries across Java, .NET, Go, Python, and JavaScript.

Safe extraction in Node.js using adm-zip:

import AdmZip from 'adm-zip';
import path from 'path';

function safeExtract(zipPath: string, targetDir: string): void {
  const zip = new AdmZip(zipPath);
  const resolved = path.resolve(targetDir);

  for (const entry of zip.getEntries()) {
    const entryPath = path.resolve(resolved, entry.entryName);
    if (!entryPath.startsWith(resolved + path.sep)) {
      throw new Error(`Zip slip attempt: ${entry.entryName}`);
    }
  }

  zip.extractAllTo(resolved, true);
}

Check every entry before extracting any of them.

Detection and Testing

Search for path construction patterns in your codebase:

grep -rn "readFile\|createReadStream\|sendFile\|res.download\|open(" src/ \
  | grep -i "req\.\|params\.\|query\.\|body\."

For automated testing, include these payloads in your DAST suite:

../../../etc/passwd
..%2f..%2f..%2fetc%2fpasswd
....//....//....//etc/passwd
%2e%2e/%2e%2e/%2e%2e/etc/passwd

A response containing root:x:0:0 confirms exploitation. Nuclei's path traversal templates automate this check across all parameterized endpoints.

Path traversal is preventable with a single canonicalization check. The patterns shown here add only a few lines of code and eliminate an entire class of vulnerability.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.