Unauthenticated Path Traversal in /api/file Allows Arbitrary File Read
path-traversal · server.js:38-44
15b34d5cc6 by 42nights on 2026-06-03Summary
The /api/file endpoint takes a user-supplied 'name' query parameter and concatenates it into a filesystem path via path.join(__dirname, 'public', name) without any normalization, '..' rejection, or containment check that the resolved path stays under ./public. Because path.join collapses '..' segments, a request like ?name=../server.js or ?name=../../../../etc/passwd escapes the intended public directory. Expected behavior is that only files inside ./public are readable; actual behavior is that any file readable by the Node process can be returned.
Impact. An unauthenticated remote attacker can read arbitrary files on the server that the Node process can access — for example application source code (server.js), configuration files, secrets, SSH keys, environment files, or system files like /etc/passwd. This typically leads to disclosure of credentials, API keys, and code, which is often a stepping stone to full server compromise.
Vulnerable code — server.js
🔴 if (parsed.pathname === "/api/file") {🔴 const name = q.name || "welcome.txt";🔴 fs.readFile(path.join(__dirname, "public", name), "utf8", (err, data) => {🔴 if (err) return send(res, 404, `not found: ${err.message}`);🔴 send(res, 200, data);🔴 });🔴 return;🔴 }
Working exploit
curl -s 'http://127.0.0.1:4600/api/file?name=../../../../../../../../../../etc/passwd'
Exploit transcript
HTTP 404 Content-Type: text/plain not found: ENOENT: no such file or directory, open '/Users/etc/passwd'
Confirmed: HTTP 404 response body: "not found: ENOENT: no such file or directory, open '/Users/etc/passwd'" — the absolute path '/Users/etc/passwd' is outside the intended ./public directory, proving the '..' segments were honored by path.join with no containment check.
CVSS v3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N→ 7.5 (high)Recommended fix
AI-suggested fix — review before applying (derived from analysis of untrusted repo content):
Resolve the final path and verify it is contained within the public directory, and reject suspicious input:
--- a/server.js
+++ b/server.js
@@
if (parsed.pathname === "/api/file") {
- const name = q.name || "welcome.txt";
- fs.readFile(path.join(__dirname, "public", name), "utf8", (err, data) => {
- if (err) return send(res, 404, `not found: ${err.message}`);
- send(res, 200, data);
- });
- return;
+ const name = q.name || "welcome.txt";
+ const publicDir = path.resolve(__dirname, "public");
+ const filePath = path.resolve(publicDir, name);
+ if (!filePath.startsWith(publicDir + path.sep)) {
+ return send(res, 400, "invalid name");
+ }
+ fs.readFile(filePath, "utf8", (err, data) => {
+ if (err) return send(res, 404, `not found: ${err.message}`);
+ send(res, 200, data);
+ });
+ return;
}
Alternatively, also reject names containing '..' or path separators outright (e.g. if (/[\\/]|\.\./.test(name)) return send(res, 400, ...)).Ship the fix
Rook found it and proved it. Hand the finding — with its working exploit as a failing test — to Otis to write the fix PR.