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

three SSRFs in webhook validators we found in one week

The webhook test endpoint is the SSRF sink everyone forgets. Here is what three different customers shipped, and what they had in common.

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

In one week in April, net-03 found three structurally identical SSRFs at three unrelated customers. All three were in the same place: the "test this webhook URL" endpoint that the dashboard hits when you wire up an integration. None of the customers had a webhook validator pattern in their 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.

All three customers had a "URL must be HTTPS" check. None had an SSRF allowlist or a deny-list for RFC 1918 + metadata IPs. All three returned the response body to the caller for "debugging purposes." None had reviewed the endpoint in over six 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');
}