Mesrai
Back to blog
// essaySecurity

SSRF in Webhook Handlers: The Internal-IP Filter Mesrai Suggests

Real Mesrai catch on a webhook tester: server-side request forgery against cloud metadata + internal services. CWE-918 with attack + DNS-validation fix.

Mesrai TeamJuly 20, 20269 min read

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

tsapi/webhook-test.ts
// 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

textexploit.txt
# 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

textreview-comment.txt
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-918

The fix

tsapi/webhook-test.ts (fixed)
// 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.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.