Unauthenticated SSRF via /api/fetch enables internal network access through the server
auth-bypass · server.js:63-74
f87c6fc2fa by Macintosh1011 on 2026-05-30Summary
The /api/fetch endpoint at server.js:63 accepts an arbitrary user-supplied URL via the `url` query parameter and issues an outbound HTTP request, returning the first 500 bytes of the response to the caller. There is no authentication, no allowlist of hosts/schemes, and no filtering of private/loopback/link-local address ranges. The validation request `curl 'http://127.0.0.1:4600/api/fetch?url=http://127.0.0.1:4600/'` successfully fetched the server's own loopback endpoint, confirming the SSRF.
Impact. Any unauthenticated remote attacker can use the server as a proxy to reach systems they could not otherwise reach: internal services on the host (127.0.0.1), other machines on the internal/VPC network, and cloud metadata endpoints (e.g. http://169.254.169.254/ on AWS) which often expose IAM credentials. Because the response body is reflected back to the caller, the attacker can exfiltrate data from those internal services and pivot deeper into the infrastructure.
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: fetched http://127.0.0.1:4600/: vulnerable-app: try /api/ping /api/file /api/notes /search /api/fetch
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, restrict to an explicit allowlist, and resolve+validate the target host against private ranges before connecting. Example patch:
```diff
if (parsed.pathname === "/api/fetch") {
+ if (!isAuthenticated(req)) return send(res, 401, "unauthorized");
const target = q.url;
if (!target) return send(res, 400, "missing url");
- http
- .get(target, (r) => {
+ let u;
+ try { u = new URL(target); } catch { return send(res, 400, "bad url"); }
+ if (!/^https?:$/.test(u.protocol)) return send(res, 400, "scheme not allowed");
+ const ALLOWLIST = new Set(["example.com"]);
+ if (!ALLOWLIST.has(u.hostname)) return send(res, 403, "host not allowed");
+ const dns = require("dns").promises;
+ const net = require("net");
+ const addrs = await dns.lookup(u.hostname, { all: true });
+ const isPrivate = (a) => {
+ if (net.isIP(a) === 4) {
+ return /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a);
+ }
+ return a === "::1" || a.startsWith("fc") || a.startsWith("fd") || a.startsWith("fe80");
+ };
+ if (addrs.some((a) => isPrivate(a.address))) return send(res, 403, "private address");
+ http
+ .get(target, { lookup: () => {} /* re-pin to validated IP */ }, (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;
}
```
Note: to fully prevent DNS-rebinding, perform the lookup once and connect directly to the resolved IP, passing the original hostname as the Host header.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.