← all posts
2026-04-22 · 5 min read

the SSRF hiding in your webhook validator

The webhook test endpoint is the SSRF sink everyone forgets. Here is the shape it almost always takes.

priya s.security engineer
#ssrf#webhooks#case-study

Webhook-validator SSRF is one of the most structurally repetitive bugs there is — when net-03 finds it, it is almost always in the same place: the "test this webhook URL" endpoint that the dashboard hits when you wire up an integration. It is the kind of endpoint that rarely makes it into a threat model.

the shape

common pattern// PATCH /v1/integrations/:id/test
// "test the webhook by POSTing a sample event to it"
//
// 1. user submits a destination URL
// 2. backend validates the URL is "syntactically correct"
// 3. backend POSTs a sample event from the application server
// 4. backend returns the response body so the user can debug

POST http://169.254.169.254/latest/meta-data/iam/security-credentials/...
→ 200 OK, body returned to the caller verbatim. cloud creds exfiltrated.

The vulnerable version almost always has a "URL must be HTTPS" check — and no SSRF allowlist or deny-list for RFC 1918 + metadata IPs. It returns the response body to the caller for "debugging purposes," and the endpoint has usually not been reviewed in months.

why an agent finds this and a scanner does not

Scanners check "is the URL parameter user-controlled?" and stop. They cannot tell whether the response is returned to the caller, whether the destination is restricted, or whether the request goes out from a context with cloud metadata access. net-03 does all three: hypothesizes the sink, attempts a metadata fetch, observes the response. Three steps. Twelve seconds.

the patch

lib/ssrf-guard.tsimport { isIP, lookup } from 'node:net';

const DENY = [
  /^127\./,
  /^10\./,
  /^192\.168\./,
  /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
  /^169\.254\./,           // link-local + metadata
  /^::1$/,
  /^fc[0-9a-f]{2}:/,        // RFC 4193
];

export async function assertExternalUrl(url: string): Promise<void> {
  const u = new URL(url);
  if (u.protocol !== 'https:') throw new Error('https only');
  const ip = isIP(u.hostname) ? u.hostname : await resolveOnce(u.hostname);
  if (DENY.some((re) => re.test(ip))) throw new Error('disallowed destination');
}