How to check path traversal in an uploaded file name
Send one upload whose filename field is ../../evil.txt, then the same name with backslashes, then percent-encoded. Run curl -F "file=@payload.txt;filename=../../evil.txt" against your own endpoint, then list the storage directory. A file that landed outside it is the defect, and on Windows the three forms do not behave alike.
Why check this
A multipart upload carries a file name the client writes and the server obeys. The bytes are harmless until the name decides where they land. The question is whether that name can steer the write out of the upload directory.
Run it before sign-off on any endpoint that stores what it receives, and again after the upload handler moves to another framework or another operating system. The failure it prevents is a write over a file the application owns: ../../config/app.json replaces configuration, and an escape into a template directory under a document root turns an upload into code execution.
Run it against a service you own. Everything below runs on 127.0.0.1 against a target you start, and every write stays inside one scratch directory you delete at the end.
Prerequisites
- curl 8.0 or later. The
;filename=suffix on-Fsets the part name independently of the file on disk, so it can hold what no file picker produces. See the curl manual on -F. - Node 22 for the target below, or your own upload route.
- This run was Windows 11 build 22631 with Node 22.23.2. Three results below are properties of Windows, and the steps say which.
Save the target as target.mjs. It stores into srv/data/uploads under a sandbox root you pass in, cleans the name by one of five strategies chosen with ?mode=, and refuses any write resolving outside that root.
import { createServer } from 'node:http';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(process.argv[2]);
const STORE = path.join(ROOT, 'srv', 'data', 'uploads');
mkdirSync(STORE, { recursive: true });
const clean = {
raw: (n) => n,
basename: (n) => path.basename(n),
slash: (n) => n.split('/').pop(),
decode: (n) => path.basename(decodeURIComponent(n)),
decodeafter: (n) => decodeURIComponent(path.basename(n)),
};
createServer((req, res) => {
const mode = new URL(req.url, 'http://127.0.0.1:8933').searchParams.get('mode') ?? 'raw';
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks).toString('binary');
const sent = (/filename="([^"]*)"/.exec(body) ?? [, ''])[1];
let name = null, asked = null, error = null;
try {
name = (clean[mode] ?? clean.raw)(sent);
const target = path.join(STORE, name);
if (!path.resolve(target).startsWith(ROOT + path.sep)) throw new Error('refused: outside sandbox root');
asked = path.relative(ROOT, target);
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, 'x');
} catch (e) { error = e.code ?? e.message; }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ mode, sent, name, asked, error }) + '\n');
});
}).listen(8933, '127.0.0.1', () => console.log('upload target on 8933, sandbox ' + ROOT));
names.mjs asks the runtime what it does with each separator form.
import path from 'node:path';
const STORE = '/srv/data/uploads';
console.log('path.basename is path.' + (path === path.win32 ? 'win32' : 'posix') + ' here');
for (const n of ['../../evil.txt', '..\\..\\evil.txt']) {
console.log(JSON.stringify(n));
console.log(' basename posix', JSON.stringify(path.posix.basename(n)).padEnd(20),
'win32', JSON.stringify(path.win32.basename(n)));
console.log(' join posix', JSON.stringify(path.posix.join(STORE, n)).padEnd(20),
'win32', JSON.stringify(path.win32.join(STORE, n)));
}
tree.mjs walks the sandbox root rather than the store, so an escape is visible.
import { readdirSync } from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(process.argv[2]);
function walk(dir) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else console.log(JSON.stringify(path.relative(ROOT, p)));
}
}
walk(ROOT);
Write a payload file and start the target.
printf 'payload\n' > payload.txt && node target.mjs ./sandbox
Names print through JSON.stringify, so one backslash shows as two and a null byte shows as an escape.
Steps
- Step 1.
Ask the runtime what it does with each separator form.
node names.mjspath.basename is path.win32 here "../../evil.txt" basename posix "evil.txt" win32 "evil.txt" join posix "/srv/evil.txt" win32 "\\srv\\evil.txt" "..\\..\\evil.txt" basename posix "..\\..\\evil.txt" win32 "evil.txt" join posix "/srv/data/uploads/..\\..\\evil.txt" win32 "\\srv\\evil.txt"The second name is one segment under POSIX and two separators under Windows.
- Step 2.
Send the plain traversal name to the mode that cleans nothing.
curl -s -F 'file=@payload.txt;filename=../../evil.txt' 'http://127.0.0.1:8933/upload?mode=raw'{"mode":"raw","sent":"../../evil.txt","name":"../../evil.txt","asked":"srv\\evil.txt","error":null}sentis the name as it arrived, untouched.askedis two levels above the store. - Step 3.
Push the name past the sandbox root and confirm the target refuses.
curl -s -F 'file=@payload.txt;filename=../../../../../../evil.txt' 'http://127.0.0.1:8933/upload?mode=raw'{"mode":"raw","sent":"../../../../../../evil.txt","name":"../../../../../../evil.txt","asked":null,"error":"refused: outside sandbox root"}That resolve-and-compare guard keeps every escape on this page inside your scratch directory. It is also the fix.
- Step 4.
Send the backslash form to a guard written as
split('/').pop().curl -s -F 'file=@payload.txt;filename=..\..\evil.txt' 'http://127.0.0.1:8933/upload?mode=slash'{"mode":"slash","sent":"..\\..\\evil.txt","name":"..\\..\\evil.txt","asked":"srv\\evil.txt","error":null}The guard found no
/and kept the name whole. The Windows join then read the backslashes as separators. - Step 5.
Send the backslash form to
path.basenameinstead.curl -s -F 'file=@payload.txt;filename=..\..\evil.txt' 'http://127.0.0.1:8933/upload?mode=basename'{"mode":"basename","sent":"..\\..\\evil.txt","name":"evil.txt","asked":"srv\\data\\uploads\\evil.txt","error":null}Same payload, one call swapped, and the write lands inside the store.
- Step 6.
Percent-encode the separators and send them where the decode runs after the basename.
curl -s -F 'file=@payload.txt;filename=..%2f..%2fevil.txt' 'http://127.0.0.1:8933/upload?mode=decodeafter'{"mode":"decodeafter","sent":"..%2f..%2fevil.txt","name":"../../evil.txt","asked":"srv\\evil.txt","error":null}basenamesaw one segment and returned it whole. The decode put the separators back afterwards. - Step 7.
Put a percent-encoded null byte in front of the extension.
curl -s -F 'file=@payload.txt;filename=evil%00.png' 'http://127.0.0.1:8933/upload?mode=decode'{"mode":"decode","sent":"evil%00.png","name":"evil\u0000.png","asked":"srv\\data\\uploads\\evil\u0000.png","error":"ERR_INVALID_ARG_VALUE"}No file is created, and note which layer refused: the route took the name,
fsraised the error. - Step 8.
Send a name that ends in a dot.
curl -s -F 'file=@payload.txt;filename=report.txt.' 'http://127.0.0.1:8933/upload?mode=basename'{"mode":"basename","sent":"report.txt.","name":"report.txt.","asked":"srv\\data\\uploads\\report.txt.","error":null}The trailing dot survived
path.basenameand reached the filesystem call. - Step 9.
List everything the target wrote, with each name quoted.
node tree.mjs ./sandbox"srv\\data\\uploads\\evil.txt" "srv\\data\\uploads\\report.txt." "srv\\evil.txt"Three writes from seven requests.
srv\evil.txtis the escape, andreport.txt.kept its dot. - Step 10.
Ask the Windows path layer for that stored file by name.
Get-Item -LiteralPath ".\sandbox\srv\data\uploads\report.txt."Get-Item : Cannot find path '.\sandbox\srv\data\uploads\report.txt.' because it does not exist.The file is there and this cannot reach it. Win32 trims the dot and looks for
report.txt, which was never created.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| asked outside the store in step 2 | No name cleaning runs at all | Raise it. The client picks the write path. |
| Step 4 escapes, step 5 does not | The guard was written for one separator | Replace the string split with the platform basename. Both separators count on Windows. |
| Step 4 and step 5 both safe | Check which platform you ran on | On Linux the split guard passes for the wrong reason, as step 1 shows. Rerun on the deployment target. |
| Step 6 escapes | Decoding happens after validation | Decode once, then validate, then write. The order is the defect, not the decoder. |
| Step 7 returns a path error | The runtime rejects null bytes in paths | Reject the name at the edge as well, so the answer is a 400 and not a 500. |
| Step 8 accepted and step 10 fails | A stored name your own tooling cannot open | Store a generated name and keep the submitted one as metadata. |
| Every request rejected, including a plain name | Something earlier in the route rejects | Send payload.txt under its own name first and confirm it is stored. |
Common mistakes
What to check next
- How to check content disposition header: the header this writes into.
- How to check file name length: the other name property that fails there.
- How to test a unicode file name in content disposition: encoded forms in the same field.
- How to test uploading a file with a double extension: the type claim in the name.
- How to check if file type is validated on the server: the check that reads bytes.
FAQ
How do I test path traversal?
Send the traversal in the field the server trusts, then look at where the bytes landed rather than at the status code. Every request above returned 200, three wrote a file, and one wrote outside the store.
Is path.basename enough?
It strips both separators on Windows and one on Linux, as step 1 shows, and does nothing about encoding order, null bytes or trailing dots. Take the basename, then reject anything outside your allowed character set.
What if the upload legitimately carries a folder path?
Keep the path, resolve it, and compare the result with the store prefix before writing, the way step 3 refuses. Searching the raw string for .. is not the check: step 6 held none when it ran.
Why did the null byte produce no file?
Node 22 throws ERR_INVALID_ARG_VALUE for a path holding a null byte, so the write never reached the filesystem. The error came from fs, not from validation: the route had accepted that name.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2PowerShell 5.1.22621.6133Windows 11 build 22631
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny