A document service lets users download their uploaded files by name:
app.get('/files/:name', (req, res) => {
res.sendFile('/var/app/uploads/' + req.params.name);
});It looks bounded — everything comes out of /var/app/uploads/. Then a request arrives for /files/..%2f..%2f..%2f..%2fetc%2fpasswd, the .. sequences walk up out of the uploads directory, and the server returns /etc/passwd. That is path traversal: a file parameter that was supposed to name a file inside one directory becomes a way to name any file the process can read.
What an attacker reaches
The interesting targets are rarely /etc/passwd itself — they're the files that unlock everything else:
- Application secrets —
.env,config.php,application.yml, cloud credential files under~/.aws/or~/.config/gcloud/ - Source code — reading your server-side code reveals more bugs, hardcoded keys, and internal endpoints
- Session and key material — private keys, session stores, SQLite databases sitting on disk
- Proc and metadata — on Linux,
/proc/self/environleaks the process environment (often full of secrets)
If the traversal is writable rather than read-only — an upload or "save as" feature that trusts a path — it escalates further: overwrite a config file, drop a web shell into a served directory, or clobber a cron file. Read access leaks; write access executes.
Why string filtering keeps failing
The instinct is to look for ../ and remove it. Attackers have a deep bag of tricks that defeat naive filters:
- Nested sequences —
....//collapses to../after a single strip pass. Filtering once makes it more dangerous. - Encoding —
%2e%2e%2fis../URL-encoded;%252e%252e%252fis double-encoded and survives one decode. - Backslashes on Windows —
..\\..\\traverses on Windows even if you only blocked forward slashes. - Absolute paths —
/etc/passwdneeds no../at all; a blocklist tuned for traversal sequences misses it entirely. - Null bytes and truncation — in some older stacks,
secret.txt%00.pngtruncates at the null and readssecret.txt.
Every one of these is a way the string differs from the file it resolves to. That gap is the whole vulnerability, which is why the fix works on the resolved path, not the string.
The fix: resolve, then confine
The reliable pattern is two steps that must happen in this order. First, canonicalize: resolve the requested path against a fixed base directory into a single absolute, normalized path (with all .., symlinks, and encodings collapsed). Second, confine: verify that the canonical result still lives inside the base directory. If it doesn't, reject it — don't try to repair it.
Stronger still: don't let the user name the file at all
Canonicalize-and-confine is the correct baseline, but the most robust designs remove the filename from the attacker's hands entirely:
- Map an opaque ID to a real path server-side. Store uploads under generated IDs (a UUID) and look up the real path in a database keyed by that ID and the owner. The user references
file/9f2c..., never a path, so there is nothing to traverse. - Serve from object storage. Put files in S3/GCS and hand out short-lived signed URLs. Your app never touches a local filesystem path for user content.
- Pin the extension and content type. If a feature only ever serves images, reject anything whose canonical path doesn't end in an allowed extension — defense in depth against LFI-style execution.
One rule to carry away: never trust the path string — trust only the fully resolved path, and only if it's still inside the directory you meant. The attack is entirely about the difference between what the input says and where it lands, so your check has to live on the landing spot.
Ready to try it? The path traversal simulation lets you escape a file-download feature with encoded ../ payloads, then apply the confinement check and watch every escape get blocked. See also the command injection guide — another case of input crossing a boundary it should never reach.
Frequently Asked Questions
Related posts
Server-Side Template Injection: From {{7*7}} to RCE
SSTI happens when user input is rendered as part of a server-side template instead of passed as data. Here's how {{7*7}} becomes remote code execution, and how to render templates safely in Jinja2, Twig, and Node.js.
Aug 8, 2026 · 7 min readLDAP Injection: Bypassing Auth Through the Directory
LDAP injection lets an attacker rewrite a directory query with characters like * ( ) and | — bypassing login and dumping user data. Here's how the filter syntax gets abused and how to escape it correctly in Node.js, Python, and Java.
Aug 7, 2026 · 7 min readNoSQL Injection: When $gt and $where Bypass Your Login
NoSQL databases aren't immune to injection — they just get attacked differently. Here's how operator injection and $where JavaScript let attackers bypass auth in MongoDB, and how to stop it in Node.js and Python.
Aug 6, 2026 · 7 min read