Unauthenticated SSRF primitive via /api/fetch performs arbitrary server-side HTTP GETs
auth-bypass · server.js:63-74
15b34d5cc6 by 42nights on 2026-06-03Summary
The /api/fetch handler at server.js:63 accepts a user-controlled `url` query parameter and issues an outbound HTTP GET via `http.get(target, ...)` without any authentication check, session validation, or URL allow-list. The validated response shows an unauthenticated curl request causing the server to fetch http://127.0.0.1:4600/ and return up to 500 bytes of the response body to the caller. Expected behavior is to require an authenticated principal and restrict targets to a vetted allow-list; actual behavior is anonymous, unrestricted server-initiated fetches.
Impact. Anyone on the network — with no login — can make the server fetch URLs of their choosing and read the response back. Attackers can use this to reach internal services that are not exposed to the internet (cloud metadata endpoints like 169.254.169.254, internal admin panels, databases over HTTP, localhost-only management ports), exfiltrate data, perform port/host scanning from inside the trust boundary, and potentially pivot to credential theft (e.g., cloud IAM tokens). It also gives attackers a way to launder requests through the server's IP, bypassing IP-based access controls on third parties.
Vulnerable code — server.js
🔴 if (parsed.pathname === "/api/fetch") {🔴 const target = q.url;🔴 if (!target) return send(res, 400, "missing url");🔴 http🔴 .get(target, (r) => {🔴 let body = "";🔴 r.on("data", (c) => (body += c));🔴 r.on("end", () => send(res, 200, `fetched ${target}:\n${body.slice(0, 500)}`));🔴 })🔴 .on("error", (e) => send(res, 502, `fetch error: ${e.message}`));🔴 return;🔴 }
Working exploit
curl -s 'http://127.0.0.1:4600/api/fetch?url=http://127.0.0.1:4600/'
Exploit transcript
HTTP 200 Content-Type: text/plain fetched http://127.0.0.1:4600/: vulnerable-app: try /api/ping /api/file /api/notes /search /api/fetch
Confirmed: HTTP 200 text/plain body: 'fetched http://127.0.0.1:4600/:\nvulnerable-app: try /api/ping /api/file /api/notes /search /api/fetch' — content originates from the server-side fetch of the internal URL, not from the request input.
CVSS v3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:L→ 9.9 (critical)Recommended fix
AI-suggested fix — review before applying (derived from analysis of untrusted repo content):
Require authentication and constrain the destination. Example patch:
--- a/server.js
+++ b/server.js
@@
if (parsed.pathname === "/api/fetch") {
+ const user = requireAuth(req); // 401 if missing/invalid session or token
+ if (!user) return send(res, 401, "authentication required");
+
const target = q.url;
if (!target) return send(res, 400, "missing url");
- http
- .get(target, (r) => {
+ let parsedTarget;
+ try { parsedTarget = new URL(target); } catch { return send(res, 400, "invalid url"); }
+ const ALLOWED_HOSTS = new Set(["api.partner.example.com"]);
+ if (!/^https?:$/.test(parsedTarget.protocol) || !ALLOWED_HOSTS.has(parsedTarget.hostname)) {
+ return send(res, 403, "target not allowed");
+ }
+ // Resolve and reject private/loopback/link-local IPs to prevent DNS rebinding & internal access.
+ require("dns").lookup(parsedTarget.hostname, (err, address) => {
+ if (err || isPrivateOrLoopback(address)) return send(res, 403, "target not allowed");
+ http.get({ host: address, port: parsedTarget.port || 80, path: parsedTarget.pathname + parsedTarget.search, headers: { Host: parsedTarget.hostname } }, (r) => {
let body = "";
r.on("data", (c) => (body += c));
r.on("end", () => send(res, 200, `fetched ${target}:\n${body.slice(0, 500)}`));
- })
- .on("error", (e) => send(res, 502, `fetch error: ${e.message}`));
+ }).on("error", (e) => send(res, 502, `fetch error: ${e.message}`));
+ });
return;
}
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.