Get listed

Path traversal: allowlist the file, then check the prefix

A garden path leaving the fence with coral footprints.

Path traversal is a filename that walks out of the directory you intended.

An upload, a download, or a zip extract that concatenates user input onto a root will serve or overwrite ../../etc/passwd unless you resolve the path and check the prefix. Language APIs that look safe still miss. Go’s os.Root already had a CVE.

The usual mistake is blacklisting ../ and allowing encoded or nested variants through.

This page is the resolve-and-prefix check, the zip-slip variant, and the test that a crafted name does not escape the tree.

Go 1.24 shipped os.Root on 11 February 2025 as a traversal-resistant open. It has already missed. CVE-2025-22873, fixed in 1.24.3, let a name ending in ../ open the parent. CVE-2026-27139 leaked ReadDir metadata outside the root (1.25.8 / 1.26.1). CVE-2026-39822 followed a symlink plus a trailing slash (1.25.12 / 1.26.5). Go 1.24 is past its patch window. Go 1.27.0 shipped 19 August 2026. A lexical clean does not pin a directory. A library helper can still miss a suffix.

The control is an allowlist, a canonical path, and a prefix test that cannot be fooled by a sibling name that only shares a string prefix.

If the string becomes a command, stop here and read injection. If you still parse a filename at all, pair this page with input validation. If the token is a valid file and the wrong tenant, that is IDOR.

A join is not a jail. realpath the candidate, then require the FILE_ROOT prefix plus a separator, or the hatch stays shut.

SecureCoding

CWE-22 is a join you did not pin

CWE-22 is Improper Limitation of a Pathname to a Restricted Directory. MITRE 4.20 says the product uses external input to build a pathname that was supposed to stay under a parent, and does not neutralize the parts that resolve outside that parent. The preferred name is path traversal. Directory traversal is the same class.

The usual Node and Go shape is path.join(FILE_ROOT, user) or filepath.Join(fileRoot, user). user came from a query, a Content-Disposition name, a zip entry, or a template include. The join is not a sandbox. Two dots as a segment walk toward the parent. A symlink walks sideways. A Windows alternate stream or a trailing separator can change what the kernel opens after your string check.

Defense is two layers that both have to pass. First, do not take a path from the user when a token will do. Second, if you must resolve a name you already allowlisted, canonicalize and check the prefix. Never filter only for the two-dot segment. Encodings and extra slashes beat that filter.

Map a token to a file you named

The cheap control is a map. The client sends invoice or terms. Your table names the file. The user string never enters join.

const path = require("node:path");
const fs = require("node:fs");

const FILE_ROOT = "/srv/webapp/files";
const ALLOWED = new Map([
 ["invoice", "invoice.pdf"],
 ["terms", "terms.pdf"],
]);

function openAllowed(token) {
 const name = ALLOWED.get(token);
 if (!name) return null;
 return openUnderRoot(FILE_ROOT, name);
}

openAllowed is the only function a route should call. req.query.file is not a name. It is a token that either hits ALLOWED or returns null. A 404 on null is enough. Do not echo the token back as a path.

realpath, then a prefix check

openUnderRoot is the named fallback when the allowlist still has to open a relative name you trust. Canonicalize both sides. Compare with a trailing separator so /srv/webapp/files does not match /srv/webapp/files-backup.

function openUnderRoot(rootDir, name) {
 if (name.indexOf("\0") !== -1) return null;
 const root = fs.realpathSync(rootDir);
 let resolved;
 try {
 resolved = fs.realpathSync(path.join(root, name));
 } catch (err) {
 return null;
 }
 const prefix = root.endsWith(path.sep) ? root : root + path.sep;
 if (resolved !== root && !resolved.startsWith(prefix)) return null;
 return resolved;
}

If realpath fails, deny. JepZ’s April 2012 question on Stack Overflow 10064499 is the live reminder: realpath() only returns a path that already exists, and it cuts off the missing tail. That is a feature for this helper. A download of a file that is not there yet is a 404, not a lexical guess. Do not invent a normalize-only PHP function so you can serve a path that realpath refused.

PHP, same contract:

function openUnderRoot(string $rootDir, string $name): ?string {
 if (str_contains($name, "\0")) return null;
 $root = realpath($rootDir);
 $resolved = realpath($root. DIRECTORY_SEPARATOR. $name);
 if ($root === false || $resolved === false) return null;
 $prefix = rtrim($root, DIRECTORY_SEPARATOR). DIRECTORY_SEPARATOR;
 if ($resolved !== $root && !str_starts_with($resolved, $prefix)) return null;
 return $resolved;
}

On go1.25.12+, go1.26.5+, or go1.27 you can skip the string prefix and use the API that is supposed to refuse an escape. It is the open, not the proof. Keep the token map.

root, err := os.OpenRoot("/srv/webapp/files")
if err != nil { return err }
f, err := root.Open("invoice.pdf") // name from ALLOWED, never from the query

Stay off every os.Root older than go1.25.12 or go1.26.5. CVE-2025-22873, CVE-2026-27139, and CVE-2026-39822 are why. The Go blog note on os.Root also says the type does not stop mount-point walks that require privilege. Treat os.Root as the open, not as the whole host policy.

path.Clean is not a root

Go path.Clean and filepath.Clean are lexical. They collapse extra slashes and dots. They do not know your document root. filepath.Join(fileRoot, user) followed by Clean still produces a path outside fileRoot when user contains parent segments. Node path.normalize is the same class.

Symlinks beat a string prefix that you computed before resolution. That is why the helper calls realpath on both FILE_ROOT and the candidate. A link that leaves the root fails the prefix. A link that stays inside is a file you already chose to serve, or it is a name that should not be in ALLOWED.

path.IsAbs is not a fix. An absolute user string just ignores your root on join. Reject a name that is absolute, that contains a NUL, or that is not in the map. Zip and tar entries are names too. Snyk named the archive form zip-slip in 2018. The 2026 version is the same join: path.join(dest, entry.name) with an entry you did not mint. Generate a random basename, write those bytes under FILE_ROOT, and record the original name only as a label. If you must keep a relative layout, run each entry through openUnderRoot and skip the entry when the helper returns null. The same rule applies to image-processor temp files and to a CMS “template include” field. On Windows, a drive prefix or a \\?\ extended path can survive a POSIX-looking clean. If the host can be Windows, resolve with the platform path module, then apply the separator prefix on the resolved string. Do not copy a Linux-only check into a Node service that also runs on a developer laptop.

Never hand a user path to a shell

blorgle’s comment starts with the shell. Quoting ; does not stop a parent segment inside the argument. exec('cat ' + user) is command injection and traversal in one string. The injection guide is the interpreter half. This page is the pathname half. You need both: no shell, and no user path.

// BAD: do not copy. user reaches the kernel as a path and as grammar.
// execFile("sh", ["-c", "file " + user]);

// FIX: no shell. path from openAllowed only.
const resolved = openAllowed(token);
if (!resolved) return res.status(404).end();
fs.createReadStream(resolved).pipe(res);

execFile with a hardcoded binary and argument vector is still wrong if one of those arguments is a user path. Resolve first with openAllowed. If you do not need a child process, do not start one. createReadStream on the resolved string is the download.

Upload is the inverse. Do not keep the client’s filename. Generate a random object id, store the bytes under FILE_ROOT, and put that id in the map or the database. The original name is a display label. It is not a path segment. That is also input validation: type, length, allow-list, no raw filesystem grammar.

If openAllowed returns a path the caller is not authorized to read, you solved traversal and still have IDOR. Put tenant and object in the same query, as the IDOR page does. A prefix check is not an ACL.

Tests you can run on your helper

You are testing openAllowed and openUnderRoot in your repo. You are not walking someone else’s disk.

const assert = require("node:assert/strict");
const os = require("node:os");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "files-"));
fs.writeFileSync(path.join(root, "invoice.pdf"), "ok");

assert.equal(openUnderRoot(root, "invoice.pdf"), fs.realpathSync(path.join(root, "invoice.pdf")));
assert.equal(openUnderRoot(root, "missing.pdf"), null);
assert.ok(openUnderRoot(root, path.join("..", "outside")) === null);
assert.equal(openAllowed("nope"), null);

Grep the routes for the join you do not want:

rg -n 'path\.join\(|filepath\.Join\(|realpath\(|include\s*\(\s*\$_' --glob '*.{js,ts,go,php}'

A join that still takes req.params or req.query is the miss. Replace it with openAllowed(token). A test that only asserts “two dots are stripped” is not enough. Assert the resolved prefix, and assert that a missing file is null.

Questions we keep getting

Can I allow any file under FILE_ROOT?

Only if every file in that tree is public to every caller who can hit the route. Most product trees are not. Prefer the token map. If the set is large, the map lives in the database with a tenant column, and the name column is still a basename you minted.

Does chroot replace the prefix check?

chroot or a container rootfs shrinks the blast if a helper is wrong. It does not pick the file. Keep openAllowed. Use isolation as depth, not as the only pin.

What if I need to create the file first?

Create it with a name you generated, then open that name through the same helper. Do not take a user path for a file that does not exist and skip realpath. JepZ wanted a normalize without existence. That is the lexical path this page refuses.

Harsh Patel / About Author

Full Stack Developer who's passionateย about Code Security Programmer | Internet Cowboy | JS Aficionado |ย Youtuber Twitter