Path Traversal
Escaping the sandbox
In the SSRF lesson, the attacker tricked your server into making HTTP requests to internal resources. Path traversal is similar, but the resource is your file system. The attacker manipulates a file path to read or write files outside the intended directory.
The vulnerable code
Imagine a route that serves file attachments for notes:
import path from "path";
import { readFile } from "fs/promises";
const UPLOADS_DIR = path.resolve("./uploads");
route.get("/files/:filename", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const filename = c.params.filename;
const filePath = path.join(UPLOADS_DIR, filename);
// DELIBERATELY VULNERABLE — does not check if filePath stays in UPLOADS_DIR
try {
const content = await readFile(filePath);
return new Response(content);
} catch {
return Response.json({ error: "File not found" }, { status: 404 });
}
},
}); The filename comes from the URL. If the user requests /files/report.txt, the server reads ./uploads/report.txt. That is the intended behavior.
The attack
The attacker requests: /files/../../etc/passwd
path.join("./uploads", "../../etc/passwd") resolves to /etc/passwd. The server reads and returns the contents of the system’s password file.
curl -b cookies.txt http://localhost:3000/files/..%2F..%2Fetc%2Fpasswd (%2F is a URL-encoded /)
The attacker can read any file the server process has permission to read: configuration files, environment variables, source code, database files, private keys.
Why it works
.. means “parent directory” in file paths. Each ../ goes up one level. By chaining enough ../ sequences, the attacker escapes the uploads directory and reaches any file on the system.
path.join does not validate anything. It combines path segments, including .. segments, into a final path. It does not check whether the result stays within a specific directory.
The fix: resolve and verify
Use path.resolve to compute the final absolute path, then check that it starts with the allowed directory:
route.get("/files/:filename", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const filename = c.params.filename;
const filePath = path.resolve(UPLOADS_DIR, filename);
// Check that the resolved path is inside the uploads directory
if (!filePath.startsWith(UPLOADS_DIR + path.sep)) {
return Response.json({ error: "Invalid filename" }, { status: 400 });
}
try {
const content = await readFile(filePath);
return new Response(content);
} catch {
return Response.json({ error: "File not found" }, { status: 404 });
}
},
}); path.resolve computes the absolute path, resolving all .. segments. Then we check that the result starts with UPLOADS_DIR + path.sep (the separator prevents a partial match like /uploads-other/).
If the attacker sends ../../etc/passwd, path.resolve produces /etc/passwd, which does not start with /path/to/uploads/, so the request is rejected.
[!NOTE] We append
path.sep(which is/on Linux/Mac and\on Windows) to prevent a sneaky bypass. Without it, a file at/uploads-malicious/evil.txtwould pass thestartsWith("/uploads")check because the string/uploads-maliciousstarts with/uploads. Adding the separator ensures the path is actually inside the directory, not just a sibling with a similar name.
Where path traversal hides
Any route that reads or writes files based on user input: file downloads, file uploads (the filename), static file serving, template rendering (the template name), log file viewers, and report generators that read from disk.
Exercises
Exercise 1: Create a ./uploads directory with a test file. Add the vulnerable route. Try accessing ../../package.json. Do you see your package.json contents?
Exercise 2: Apply the path.resolve + startsWith fix. Try the same attack. It should return 400.
Exercise 3: Try ....//....//etc/passwd (double dots with extra slashes). Does path.resolve handle this correctly? (Yes — it normalizes the path before producing the absolute result.)
Why is path.join insufficient to prevent path traversal?