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.
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');
}