VNX-NODE-010 – Node.js Path Traversal

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

Severity: High | CWE: 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