VNX-NODE-010: Node.js path traversal
Reports an fs/fs.promises/fs-extra call or res.sendFile/res.download whose path argument comes from req.query/params/body/url, Koa ctx or a handler that destructures its request ({ params }), directly or through a variable assigned from one (path.join included). Paths reduced with path.basename, checked with startsWith or an anchored regex, and sendFile with a root option are not reported.
| Field | Value |
|---|---|
| Rule ID | VNX-NODE-010 |
| Severity | High |
| SARIF level | error |
| Kind | sast |
| Languages | JavaScript / TypeScript |
| CWE | CWE-22 |
| CAPEC | CAPEC-126 |
| ATT&CK | T1083 |
| CWSS vector | CWSS:1.0/TI:H/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:H/DI:H/EX:H/EC:N/P:H |
| Tags | path-traversal file-access lfi |
| Source | internal/sast/rules/vnx-node-010.rego |
Overview
This rule detects file-system operations, fs.readFile, fs.readFileSync, fs.createReadStream, path.join, path.resolve, and res.sendFile, where the path argument is drawn directly from user-supplied request data (req.params, req.query). Without strict path validation, an attacker can supply a filename containing ../ sequences to traverse out of the intended directory and read arbitrary files on the server. This is CWE-22 (Improper Limitation of a Pathname to a Restricted Directory, Path Traversal).
Why This Matters
Path traversal allows an attacker to read any file that the Node.js process has permission to access, application source code, configuration files, environment variable files (.env), database credentials, private keys, and operating system files like /etc/passwd. In a cloud environment, this commonly leads to reading instance credentials from well-known paths.
The attack is simple: instead of requesting /files/report.pdf, an attacker sends /files/../../../../etc/passwd or URL-encodes the traversal as %2e%2e%2f to bypass naive string filtering. The path.join function does resolve .. sequences, but it does not constrain the result to a specific directory, path.join('/var/files', '../../etc/passwd') legitimately returns /etc/passwd. Normalisation alone is not sufficient defence.
What Gets Flagged
The path (first argument) of a file-system read, write, stream, list, stat or delete, or of res.sendFile/res.download, must come from the request, on the same line or through a variable assigned from it a few lines above.
// FLAGGED
fs.readFile(req.query.file, 'utf8', cb);
const body = fs.readFileSync('./uploads/' + req.params.name);
res.sendFile(path.join(__dirname, 'docs', req.params.name));
fs.readFile('./public' + req.url, cb); // plain http server
// FLAGGED: two lines
const name = req.query.name;
const target = path.join(UPLOAD_DIR, name);
fs.createReadStream(target).pipe(res);
Not flagged: path.basename(req.query.file), a resolved path checked with startsWith(BASE + path.sep), a name matched against /^[a-z0-9-]+$/, res.sendFile(req.params.name, { root: PUBLIC_DIR }), and request values written as file content (fs.appendFileSync('/var/log/app.log', req.body.message)).
Remediation
-
Resolve the full path and verify it starts with your base directory. This is the only reliable defence against traversal:
// SAFE: resolve and prefix-check to prevent traversal const path = require('path'); const fs = require('fs'); const BASE_DIR = path.resolve('/var/uploads'); app.get('/files', (req, res) => { const userPath = req.query.path; const resolved = path.resolve(BASE_DIR, userPath); if (!resolved.startsWith(BASE_DIR + path.sep)) { return res.status(403).json({ error: 'Access denied' }); } fs.readFile(resolved, (err, data) => { if (err) return res.status(404).json({ error: 'File not found' }); res.send(data); }); });Note the
+ path.sep, this prevents a path like/var/uploads-secret/filefrom passing the check even though it starts with/var/uploads. -
Strip all path separators and traversal sequences from filenames. Accept only the base filename, never a path with directory components:
// SAFE: strip everything except the filename const filename = path.basename(req.params.filename); if (filename !== req.params.filename) { return res.status(400).json({ error: 'Invalid filename' }); } const fullPath = path.join(BASE_DIR, filename); -
Validate the filename against an allowlist pattern. Only allow alphanumeric characters, hyphens, underscores, and a single dot:
// SAFE: allowlist validation const SAFE_FILENAME = /^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/; if (!SAFE_FILENAME.test(req.params.filename)) { return res.status(400).json({ error: 'Invalid filename' }); } -
Use
res.sendFilewith an explicitrootoption, Express will then constrain the file path to that root automatically:// SAFE: root option constrains the path app.get('/static/:file', (req, res) => { res.sendFile(req.params.file, { root: path.resolve('/var/public'), dotfiles: 'deny', }); }); -
Apply least privilege at the OS level. Run the Node.js process as a dedicated user with read access only to the specific directories it needs. Mount file-serving directories read-only in Docker deployments.
References
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
- CAPEC-126: Path Traversal
- OWASP Path Traversal
- Express – res.sendFile root option
- Node.js path.resolve documentation
- OWASP Node.js Security Cheat Sheet
- MITRE ATT&CK T1083 – File and Directory Discovery
Suppressing this rule
Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):
# nosec vnx-node-010
// nosec vnx-node-010
Or record an ignore rule: vulnetix ignore add --rule vnx-node-010 --reason "...". See Suppressing findings.