CLUSTER · SERVER-SIDE TRUST/ 2026-05-20/ 10 min read

The webhook URL field nobody validates

Every small SaaS eventually adds a "send webhook on event X" feature. Almost none of them validate the URL field properly. The bug is the same every time — and it's been the same since 2017.

If you ever wrote a settings page where the user pastes a URL and your server later POSTs to it, you have written an SSRF surface. You may have written a good one. The odds are against it.

I'm going to walk through the three flavors of broken webhook URL handling we keep finding on small-product audits, and then write down the validation that — done once, in one place — kills all of them. If you ship a webhook feature and don't have this validator, today is the day.

What we mean by "the webhook URL field"

You probably have a settings page that looks like this:

POST /api/integrations/webhook
{
  "event": "order.created",
  "url":   "https://hooks.customer.example/notify"
}

Later, when an order is created, your server runs something like:

const wh = await db.webhook.findFirst({ where: { teamId, event } });
await fetch(wh.url, { method: 'POST', body: JSON.stringify(payload) });

This is the bug. Not because fetch is evil. Because wh.url is a URL chosen by the user, and your server is now reaching out to it from inside your VPC, with whatever default trust your environment grants to "things coming from our own IP."

In a cloud environment, that default trust is almost always more than zero.

Three patterns we keep finding

1. Plain old IMDS

The user sets their webhook URL to http://169.254.169.254/latest/meta-data/iam/security-credentials/. On AWS, that's the instance metadata endpoint. Your server POSTs to it from inside the VPC, and… well, that depends on your IMDS configuration.

If you're on IMDSv1, the body of the response — your IAM credentials — comes back to a URL the attacker chose. Some webhook implementations log the response body. Some retry on failure and surface the response in an admin UI — and if the body ends up cached by a CDN, congratulations, you've now combined this bug with the one from the signed-URL post. Either way, the credentials have left the building.

IMDSv2 helps, but does not save you on its own. We've seen products where the webhook implementation specifically follows redirects, and an attacker-controlled URL redirects to IMDS with a chain that smuggles the right header. The right answer is not "trust IMDSv2." The right answer is "never let user-controlled URLs go to link-local space."

2. The "internal admin" lateral move

This is the one founders find genuinely surprising. Your product has an internal admin tool at http://admin.internal:8080, accessible from inside the cluster. It is "not exposed to the internet." It is also reachable from your webhook sender.

An attacker sets their webhook URL to http://admin.internal:8080/users/grant-staff?email=attacker@… and waits for any event to trigger. The admin tool is — of course — a GET-or-POST-it-doesn't-care kind of tool, and an unauthenticated POST from "inside the network" is treated as trusted.

We've found this exact pattern three times in the last year, twice on products where the team swore the admin tool was air-gapped. It wasn't. The webhook sender was the air gap, and the air gap was the attacker.

3. The "DNS rebinding because you remembered the textbook attack but not the whole textbook"

A diligent team adds an allowlist: only HTTPS, no private IPs, no localhost, no link-local. Good. Then they implement the check like this:

// resolve once for the check
const ip = await dns.lookup(parsedUrl.hostname);
if (isPrivate(ip)) throw new Error('private');
// then fetch — which resolves again
await fetch(url);

Two DNS lookups. Different answers. The attacker controls the resolver and returns a public IP on the first lookup and 127.0.0.1 on the second. This is DNS rebinding, and it has been documented since the 1990s, and it still ships.

The fix is to resolve once, and pass the resolved IP — not the hostname — to the fetch. Or to use an HTTP client that lets you pin the IP. We have notes on which clients in which languages get this right; reach out and we'll send you the matrix.

The validation that kills all of them

If you have one place in your code where you decide whether a user-supplied URL is OK to fetch, here is what it should look like. I'm writing it in Node because most of our small-SaaS work is there, but translate freely.

import { lookup } from 'node:dns/promises';
import net from 'node:net';

const BLOCK_CIDRS = [
  '0.0.0.0/8',        // current network
  '10.0.0.0/8',       // RFC1918
  '100.64.0.0/10',    // CGNAT
  '127.0.0.0/8',      // loopback
  '169.254.0.0/16',   // link-local (IMDS!)
  '172.16.0.0/12',    // RFC1918
  '192.0.0.0/24',
  '192.168.0.0/16',   // RFC1918
  '198.18.0.0/15',
  '224.0.0.0/4',      // multicast
  '240.0.0.0/4',      // reserved
];

function inCidr(ip, cidr) { /* … standard CIDR check … */ }

export async function resolveSafe(url) {
  const u = new URL(url);
  if (u.protocol !== 'https:') throw new Error('protocol');
  if (u.username || u.password) throw new Error('userinfo');

  const { address, family } = await lookup(u.hostname, { verbatim: true });
  if (family !== 4) throw new Error('v4-only for now');
  if (!net.isIPv4(address)) throw new Error('bad-ip');
  for (const c of BLOCK_CIDRS) {
    if (inCidr(address, c)) throw new Error('private:' + c);
  }
  // important — return the resolved IP, not the hostname
  return { ip: address, hostHeader: u.host, path: u.pathname + u.search };
}

Then your fetcher uses the IP, sets the Host header manually, refuses redirects (or re-validates each hop), and lives in a process that does not have IAM credentials beyond what the webhook job actually needs.

I am intentionally skipping IPv6. Not because it doesn't matter, but because you should pick one — block IPv6 entirely for outbound user-URL fetches, or write a real v6-aware allow/block list. The middle ground (allow v6 but only check v4) is where the next CVE comes from.

One thing your provider can do for you

If you're on AWS and you use a NAT gateway for outbound traffic from your app, the cheapest single move that buys you the most coverage is: put your webhook-sender workload in a subnet whose only outbound route is to a NAT that blocks RFC1918 and link-local at the network layer.

This will not save you from a redirect chain you didn't filter. It will save you from being one bad fetch call away from IAM credentials. It buys you a week of breathing room while you ship the real validator.

What we look at on the audit

Concretely, here's what the SSRF half of our first-audit week looks like for a small product with webhooks:

  1. Find every place in the code where a URL from a database row or a request body is passed to fetch, http.request, curl, an SDK like got/axios, or a queue worker's HTTP client.
  2. For each, ask: is this URL ever user-controlled, even transitively? OAuth callbacks count. OpenGraph fetchers count. Avatar imports count. "Just our admin" is not an answer — every admin tool has a way for users to indirectly influence it, the same way an unscoped billing route ends up being a tenant-crossing oracle.
  3. For each user-controllable site: trace whether the URL goes through a central validator, and whether that validator does the DNS-rebinding-safe resolution above.
  4. If there's no central validator, you've got at minimum a finding for the validator's absence, plus one per call site. We write them as a single section with a list, not a finding per site.

The validator's absence is, in our experience, more valuable to find than any individual SSRF, because it tells the team where to ship the fix once instead of N times.

If you ship a webhook feature

You have until your next free Friday to write down — somewhere — the exact validator your code uses for outbound user URLs. If you can't point to one, that's your bug.

This is the kind of issue that costs almost nothing to fix in week three of a product's life and is genuinely painful to fix in year three, because by then the validator has to be retrofitted into seventeen call sites, each with its own retry semantics and its own logging story. Ship it small. Ship it now.

Cyclopes
Independent security practice — small product audits.

Read next

Have a webhook feature you're not sure about?

The free heads-up includes a focused look at your outbound-URL handling. If your validator is good, we'll tell you. If it isn't, we'll show you what to change.

Request a free check