A new webhook-tester endpoint — paste a URL, server fires a request, response comes back. Useful internally for debugging integrations. Catastrophic without input validation.
The vulnerable diff
// api/webhook-test.ts
webhookRouter.post("/test", requireAuth, async (req, res) => {
// BUG: no validation of req.body.url
const resp = await fetch(req.body.url, {
signal: AbortSignal.timeout(5_000),
});
res.json({
status: resp.status,
body: await resp.text(),
});
});What is wrong
The server fetches the URL the caller supplied. The server runs inside a cloud VPC with access to internal services, sibling endpoints, the cloud metadata service. The caller has none of those things. SSRF lets the caller use the server as a proxy into the trust boundary — fetch internal admin pages, read instance metadata, scan internal IPs. This is CWE-918, Server-Side Request Forgery.
The attack
# AWS instance metadata (IMDSv1 — many services still permit it):
POST /api/webhook/test
{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }
# Returns IAM role names. Follow up with:
{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>" }
# Returns temporary AWS credentials.
# Internal Redis:
{ "url": "http://localhost:6379/INFO" }
# Internal admin dashboard:
{ "url": "http://admin.internal.example/users" }AWS IMDSv2 makes this attack harder (requires a token-fetching PUT request), but plenty of services still permit IMDSv1, and many cloud providers have their own equivalent reachable via 169.254.169.254 or similar link-local addresses. The general defense is the same: never let untrusted callers choose the destination IP.
Mesrai's review comment
mesraipilot · Bot · reviewed 5 min ago
[mesrai] [code-review] [Security] [CWE-918] [critical]
`fetch(req.body.url)` is server-side request forgery. The server runs
inside your VPC with access to:
- cloud metadata (http://169.254.169.254)
- internal services (http://localhost:6379, http://admin.internal)
- any host reachable on the private network
Validate before the fetch. Three layers:
1. Parse the URL; require http or https.
2. Resolve the hostname to an IP and check it is public.
3. (Bind-and-connect protection) Reject the request before the
network call, not after — DNS rebinding can change the IP
between the check and the fetch. Use a `fetch` wrapper that
re-resolves and re-checks at connect time.
For a webhook tester, also consider an allowlist of partner domains
instead of accepting arbitrary URLs.
Reference: OWASP SSRF Prevention Cheat Sheet, CWE-918The fix
// api/webhook-test.ts — fixed
import dns from "dns/promises";
import net from "net";
import ipaddr from "ipaddr.js";
const PRIVATE_RANGES = [
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8",
"::1/128", "fc00::/7", "fe80::/10",
];
function isPrivateIp(ip: string): boolean {
const parsed = ipaddr.parse(ip);
return PRIVATE_RANGES.some(cidr => {
const [addr, bits] = ipaddr.parseCIDR(cidr) as any;
return (parsed as any).match(addr, bits);
});
}
async function assertPublicUrl(raw: string) {
const u = new URL(raw);
if (u.protocol !== "https:" && u.protocol !== "http:") {
throw new Error("unsupported scheme");
}
const { address } = await dns.lookup(u.hostname);
if (net.isIP(address) === 0) throw new Error("invalid host");
if (isPrivateIp(address)) throw new Error("private network blocked");
return u;
}
webhookRouter.post("/test", requireAuth, async (req, res) => {
let url: URL;
try { url = await assertPublicUrl(req.body.url); }
catch (e: any) { return res.status(400).json({ error: e.message }); }
const resp = await fetch(url, { signal: AbortSignal.timeout(5_000) });
res.json({ status: resp.status, body: await resp.text() });
});Three layers in the fix: scheme allowlist, DNS lookup with an explicit IP check, and a private-range blocklist that covers the common cloud-metadata and intranet ranges. For full DNS-rebinding protection you also need to pin the IP between resolve and connect — production-grade SSRF defenses use a custom Agent or a node-fetch wrapper that intercepts the connect step.
Why human review missed it
SSRF was on the OWASP Top 10 for the first time in 2021 — many senior engineers built their threat-modeling instincts before SSRF was a marquee class. The fetch line reads as benign; the threat lives in the network topology around it. Mesrai's rule pack catches every `fetch` against untrusted input where there is no preceding URL-validation function.
Related rules + further reading
Mesrai rule pack: security/no-ssrf — flags `fetch`/`axios`/`http.get` against unvalidated URLs.
OWASP Cheat Sheet: Server Side Request Forgery Prevention.
CWE-918 — Server-Side Request Forgery (SSRF). 2021 OWASP Top 10 entry.
Takeaway
Every server-side fetch against caller-supplied URLs needs a guard. Scheme allowlist, DNS resolve, private-IP block. Mesrai automates the audit on every PR — the only remaining work is deciding which URLs the application actually needs to reach.