Path validation bypass through parameter type confusion

Path validation bypass through parameter type confusion

Description

Node.js servers such as Express may parse repeated query parameters as arrays rather than strings. Code that assumes a string and checks for path traversal with indexOf or includes can be bypassed because these methods behave differently on arrays. If the value is then used with path.resolve or a file response, path validation may fail.

Potential impact

  • Repeated parameters may bypass validation and lead to files outside the restricted directory.
  • String-specific validation can behave unexpectedly on arrays, objects, or other types.
  • Bypassing path traversal defenses may expose sensitive files or allow unintended file access.

Remediation

  • Check typeof value === "string", or reject arrays with Array.isArray(value), before validating the path.
  • Reject absolute paths. Use path.relative to verify that the final path calculated by path.resolve stays inside the base directory.
  • If symbolic links may exist, use realpath and check that the resolved file path also remains inside the directory.
  • Keep the base directory protected from attacker modification so paths cannot change between validation and use.
  • Prefer server-issued file IDs mapped through an allow-list to user-supplied paths.

Examples

Before

javascript
const express = require("express");
const app = express();
const path = require("path");

app.get("/download", (req, res) => {
  const file = req.query.file;
  if (file.indexOf("..") !== -1) {
    return res.status(400).send("Bad request");
  }

  const selectedFile = Array.isArray(file) ? file[file.length - 1] : file;
  return res.sendFile(path.resolve("/srv/public", selectedFile));
});

After

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

const publicDir = fs.realpathSync("/srv/public");

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

app.get("/download", (req, res) => {
  const file = req.query.file;
  if (typeof file !== "string" || path.isAbsolute(file)) {
    return res.status(400).send("Bad request");
  }

  const candidate = path.resolve(publicDir, file);
  if (!isInside(publicDir, candidate)) {
    return res.status(400).send("Bad request");
  }

  let realFile;
  try {
    realFile = fs.realpathSync(candidate);
  } catch {
    return res.status(404).send("Not found");
  }

  if (!isInside(publicDir, realFile)) {
    return res.status(400).send("Bad request");
  }
  return res.sendFile(realFile);
});

Explanation:

  • Before: Validation assumes a string, but has different semantics for arrays and other types.
  • After: Use a base directory that attackers cannot modify. Reject non-string values and absolute paths first, then confirm that both the normalized path and the real path after symbolic-link resolution remain inside the directory before sending the file.

Attack example:

Express query parsing can produce an array when a parameter name is repeated. Then file.indexOf("..") searches for an array element rather than a substring. An array with no element exactly equal to ".." passes the check. If later code selects its last element as the filename, a path traversal string can reach file access unchanged.

bash
curl "http://localhost:3000/download?file=report.txt&file=../../../../etc/passwd"

In this request, req.query.file may be an array like this:

javascript
["report.txt", "../../../../etc/passwd"]

file.indexOf("..") returns -1, so it does not block the request. selectedFile becomes "../../../../etc/passwd", which can request a file outside the restricted directory.

References