ASVS 5.0 V5.3.2 (level 1) requires that when the application creates file paths for file operations, it uses internally generated or trusted data rather than user submitted filenames. If a user supplied filename or file metadata must be used, strict validation and sanitization must be applied, to protect against path traversal, local and remote file inclusion, and server side request forgery.
The simplest, strongest design: the user never controls any part of the path.
Why it matters
Path building from user input is the root of a large family of bugs. A filename such as ../../etc/passwd walks out of the upload folder. A name like C:\\Windows\\win.ini or an absolute path on Linux replaces the base directory in many APIs. A value that looks like a URL, passed to a function that can open both files and URLs, becomes SSRF or remote file inclusion. See CWE-22 for path traversal and CWE-918 for SSRF.
Filtering out ../ is a known losing game. Encodings, nested sequences, backslashes, null bytes and Unicode tricks keep producing bypasses. Removing the user from the path avoids the whole contest.
How to implement it
Preferred: generate the name yourself. Keep the original filename only as display metadata in your database, never as a path component.
import crypto from 'node:crypto';
import path from 'node:path';
const BASE = '/srv/uploads';
function newStoragePath(validatedExt) {
const id = crypto.randomUUID();
// fan out to avoid huge directories
return path.join(BASE, id.slice(0, 2), `${id}${validatedExt}`);
}
The extension comes from your own allowlist after content validation, not from the client.
If a user chosen name or relative path is unavoidable, for example when serving files by a requested key, resolve and then verify containment:
from pathlib import Path
BASE = Path('/srv/files').resolve()
def open_user_file(relative: str):
candidate = (BASE / relative).resolve()
if BASE != candidate and BASE not in candidate.parents:
raise PermissionError('path escapes base directory')
return candidate.open('rb')
Resolve first so symlinks and dot segments are collapsed, then check the result is inside the base. Also reject null bytes and absolute paths up front, and restrict the allowed characters with an allowlist such as letters, digits, dash and underscore.
For SSRF and remote inclusion, never pass user input to a function that accepts URLs. Fetch remote content only through a dedicated client that checks the scheme, resolves the host, blocks private and link local ranges, and uses an allowlist of destinations.
Where this goes wrong in real code
The bug shows up in more places than the upload handler. Search your codebase for every use of file APIs that receive request data: static file helpers, template names, report generators, image resizers that take a path, PDF renderers that load local resources, import features, and "download as" endpoints. In each case ask who controls the string.
A few frequent patterns:
- A template engine that takes the template name from a query parameter, such as
render(req.query.page). An attacker passes../../secrets. - A thumbnail service that accepts a path or URL and fetches it. This is the SSRF side: the attacker asks it to open the cloud metadata address.
- A download route like
/download?file=report.pdfthat is later extended to subfolders, and nobody revisits the check. - Archive entries or document metadata used as output names.
For lookups, prefer indirection. Instead of accepting a name, accept an identifier and map it to a path through a table you control:
const REPORTS = new Map([
['monthly', '/srv/reports/monthly.pdf'],
['annual', '/srv/reports/annual.pdf'],
]);
app.get('/reports/:key', (req, res) => {
const file = REPORTS.get(req.params.key);
if (!file) return res.sendStatus(404);
res.sendFile(file);
});
Add operating system defenses as a second layer. Run the service as an account that can read only its own data folder, use container read only mounts, and where available a mandatory access policy such as AppArmor or SELinux. A static code scan flags the pattern of request data flowing into file functions, and the interactive demo below shows what such a finding looks like.
How to test it
Probe every parameter that ends up in a file operation: download, preview, export, import, template and report names.
for p in '../../../etc/passwd' '..%2f..%2f..%2fetc%2fpasswd' '....//....//etc/passwd' \
'/etc/passwd' 'C:\\Windows\\win.ini' 'file:///etc/passwd' 'http://169.254.169.254/' \
'a%00.png'; do
curl -s -o /dev/null -w "$p -> %{http_code}\n" "https://example.com/download?name=$p"
done
Every case must be refused with a 4xx and no file content. Also upload a file whose name is ../../escape.txt and confirm it lands inside the base directory under a generated name, or is rejected. Add unit tests for the containment function with symlinks inside the base that point out, since resolving must follow them. A static scan of the code for file APIs that receive request data is a good complement.
Common mistakes
- Joining the base path and the user filename directly.
- Stripping
../once, so....//turns into../afterwards. - Checking the path before decoding, then decoding after.
- Validating the string but not the resolved path, so symlinks escape.
- Using one function for both local files and URLs.
- Trusting file metadata such as the name inside an archive or a document, treating it as path safe.
Try it yourself
Click through it at your own pace, or open it full screen.
Quick check
What is the strongest defense against path traversal in uploads?
Never use the client filename in the path. Generate a random name and store the original only as metadata.
After joining a base path and user input, what check is needed?
Resolve the result to a canonical path, then verify it is still inside the base directory.
Sources
- OWASP Application Security Verification Standard, requirement V5.3.2 (level 1)
- OWASP File Upload Cheat Sheet
- OWASP Path Traversal