Path traversal

Path traversal through unvalidated filesystem paths

Description

Using user input directly as a filesystem path may let an attacker access files outside the intended directory. Inputs such as ../, absolute paths (/, C:\), or symbolic links can redirect access. Depending on the operation and process permissions, an attacker may read system files such as /etc/passwd, overwrite configuration or logs, or delete files.

Potential impact

  • Disclosure of system files, source code, or configuration such as .env
  • Overwriting or deleting uploaded files, logs, or configuration
  • Indirect privilege escalation if attacker-controlled content is written to a location that is later executed
  • Service disruption from deletion or corruption of essential files

Remediation

  • Avoid deriving paths directly from user input. Canonicalize with path.resolve and verify the boundary.
    • Resolve the existing path, for example with const resolved = fs.realpathSync(path.resolve(SAFE_ROOT, input));.
    • Reject results outside the canonical root. A prefix check must include SAFE_ROOT + path.sep to distinguish adjacent directory names.
  • If only a filename is needed, use an allow-list, sanitize-filename, or strict format validation such as ^[a-zA-Z0-9._-]{1,100}$.
  • Reject absolute paths, .., and empty values where they are not permitted.
  • Resolve existing symbolic links with realpath. Files can still change after realpath or fs.lstat checks, so protect the root and descendant paths from attacker modification.
  • Minimize filesystem permissions and do not expose real paths in error responses.
  • Keep a fixed upload or download root (SAFE_ROOT) and reject access outside it.

Examples

Before

javascript
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

// Before: joining input alone permits traversal with ..
app.get('/view', (req, res) => {
  const name = req.query.f; // Example: ../../etc/passwd
  const unsafePath = path.join(__dirname, 'public', name);
  fs.createReadStream(unsafePath)
    .on('error', () => res.status(404).end('not found'))
    .pipe(res);
});

app.listen(3000);

After

javascript
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();

// SAFE_ROOT must be server-managed and not modifiable by attackers
const SAFE_ROOT = fs.realpathSync(path.resolve(__dirname, 'public'));

function isInside(baseDir, targetPath) {
  const relative = path.relative(baseDir, targetPath);
  return (
    relative !== '' &&
    relative !== '..' &&
    !relative.startsWith('..' + path.sep) &&
    !path.isAbsolute(relative)
  );
}

app.get('/view', (req, res) => {
  const name = req.query.f;
  if (typeof name !== 'string') {
    return res.status(400).send('parameter f is required');
  }

  // Normalize the path and resolve symbolic links
  const candidate = path.resolve(SAFE_ROOT, name);
  let resolved;
  try {
    resolved = fs.realpathSync(candidate);
  } catch {
    return res.status(404).end('not found');
  }

  // Check the boundary: allow only descendants of SAFE_ROOT
  if (!isInside(SAFE_ROOT, resolved)) {
    return res.status(403).end('forbidden');
  }

  // Use only the validated path
  const stream = fs.createReadStream(resolved);
  stream.on('error', () => res.status(404).end('not found'));
  stream.pipe(res);
});

app.listen(3000);

Explanation:

  • Before: f is joined to a path without validation. path.join normalizes .. but does not guarantee that the result remains inside the base directory.
  • After: A string input is resolved relative to SAFE_ROOT, and fs.realpathSync resolves symbolic links. path.relative checks that the actual path is a descendant of the server-managed root. That directory structure must remain trusted between validation and opening; these checks alone do not eliminate races if an attacker can modify descendant paths.

References