Skip to content

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.

Updated 02 Nov 2026Checked against cli@de9b52a
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

  1. 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/file from passing the check even though it starts with /var/uploads.

  2. 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);
  3. 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' });
    }
  4. Use res.sendFile with an explicit root option, 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',
      });
    });
  5. 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

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.

Edit this pageSources: cli/internal/sast/rules/vnx-node-010.rego, cli/internal/sast/docs/vnx-node-010.md
esc
↑ ↓ move↵ openSearches in your browser. A key like autonomy.max or a code like threshold_required jumps straight to it.