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.
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:
- Use
path.resolve(), notpath.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. - Include the trailing separator in the prefix check (
UPLOAD_DIR + path.sep). Without it, a directory named/var/app/uploads-extrawould 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.