Skip to content

CWE-22: Path Traversal - JavaScript/Node.js

Overview

Path Traversal in JavaScript/Node.js occurs when applications use user-supplied input to construct file paths without validation, allowing attackers to access files outside intended directories. Node.js applications handling file uploads, downloads, static file serving, or template rendering are vulnerable when using fs.readFile(), fs.createReadStream(), path.join(), or Express static middleware without proper validation.

Primary Defence: Use indirect reference mapping (map IDs to filenames), or validate with path.resolve() plus a path-boundary containment check (resolved === base || resolved.startsWith(base + path.sep)) to ensure the resolved path remains within the intended directory.

Common Vulnerable Patterns

Direct Path Concatenation

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

app.get('/download', (req, res) => {
    const filename = req.query.file;

    // VULNERABLE - User input directly in path
    const filePath = './uploads/' + filename;

    fs.readFile(filePath, (err, data) => {
        if (err) {
            res.status(404).send('File not found');
        } else {
            res.send(data);
        }
    });
});

// Attack: /download?file=../../../etc/passwd
// Result: Reads /etc/passwd instead of file in uploads/

Why this is vulnerable: String concatenation doesn't prevent ../ sequences from escaping the directory.

path.join() Without Validation

const path = require('path');
const fs = require('fs');

app.get('/files/:filename', (req, res) => {
    const filename = req.params.filename;

    // VULNERABLE - path.join doesn't prevent traversal
    const filePath = path.join(__dirname, 'public', filename);

    fs.readFile(filePath, (err, data) => {
        if (err) {
            res.status(404).send('Not found');
        } else {
            res.send(data);
        }
    });
});

// Attack: /files/..%2F..%2F..%2Fetc%2Fpasswd
// After URL decoding: /files/../../../etc/passwd
// Result: Escapes public/ directory

Why this is vulnerable: path.join() resolves .. components, allowing traversal.

Denylist Bypass with Encoding

app.get('/download', (req, res) => {
    const filename = req.query.file;

    // VULNERABLE - Denylist can be bypassed
    if (filename.includes('..') || filename.includes('/')) {
        return res.status(400).send('Invalid filename');
    }

    const filePath = path.join('./uploads', filename);
    fs.readFile(filePath, (err, data) => {
        if (err) {
            res.status(404).send('Not found');
        } else {
            res.send(data);
        }
    });
});

// Attack 1: Windows separators: ?file=..\\..\\windows\\win.ini
// Bypasses checks that only look for "/"

// Attack 2: Double encoding: ?file=%252e%252e%252f%252e%252e%252fetc%252fpasswd
// After double decode: ?file=../../etc/passwd
// Risk appears if another layer decodes again later

// Attack 3: Unicode: ?file=..%c0%af..%c0%afetc%c0%afpasswd
// Malformed or normalized input can bypass string-level checks in some stacks

Why this is vulnerable: Denylist approaches fail because attackers have many representation variations: URL encoding (%2e%2e%2f), double encoding across proxy/application layers, Unicode normalization, backslashes on Windows, absolute paths, and combinations. Express normally exposes decoded values in req.query and req.params, but string filters are still easy to get wrong and can diverge from the path the filesystem eventually uses. The reliable defense is canonical path validation using path.resolve() with a path-boundary containment check, which validates the final path that will be used.

Express Static with User-Controlled Root

const express = require('express');
const app = express();

app.use('/files/:folder', (req, res, next) => {
    const folder = req.params.folder;

    // VULNERABLE - User controls static root directory
    express.static(path.join(__dirname, 'data', folder))(req, res, next);
});

// Attack: /files/..%2F..%2F/etc/passwd
// Result: Serves files from arbitrary directories

Why this is vulnerable: User controls part of the root path for static file serving.

fs.createReadStream with Template Paths

app.get('/template', (req, res) => {
    const templateName = req.query.template;

    // VULNERABLE - Template name from user
    const templatePath = `./templates/${templateName}.html`;

    const stream = fs.createReadStream(templatePath);
    stream.pipe(res);
});

// Attack: /template?template=../../config/database
// Result: Reads ./config/database.html (../../ escapes templates/)

Why this is vulnerable: Template literals with user input don't prevent path traversal.

Multer File Access

const multer = require('multer');
const upload = multer({ dest: 'uploads/' });

app.post('/upload', upload.single('file'), (req, res) => {
    res.json({ filename: req.file.filename });
});

app.get('/view', (req, res) => {
    const filename = req.query.filename;

    // VULNERABLE - User specifies which uploaded file to view
    const filePath = path.join('uploads', filename);

    fs.readFile(filePath, (err, data) => {
        if (err) {
            res.status(404).send('File not found');
        } else {
            res.send(data);
        }
    });
});

// Attack: /view?filename=../app.js
// Result: Reads application source code

Why this is vulnerable: No validation that filename stays within uploads directory.

Archive Extraction (Zip Slip)

const AdmZip = require('adm-zip');

app.post('/extract', upload.single('archive'), (req, res) => {
    const zipPath = req.file.path;
    const zip = new AdmZip(zipPath);

    // VULNERABLE - No validation of entry paths
    zip.extractAllTo('./extracted/', true);

    res.json({ message: 'Extracted' });
});

// Attack: Upload ZIP with entry named "../../../../tmp/evil.sh"
// Result: File written outside extracted/ directory (Zip Slip)

Why this is vulnerable: Malicious ZIP archives can contain entries with ../ in filenames.

require() with User Input

app.get('/load-plugin', (req, res) => {
    const pluginName = req.query.plugin;

    try {
        // VULNERABLE - Dynamic require with user input
        const plugin = require(`./plugins/${pluginName}`);
        res.json({ loaded: plugin.name });
    } catch (error) {
        res.status(500).send('Plugin not found');
    }
});

// Attack: /load-plugin?plugin=../../config/secrets
// Result: Loads and executes code from arbitrary module

Why this is vulnerable: require() with user input can load modules outside intended directory.

Delete Operation Without Validation

app.delete('/files/:filename', (req, res) => {
    const filename = req.params.filename;

    // VULNERABLE - No validation before deletion
    const filePath = path.join('./user-files', filename);

    fs.unlink(filePath, (err) => {
        if (err) {
            res.status(500).send('Delete failed');
        } else {
            res.json({ deleted: filename });
        }
    });
});

// Attack: DELETE /files/..%2F..%2Fconfig%2Fdatabase.json
// Result: Deletes critical application file

Why this is vulnerable: User can delete files outside user-files directory.

Secure Patterns

Indirect File References (ID Mapping)

const crypto = require("crypto");
const path = require("path");
const fs = require("fs/promises");

const uploadsBase = path.resolve("/var/app/uploads");
const db = new Map(); // id -> { fullPath, ownerId, expiresAt }

function registerFile(fullPath, ownerId) {
  const id = crypto.randomBytes(16).toString("hex");
  db.set(id, { fullPath, ownerId, expiresAt: Date.now() + 5 * 60 * 1000 }); // 5 min
  return id;
}

function assertWithinBase(fullPath) {
  if (!(fullPath === uploadsBase || fullPath.startsWith(uploadsBase + path.sep))) {
    throw new Error("Invalid upload path");
  }
}

app.post("/upload", upload.single("file"), (req, res) => {
  // req.file.path must be a server-controlled upload location
  const fullPath = path.resolve(req.file.path);
  assertWithinBase(fullPath);

  const ownerId = req.user.id; // example
  const id = registerFile(fullPath, ownerId);

  res.json({ id });
});

app.get("/download/:id", async (req, res) => {
  const rec = db.get(req.params.id);
  if (!rec || rec.expiresAt < Date.now()) return res.sendStatus(404);

  // Authorization
  if (rec.ownerId !== req.user.id) return res.sendStatus(403);

  try {
    await fs.stat(rec.fullPath);
  } catch {
    return res.sendStatus(404);
  }

  res.download(rec.fullPath, (err) => {
    if (err && !res.headersSent) res.sendStatus(500);
  });
});

Why this works:

  • Users download files by opaque IDs rather than providing filesystem paths.
  • The server maintains the authoritative ID → path mapping and validates it before access.
  • When IDs are scoped (per user/tenant) and time-limited, leaked IDs are less useful and IDOR risk is reduced.
  • Upload paths should be server-controlled and validated to prevent registry poisoning.

Canonical Path Validation

const path = require("path");
const fs = require("fs");

const ALLOWED_DIR = path.resolve("./uploads");

function resolveSafe(userPath) {
  if (typeof userPath !== "string" || userPath.length === 0) return null;

  const resolved = path.resolve(ALLOWED_DIR, userPath);

  if (resolved === ALLOWED_DIR || resolved.startsWith(ALLOWED_DIR + path.sep)) {
    return resolved; // use this exact path
  }
  return null;
}

app.get("/download", (req, res) => {
  const filename = req.query.file;
  const filePath = resolveSafe(filename);

  if (!filePath) return res.status(400).send("Invalid file path");

  fs.stat(filePath, (err, st) => {
    if (err || !st.isFile()) return res.status(404).send("File not found");
    res.sendFile(filePath);
  });
});

// Attack: /download?file=../../etc/passwd
// Result: resolveSafe returns null, request rejected

Why this works:

  • The user-supplied path is resolved against a fixed base directory using path.resolve(), collapsing ./...
  • A containment check ensures the resolved absolute path remains under the allowed directory.
  • The application uses the validated resolved path for file access, avoiding mismatches between validation and use.
  • For existing files, resolving realpath can additionally detect symlink escapes if that’s a concern.

Basename Extraction

const path = require("path");

const PUBLIC_DIR = path.resolve(__dirname, "public");
const allowedFiles = new Set(["report.pdf", "invoice.pdf", "receipt.pdf"]);

app.get("/files/:filename", (req, res) => {
  const safeName = path.basename(req.params.filename);

  if (!allowedFiles.has(safeName)) {
    return res.status(400).send("File not allowed");
  }

  const filePath = path.resolve(PUBLIC_DIR, safeName);
  res.download(filePath);
});

// Attack: /files/..%2F..%2Fetc%2Fpasswd
// Result: basename extracts "passwd", not in allowlist, rejected

Why this works:

  • path.basename() drops any directory components so user input cannot directly specify a path.
  • An exact allowlist restricts access to a fixed set of known-safe filenames (the primary security control).
  • The server constructs the final path from a trusted base directory and an allowlisted filename.
  • With appropriate filesystem permissions (the public directory not attacker-writable), this prevents path traversal and common file-path injection attempts.

Express Static with Fixed Root

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

// SECURE - Static root is fixed, not user-controlled
app.use('/public', express.static(path.join(__dirname, 'public'), {
    dotfiles: 'deny',  // Deny access to dotfiles
    index: false       // Don't serve directory indexes
}));

// Requests like /public/../../../etc/passwd
// Express static middleware handles path normalization safely

Why this works:

  • Static files are served from a fixed, server-controlled root directory.
  • Express’s static middleware normalizes request paths and prevents traversal outside the configured root.
  • User input is never used to construct filesystem paths manually.
  • Directory listings and dotfile access are explicitly disabled to reduce accidental exposure.
  • With appropriate filesystem permissions (static directory not attacker-writable), this eliminates classic path traversal attacks.

Allowlist for Templates

const ALLOWED_TEMPLATES = {
    'welcome': 'welcome.html',
    'dashboard': 'dashboard.html',
    'profile': 'profile.html'
};

app.get('/template', (req, res) => {
    const templateKey = req.query.template;

    // SECURE - Lookup from allowlist
    const templateFile = ALLOWED_TEMPLATES[templateKey];

    if (!templateFile) {
        return res.status(400).send('Invalid template');
    }

    const templatePath = path.join(__dirname, 'templates', templateFile);

    fs.readFile(templatePath, 'utf8', (err, data) => {
        if (err) {
            res.status(500).send('Error loading template');
        } else {
            res.send(data);
        }
    });
});

// Attack: /template?template=../../config/database
// Result: Not in ALLOWED_TEMPLATES, rejected

Why this works:

  • User input selects only from a fixed allowlist of template keys.
  • The server-controlled filename (not the user) is used to build the filesystem path, preventing traversal via ../.
  • Templates are loaded only from a fixed templates directory.
  • For full safety, templates should be rendered with a templating engine that escapes user data (serving raw HTML is not XSS-safe by itself).

Zip Extraction with Path Validation

const AdmZip = require("adm-zip");
const path = require("path");
const fs = require("fs/promises");

const EXTRACT_DIR = path.resolve("./extracted");

function resolveEntryDest(entryName) {
  const dest = path.resolve(EXTRACT_DIR, entryName);
  if (dest === EXTRACT_DIR || dest.startsWith(EXTRACT_DIR + path.sep)) return dest;
  return null;
}

app.post("/extract", upload.single("archive"), async (req, res) => {
  const zipPath = req.file.path;

  try {
    await fs.mkdir(EXTRACT_DIR, { recursive: true });

    const zip = new AdmZip(zipPath);
    const entries = zip.getEntries();

    for (const entry of entries) {
      const dest = resolveEntryDest(entry.entryName);
      if (!dest) return res.status(400).send("Malicious archive detected");

      // Optional: skip directories explicitly
      if (entry.isDirectory) {
        await fs.mkdir(dest, { recursive: true });
        continue;
      }

      // Ensure parent exists
      await fs.mkdir(path.dirname(dest), { recursive: true });

      // Write file bytes to the validated destination
      const data = entry.getData();
      await fs.writeFile(dest, data, { flag: "wx" }); // no overwrite
    }

    return res.json({ message: "Extracted successfully" });
  } catch (e) {
    return res.status(500).send("Extraction failed");
  } finally {
    // Best-effort cleanup
    try { await fs.unlink(zipPath); } catch {}
  }
});

Why this works:

  • Each archive entry is resolved against a fixed extraction directory and rejected if it escapes that directory (Zip Slip prevention).
  • Extraction writes each entry to the validated destination path, avoiding “validate then call extractAll” mismatches.
  • Parent directories are created safely, and files are written without overwriting existing content.
  • Additional limits (entry count/total size/compression ratio) may be needed to mitigate zip bombs.

Disable Dynamic require()

// SECURE - Use allowlist for plugins
const ALLOWED_PLUGINS = {
    'analytics': require('./plugins/analytics'),
    'cache': require('./plugins/cache'),
    'logger': require('./plugins/logger')
};

app.get('/load-plugin', (req, res) => {
    const pluginName = req.query.plugin;

    // SECURE - Lookup from pre-loaded plugins
    const plugin = ALLOWED_PLUGINS[pluginName];

    if (!plugin) {
        return res.status(400).send('Invalid plugin');
    }

    res.json({ loaded: plugin.name, version: plugin.version });
});

// Attack: /load-plugin?plugin=../../config/secrets
// Result: Not in ALLOWED_PLUGINS, rejected

Why this works:

  • User input is never passed to require() or import().
  • All modules are resolved and loaded from fixed, server-controlled paths at startup.
  • An explicit allowlist limits which plugins can be accessed.
  • Attempts to reference arbitrary files or modules are rejected before any filesystem or module resolution occurs.

Delete with Path Validation

const fs = require("fs");
const path = require("path");

const ALLOWED_DELETE_DIR = path.resolve("./user-files");

function resolveSafeFilename(name) {
  const base = path.basename(name);
  const full = path.resolve(ALLOWED_DELETE_DIR, base);
  if (full === ALLOWED_DELETE_DIR || full.startsWith(ALLOWED_DELETE_DIR + path.sep)) return full;
  return null;
}

app.delete("/files/:filename", (req, res) => {
  const fullPath = resolveSafeFilename(req.params.filename);
  if (!fullPath) return res.status(400).send("Invalid file path");

  // TODO: enforce ownership/authorization here (best: delete by ID, not filename)

  fs.lstat(fullPath, (err, st) => {
    if (err) return res.status(404).send("File not found");
    if (st.isSymbolicLink()) return res.status(400).send("Invalid file type");
    if (!st.isFile()) return res.status(404).send("File not found");

    fs.unlink(fullPath, (err2) => {
      if (err2) return res.status(500).send("Delete failed");
      return res.json({ deleted: path.basename(fullPath) });
    });
  });
});

Why this works:

  • path.basename() ensures the request cannot include directory components.
  • The target path is resolved against a fixed base directory and rejected if it escapes that directory.
  • lstat() can be used to reject symlinks and ensure only regular files are deleted.
  • Deletion should be authorized (prefer indirect references or per-user directories); path validation alone does not prevent IDOR.

Key Security Functions

Path Traversal Validation

const path = require("path");

function safeResolve(baseDir, userPath) {
  const base = path.resolve(baseDir);
  const target = path.resolve(base, userPath);

  if (!(target === base || target.startsWith(base + path.sep))) {
    throw new Error("Path traversal detected");
  }

  return target; // return the validated path
}

// Usage:
try {
  const safePath = safeResolve("./uploads", req.query.file);
  fs.readFile(safePath, callback);
} catch {
  res.status(400).send("Invalid path");
}

Why this works:

  • The user-supplied path is resolved against a fixed base directory, collapsing ./...
  • A containment check ensures the resolved absolute path remains within the base directory.
  • The code uses the validated resolved path for filesystem access, avoiding validation/use mismatches.
  • For existing files, realpath checks can be added to detect symlink escapes if the base directory might be tampered with.

Filename Sanitization

const path = require("path");

function sanitizeFilename(filename) {
  const base = path.basename(String(filename || "")).trim();

  if (!base || base === "." || base === "..") {
    throw new Error("Invalid filename");
  }

  let safe = base.replace(/[^a-zA-Z0-9._-]/g, "_");

  if (safe.startsWith(".")) safe = "_" + safe;

  return safe;
}

// Usage:
const UPLOAD_DIR = path.resolve("./uploads");
const safeFilename = sanitizeFilename(req.query.file);
const filePath = path.resolve(UPLOAD_DIR, safeFilename);

Why this helps:

  • path.basename() discards any directory components so input cannot directly specify a path.
  • Character allowlisting produces a predictable, filesystem-friendly filename.
  • Rejecting empty/./.. and optionally blocking dotfiles avoids special-name edge cases.
  • This is filename hygiene only; safe file handling still requires a trusted base directory, collision/overwrite policy, and (if needed) symlink protections.

Canonical Path Checker

const path = require("path");
const fs = require("fs/promises");

async function validatePath(baseDir, userPath, { requireFile = true, followSymlinks = false } = {}) {
  const base = followSymlinks
    ? await fs.realpath(path.resolve(baseDir))
    : path.resolve(baseDir);
  const target = path.resolve(base, userPath);

  if (!(target === base || target.startsWith(base + path.sep))) {
    return { valid: false, reason: "Path traversal detected" };
  }

  try {
    const candidate = followSymlinks ? await fs.realpath(target) : target;

    if (!(candidate === base || candidate.startsWith(base + path.sep))) {
      return { valid: false, reason: "Symlink escape detected" };
    }

    if (requireFile) {
      const st = await fs.stat(candidate);
      if (!st.isFile()) return { valid: false, reason: "Not a file" };
    }

    return { valid: true, path: candidate };
  } catch {
    return { valid: false, reason: "File not found" };
  }
}

Why this works:

  • The user path is resolved against a fixed base directory, collapsing ./...
  • A containment check ensures the resolved absolute path stays within the base directory.
  • The validated resolved path is returned and used for file access (no validation/use mismatch).
  • Optional realpath verification can detect symlink escapes for existing files.
  • Existence/type checks improve error handling, but filesystem permissions are what prevent TOCTOU attacks.

Secure File Serving Middleware

const path = require("path");
const fs = require("fs/promises");

function secureFileServer(baseDirectory) {
  const base = path.resolve(baseDirectory);

  return async (req, res, next) => {
    try {
      const filename = req.params.filename ?? req.query.file;
      if (!filename) {
        return res.status(400).send("Filename required");
      }

      // Flatten input to a simple filename
      const safeName = path.basename(String(filename)).trim();
      if (!safeName || safeName === "." || safeName === "..") {
        return res.status(400).send("Invalid filename");
      }

      const resolved = path.resolve(base, safeName);

      // Containment check (defense-in-depth)
      if (!(resolved === base || resolved.startsWith(base + path.sep))) {
        return res.status(403).send("Access denied");
      }

      // Optional: reject symlinks
      const st = await fs.lstat(resolved);
      if (!st.isFile() || st.isSymbolicLink()) {
        return res.status(404).send("File not found");
      }

      return res.sendFile(resolved, err => {
        if (err) next(err);
      });
    } catch (err) {
      next(err);
    }
  };
}

// Usage:
app.get("/download/:filename", secureFileServer("./uploads"));

Why this works:

  • path.basename() discards all directory components, so user input cannot specify paths.
  • Files are resolved against a fixed, server-controlled base directory.
  • A containment check ensures the resolved path stays within that directory.
  • Rejecting symlinks prevents indirect escapes if the directory is writable.
  • This pattern is intentionally flat: only files in a single directory are accessible.

Framework-Specific Guidance

Express.js with Secure File Serving

const express = require("express");
const path = require("path");
const fs = require("fs/promises");
const app = express();

// Secure static file serving (fixed root)
app.use(
  "/public",
  express.static(path.join(__dirname, "public"), {
    dotfiles: "deny",
    index: false,
    redirect: false,
  })
);

// Secure download endpoint (basename-only)
const UPLOADS_DIR = path.resolve(__dirname, "uploads");

app.get("/download/:filename", async (req, res, next) => {
  try {
    const safeName = path.basename(String(req.params.filename)).trim();
    if (!safeName || safeName === "." || safeName === "..") {
      return res.status(400).send("Invalid filename");
    }

    const resolved = path.resolve(UPLOADS_DIR, safeName);

    if (!(resolved === UPLOADS_DIR || resolved.startsWith(UPLOADS_DIR + path.sep))) {
      return res.status(403).send("Access denied");
    }

    // Optional: reject symlinks and non-files
    const st = await fs.lstat(resolved);
    if (!st.isFile() || st.isSymbolicLink()) {
      return res.status(404).send("File not found");
    }

    return res.download(resolved, safeName, (err) => (err ? next(err) : undefined));
  } catch (err) {
    return next(err);
  }
});

Why this works:

  • Static assets are served from a fixed root via express.static, which prevents traversal outside that directory.
  • Downloads use path.basename() to discard any directory components from user input.
  • The final path is resolved against a fixed uploads directory and checked for containment.
  • Rejecting symlinks helps prevent indirect escapes if the uploads directory could be tampered with.
  • Authorization may still be required to prevent IDOR if filenames/links are user-specific.

Fastify with Path Validation

const fastify = require("fastify")();
const path = require("path");
const fs = require("fs/promises");
const fsSync = require("fs");

const ALLOWED_DIR = path.resolve("./files");

fastify.get("/file/:name", async (request, reply) => {
  const safeName = path.basename(String(request.params.name)).trim();
  if (!safeName || safeName === "." || safeName === "..") {
    return reply.code(400).send({ error: "Invalid filename" });
  }

  const resolved = path.resolve(ALLOWED_DIR, safeName);

  // Defense-in-depth containment check
  if (!(resolved === ALLOWED_DIR || resolved.startsWith(ALLOWED_DIR + path.sep))) {
    return reply.code(403).send({ error: "Access denied" });
  }

  try {
    const st = await fs.lstat(resolved);
    if (!st.isFile() || st.isSymbolicLink()) {
      return reply.code(404).send({ error: "File not found" });
    }

    reply.type("application/octet-stream");
    return reply.send(fsSync.createReadStream(resolved));
  } catch {
    return reply.code(404).send({ error: "File not found" });
  }
});

Why this works:

  • path.basename() discards directory components so user input cannot specify a path.
  • The server resolves the final path against a fixed base directory and enforces containment.
  • Files are streamed instead of loaded fully into memory.
  • lstat() can reject symlinks to reduce indirect escapes if the directory might be attacker-writable.

Next.js API Route

// pages/api/download.js
import path from "path";
import fs from "fs";
import fsp from "fs/promises";

const UPLOADS_DIR = path.resolve(process.cwd(), "uploads");

export default async function handler(req, res) {
  const filename = req.query.filename;
  if (!filename) return res.status(400).json({ error: "Filename required" });

  const safeName = path.basename(String(filename)).trim();
  if (!safeName || safeName === "." || safeName === "..") {
    return res.status(400).json({ error: "Invalid filename" });
  }

  const resolved = path.resolve(UPLOADS_DIR, safeName);

  if (!(resolved === UPLOADS_DIR || resolved.startsWith(UPLOADS_DIR + path.sep))) {
    return res.status(403).json({ error: "Access denied" });
  }

  try {
    const st = await fsp.lstat(resolved);
    if (!st.isFile() || st.isSymbolicLink()) {
      return res.status(404).json({ error: "File not found" });
    }

    res.setHeader("Content-Type", "application/octet-stream");
    res.setHeader("Content-Disposition", `attachment; filename="${safeName}"`);
    res.setHeader("Content-Length", String(st.size));

    const stream = fs.createReadStream(resolved);
    stream.on("error", () => {
      if (!res.headersSent) res.status(404).json({ error: "File not found" });
      else res.destroy();
    });
    return stream.pipe(res);
  } catch {
    return res.status(404).json({ error: "File not found" });
  }
}

Why this works:

  • path.basename() discards directory components so user input cannot specify a path.
  • The file path is resolved against a fixed base directory and checked for containment.
  • lstat() can reject symlinks and non-files to reduce indirect escapes.
  • The file is streamed (not fully buffered), reducing memory DoS risk.
  • Appropriate download headers are set for predictable client behavior.

Typical Path Traversal Findings

  1. "User input used to construct file path without validation"

    • Location: fs.readFile('./uploads/' + req.query.file)
    • Fix: Use canonical path validation or indirect references
  2. "Path traversal via URL parameter"

    • Location: path.join('./data', req.params.filename)
    • Fix: Add path.resolve() validation to ensure path stays within base directory
  3. "Arbitrary file access via file parameter"

    • Location: File download endpoint with user-controlled path
    • Fix: Use path.basename() to remove directory components, or use ID-based mapping
  4. "Zip Slip vulnerability in archive extraction"

    • Location: zip.extractAllTo() without entry validation
    • Fix: Validate each entry path before extraction
  5. "Dynamic require with user input"

    • Location: require('./plugins/' + pluginName)
    • Fix: Use allowlist of pre-loaded modules

Remediation Steps

  1. Locate every filesystem sink that uses request, route, form, upload, archive, cookie, or database values: fs.readFile, streams, sendFile, download, unlink, upload handlers, static roots, archive extraction, and dynamic module loading.
  2. Determine whether the user should select a file at all. Prefer opaque IDs mapped to server-controlled paths when files are user- or tenant-specific.
  3. If a path segment must be accepted, resolve it against a fixed base directory and enforce containment with the exact resolved path that will be used.
  4. Use allowlists for templates, plugin names, report names, and other finite choices instead of accepting arbitrary filenames.
  5. For writable directories, uploads, and archive extraction, account for symlinks, overwrite behavior, file count, total extracted size, and ownership/authorization.
  6. Add focused tests and scanner rules for every read, write, delete, static-file, and archive path before closing the finding.

Testing

  • Test normal allowed files, missing files, nested allowed paths if supported, and filenames containing spaces or Unicode.
  • Test traversal payloads such as ../, ..%2f, ..\\, absolute paths, URL-encoded paths, double-encoded paths, and mixed separators.
  • Test prefix bypasses such as base-sibling directories (uploads_evil) and paths that differ only by case on case-insensitive filesystems.
  • Test symlink escapes from writable directories and uploaded archives when the platform and library support symlinks.
  • Test Zip Slip entries such as ../../app.js, absolute paths, hidden files, very large entry counts, and overwrite attempts.
  • Test authorization separately from path safety so one user cannot access another user's valid in-base file.

Common Pitfalls

  • Assuming path.join() or path.normalize() prevents traversal by itself.
  • Checking for ".." or "/" instead of validating the canonical path that will actually be opened.
  • Validating a path and then using a different string for fs, res.sendFile, or archive extraction.
  • Forgetting Windows backslashes, absolute paths, drive letters, and case-insensitive filesystem behavior.
  • Validating reads but leaving writes, deletes, uploads, and archive extraction unprotected.
  • Treating path containment as authorization; users may still access another user's file inside the allowed directory.

Dependencies and Installation

  • Node's built-in path, fs, and fs/promises modules provide the core path resolution and file type checks.
  • Express res.sendFile(), res.download(), and express.static() are safe only when configured with fixed roots or already-validated absolute paths.
  • Upload and archive libraries such as Multer and AdmZip still require application-level path, size, overwrite, and authorization checks.
  • Keep file-serving, upload, and archive packages current, and avoid libraries that hide extraction paths or do not expose entry-level validation hooks.

Security Checklist

  • Never use string concatenation to build file paths with user input
  • Use path.resolve() to get canonical paths and validate they're within allowed directory
  • Use path.basename() to extract filename without directory components
  • Implement indirect file references (ID mapping) instead of exposing filenames
  • Use allowlists for file/template names when possible
  • Validate all archive entries before extraction (prevent Zip Slip)
  • Never use dynamic require() with user input
  • Set Express static middleware options: dotfiles: 'deny', index: false
  • Use res.download() or res.sendFile() with validated paths
  • Test with payloads: ../, ..%2F, absolute paths, double encoding
  • Validate paths on both read and write/delete operations
  • Consider using chroot/jail for file operations in high-security contexts

Additional Resources