Preventing Path Traversal Attacks in Node.js
If your Express app serves files based on user input—a download route, an upload preview, a dynamic asset—you have a potential path traversal vulnerability. It’s one of the most common and damaging file security mistakes in Node.js, and it’s surprisingly easy to introduce without realizing it.
This article explains how path traversal attacks work, why common “fixes” fall short, and what secure file handling in Node.js actually looks like in practice.
Key Takeaways
- Path traversal attacks exploit missing or incomplete validation of user-supplied file paths, allowing attackers to access files outside intended directories.
path.normalize()andpath.join()are not security tools—they clean up paths but do not prevent directory traversal.- The correct defense is to resolve paths with
path.resolve()and verify the result stays within a permitted base directory using astartsWithcheck that includespath.sep. - The safest approach is to avoid user-supplied paths entirely by mapping input to files through an ID-based lookup.
What Is a Path Traversal Attack?
A path traversal attack (also called directory traversal) happens when an attacker manipulates a file path input to access files outside the intended directory. The classic example:
GET /download?file=../../etc/passwd
If your server naively joins that input with a base directory and reads the file, you’ve just handed over your system’s password file.
The attack isn’t sophisticated. It exploits missing or incomplete input validation—specifically, the failure to verify that a resolved path stays within a permitted directory.
Why path.normalize() and path.join() Are Not Enough
This is the most important thing to understand about Node.js path traversal prevention: these utilities are not security tools.
path.normalize() resolves .. sequences and cleans up redundant slashes. path.join() concatenates segments. Neither one prevents traversal—they just produce a cleaner path that might still point outside your intended directory.
Consider this common but insecure pattern:
// ❌ Insecure: path.join doesn't prevent traversal
app.get('/download', (req, res) => {
const filePath = path.join(__dirname, 'uploads', req.query.file)
res.sendFile(filePath)
})
If req.query.file is ../../etc/passwd, path.join() resolves it cleanly—and sends the wrong file.
URL encoding makes it worse. An attacker might send ..%2F..%2Fetc%2Fpasswd. Express decodes URL parameters automatically, so you should validate the decoded value you receive. If you’re handling raw URLs yourself, decode once (with proper error handling) before validation.
The Correct Pattern: Resolve and Verify Containment
The reliable approach for secure file handling in Node.js is:
- Resolve the user-supplied path against your base directory using
path.resolve(). - Verify the resolved path stays within that directory.
const path = require('path')
const BASE_DIR = path.resolve(__dirname, 'uploads')
function safeResolve(userInput) {
const resolved = path.resolve(BASE_DIR, userInput)
// Ensure resolved path is inside BASE_DIR
if (!resolved.startsWith(BASE_DIR + path.sep) && resolved !== BASE_DIR) {
return null
}
return resolved
}
// ✅ Secure Express file download route
app.get('/download', (req, res) => {
const safePath = safeResolve(req.query.file)
if (!safePath) {
return res.status(403).send('Access denied')
}
res.sendFile(safePath)
})
Note the path.sep addition. Without it, a base directory of /uploads would incorrectly allow /uploads-other/secret.txt to pass the prefix check.
On Windows, path.resolve() and path.sep handle backslashes correctly, so this pattern works across platforms. For stricter validation, you can also compare paths using path.relative() and reject any result that escapes the base directory.
Be aware that symlinks can still point outside your base directory. If you’re serving sensitive files, resolve real paths with fs.realpath() before sending them.
Discover how at OpenReplay.com.
Better Yet: Avoid User-Supplied Paths Entirely
The most secure approach is to not use user-supplied paths at all. Instead, map user input to files indirectly:
// ✅ ID-based file lookup — no path construction from user input
const FILES = {
'report-2024': 'reports/annual-2024.pdf',
'invoice-001': 'invoices/inv-001.pdf',
}
app.get('/download/:id', (req, res) => {
const filePath = FILES[req.params.id]
if (!filePath) {
return res.status(404).send('Not found')
}
res.sendFile(path.resolve(__dirname, 'secure', filePath))
})
This eliminates the traversal risk entirely. If your use case allows it—file downloads, document exports, user-specific assets—this is the pattern to reach for first.
A Note on Express Static Serving
express.static() is generally safer for serving public assets because it restricts access to a defined root directory. The risk appears when you write custom file-serving routes that construct paths from request parameters or expose unintended directories. Configuration and root selection still matter.
Conclusion
Path traversal is a straightforward vulnerability with a straightforward fix: never trust user input as a file path. Resolve it against a fixed base, verify containment with path.resolve() and a startsWith check that includes path.sep, and validate decoded input before use. When possible, prefer ID-based lookups that sidestep path construction from user input altogether. path.normalize() and path.join() are useful utilities—just not security guarantees.
FAQs
It helps by serving files from a defined root directory and handling path resolution internally, but it is not a guarantee of complete safety. Misconfiguration or exposing sensitive directories can still create risk. Custom file-serving routes that use user input are where most vulnerabilities occur.
No. Filtering or rejecting strings that contain dot-dot sequences is fragile. Attackers can bypass such checks with URL encoding, double encoding, or platform-specific path separators. The reliable defense is to resolve the full path with path.resolve() and then verify the result falls within your intended base directory using a startsWith check or path.relative().
Yes. Using path.resolve() and path.sep makes the containment check work across platforms. path.resolve() normalizes Windows paths, and path.sep evaluates to the correct separator. For stricter validation, use path.relative() to ensure the resolved path does not escape the base directory.
Yes. After confirming the resolved path is within your base directory, verify the file exists using fs.existsSync() or fs.access() before calling res.sendFile(). This prevents leaking information about your directory structure through error messages and gives you control over the response when a file is missing.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.