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 withArray.isArray(value), before validating the path. - Reject absolute paths. Use
path.relativeto verify that the final path calculated bypath.resolvestays inside the base directory. - If symbolic links may exist, use
realpathand 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
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
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.
curl "http://localhost:3000/download?file=report.txt&file=../../../../etc/passwd"
In this request, req.query.file may be an array like this:
["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.