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

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

  1. Step 1.

    Ask the runtime what it does with each separator form.

    node names.mjs
    
    path.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.

  2. 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}

    sent is the name as it arrived, untouched. asked is two levels above the store.

  3. 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.

  4. 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.

  5. Step 5.

    Send the backslash form to path.basename instead.

    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.

  6. 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}

    basename saw one segment and returned it whole. The decode put the separators back afterwards.

  7. 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, fs raised the error.

  8. 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.basename and reached the filesystem call.

  9. 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.txt is the escape, and report.txt. kept its dot.

  10. 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

Sign: A sanitiser that splits on the forward slash passes every test, then a Windows deployment writes outside the upload directory.Cause: Step 4 is that bug. split('/').pop() leaves ..\..\evil.txt intact, and the Windows join reads the backslash as a separator, so the write resolves two levels up. Step 1 shows the POSIX join keeping the same name inside the store, which is why the suite that ran on CI reported a pass.
Sign: Decoding the name looks safe because the decode itself is correct.Cause: Step 6 decoded correctly and still escaped, because path.basename ran first on ..%2f..%2fevil.txt, saw one segment and returned it whole. Decoding twice is not the answer either: ..%252f..%252fevil.txt under decode-then-basename was measured here storing a file named ..%2f..%2fevil.txt, which the next layer that decodes turns back into a traversal.
Sign: A test asserts that the upload directory holds exactly one new file and reports a pass.Cause: An escape writes outside that directory, so a listing of the directory alone cannot see it. Step 9 walks the sandbox root instead. Assert on the absolute path the server resolved, or diff the tree above the store, not the store.
Sign: Deleting the fixtures fails and the scratch directory will not go away.Cause: Remove-Item -Recurse over the directory holding the trailing-dot name answered Could not find file 'report.txt.' and left every parent directory in place, because Win32 trims the dot before the delete. A direct Remove-Item on the file answers Cannot find path instead. The same file went away without complaint under Node's fs.rmSync. Delete a traversal fixture from the runtime that created it.

What to check next

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.

intermediate10 minpublished updated Maks Verny