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() combined with resolvedPath.startsWith() 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: URL encoding: ?file=..%2F..%2Fetc%2Fpasswd
// After automatic URL decode: ?file=../../etc/passwd
// Bypasses includes('..') check done before decoding

// Attack 2: Double encoding: ?file=%252e%252e%252f%252e%252e%252fetc%252fpasswd  
// After double decode: ?file=../../etc/passwd

// Attack 3: Unicode: ?file=..%c0%af..%c0%afetc%c0%afpasswd
// Malformed UTF-8 sequences may bypass checks

Why this is vulnerable: Denylist approaches fail because attackers have endless encoding variations: URL encoding (%2e%2e%2f), double encoding, Unicode normalization, backslashes on Windows, and combinations. Express automatically decodes URLs, so encoded sequences bypass string checks but become active after decoding. The only reliable defense is canonical path validation using path.resolve() with startsWith() checking, which validates the final decoded path regardless of encoding tricks.

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) {
  const base = uploadsBase + path.sep;
  if (!fullPath.startsWith(base)) 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: isPathSafe returns false, 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 = 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 Path Traversal Patterns

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

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