Back to Blog
Security

SSRF in Webhook Senders: Protecting Your Infrastructure from User-Supplied URLs

When your platform sends webhooks to user-configured URLs, those URLs can point anywhere—including your cloud metadata service. DNS rebinding, private IP ranges, and redirect chains explained.

WebhookVault Team · Webhook Infrastructure Experts9 min read
JavaScript code with conditional logic and variable declarations in a dark code editor, showing line numbers and teal and magenta syntax highlighting

The Internal Request Nobody Authorized

A few years ago I was reviewing the security posture of a small SaaS platform. It let customers configure webhook endpoints for payment events, homegrown Stripe-style. A penetration tester had flagged something in their audit: by registering http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name as a webhook endpoint, they had received the platform's AWS IAM temporary credentials in the delivery logs. The platform was faithfully fetching that URL on every failed payment and writing the response body to its own database for debugging.

SSRF, server-side request forgery, is well documented in web security literature. In webhook delivery it has a shape that is easy to miss. The platform was not processing a user-supplied URL as data. It was making an HTTP request to that URL as part of its core function. The attack requires no unusual input handling. The request is made deliberately. You just pointed it at a target the attacker chose.

Why Webhook Senders Face This Risk

Building a webhook sender means building something that makes HTTP requests to arbitrary destinations on behalf of your users. That includes localhost, internal microservices, cloud metadata endpoints, and any host on the private network your delivery workers sit on.

A CI/CD platform that fires webhooks to a developer-supplied URL can be aimed at an internal Jenkins admin panel. A payment platform at the cloud metadata service. A CRM sending contact-created webhooks at 192.168.1.1, the admin interface of a router on the same network segment as your workers. These are real attack classes against real platforms, not thought experiments.

Once you accept user-submitted URLs, protection is not optional. The only question is how complete it is, because partial protection creates a false sense of security that is worse than none.

The Private IP Ranges You Must Block

Validate the destination URL before making any request. Three ranges need to be on your blocklist.

Private RFC 1918 space covers 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16. These are never routable on the public internet. No legitimate production webhook consumer will have an address in them.

Loopback covers 127.0.0.0/8 for IPv4 and ::1 for IPv6. A request to 127.0.0.1 hits the server making the request. Anything listening on localhost, an admin HTTP interface or a debug port, is accessible from that request.

Link-local covers 169.254.0.0/16 for IPv4 and fe80::/10 for IPv6. Cloud metadata endpoints live here. APIPA addresses on Windows hosts that failed to reach a DHCP server also land in this range. Blocking it entirely carries no false-positive risk. No production endpoint uses a link-local address.

import * as net from 'net'
import * as dns from 'dns/promises'

interface CIDRBlock {
  range: string
  prefix: number
}

const BLOCKED_CIDRS_V4: CIDRBlock[] = [
  { range: '10.0.0.0', prefix: 8 },
  { range: '172.16.0.0', prefix: 12 },
  { range: '192.168.0.0', prefix: 16 },
  { range: '127.0.0.0', prefix: 8 },
  { range: '169.254.0.0', prefix: 16 },
  { range: '100.64.0.0', prefix: 10 },  // Carrier-grade NAT
]

function ipv4ToLong(ip: string): number {
  return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0
}

function isBlockedIPv4(ip: string): boolean {
  const ipLong = ipv4ToLong(ip)
  return BLOCKED_CIDRS_V4.some(({ range, prefix }) => {
    const rangeLong = ipv4ToLong(range)
    const mask = (0xffffffff << (32 - prefix)) >>> 0
    return (ipLong & mask) === (rangeLong & mask)
  })
}

export async function resolveAndValidate(urlString: string): Promise<string> {
  const parsed = new URL(urlString)
  const hostname = parsed.hostname

  if (net.isIPv4(hostname)) {
    if (isBlockedIPv4(hostname)) {
      throw new Error('Webhook endpoint URL is not allowed')
    }
    return hostname
  }

  const addresses = await dns.resolve4(hostname)
  for (const addr of addresses) {
    if (isBlockedIPv4(addr)) {
      throw new Error('Webhook endpoint URL is not allowed')
    }
  }
  return addresses[0]
}

The error message deliberately omits the resolved IP. Returning blocked: hostname resolved to 169.254.169.254 tells the attacker that your metadata endpoint is reachable from the delivery network. A generic denial gives them nothing useful.

DNS Rebinding: The Gap Between Registration and Delivery

Registration-time IP blocking catches obvious cases. It does not stop DNS rebinding.

An attacker registers https://attacker-controlled.example as their webhook endpoint. Your validation resolves the hostname and sees a public IP, say 93.184.216.34. The URL gets stored. Hours later, your delivery worker picks up a batch of events. By then the attacker has flipped the DNS record: attacker-controlled.example now points to 169.254.169.254. The second DNS lookup, the one your HTTP client makes when it actually opens the connection, returns the metadata endpoint. Your server delivers the webhook to it.

The fix: resolve once, validate, then connect directly to that IP. Node's https module lets you pass the target address as a separate field in RequestOptions, setting Host independently so TLS SNI and virtual hosting still work:

import * as http from 'http'
import * as https from 'https'

export async function deliverWebhook(
  urlString: string,
  body: string,
  signature: string
): Promise<number> {
  const parsed = new URL(urlString)
  const resolvedIP = await resolveAndValidate(urlString)

  const options: https.RequestOptions = {
    hostname: resolvedIP,
    host: resolvedIP,
    port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
    path: parsed.pathname + parsed.search,
    method: 'POST',
    headers: {
      'Host': parsed.hostname,
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(body),
      'Webhook-Signature': signature,
    },
    rejectUnauthorized: true,
    timeout: 10_000,
  }

  return new Promise((resolve, reject) => {
    const mod = parsed.protocol === 'https:' ? https : http
    const req = mod.request(options, (res) => {
      res.resume()
      resolve(res.statusCode ?? 0)
    })
    req.on('error', reject)
    req.on('timeout', () => { req.destroy(); reject(new Error('Delivery timeout')) })
    req.write(body)
    req.end()
  })
}

Whatever DNS says at connection time is irrelevant. The socket goes to the IP you already validated.

Cloud Metadata Endpoints Are a Special Case

169.254.169.254 is the most dangerous target on the list and the easiest to miss if you only block RFC 1918 ranges. AWS, GCP, and Azure all expose instance metadata at link-local addresses. These endpoints return IAM credentials, service account tokens, and instance identity documents with no authentication from the local network.

Alibaba Cloud uses 100.100.100.200, which falls in the carrier-grade NAT range (100.64.0.0/10) rather than link-local. Oracle Cloud uses 169.254.0.2. The blocklist in the code above already covers 100.64.0.0/10 for the Alibaba case. If you are running workloads in an environment whose metadata addresses you do not recognise, check the provider's documentation and add those ranges explicitly.

None of these addresses will ever be a legitimate webhook destination. Block entire ranges, not individual addresses. Provider infrastructure changes; a list of specific IPs drifts.

Redirects Open a Second Door

An attacker registers https://legitimate-looking-domain.com/webhooks as their endpoint. Your validation resolves the hostname to a public IP and accepts it. During delivery, the endpoint returns a 301 Redirect to http://169.254.169.254/latest/meta-data/. If your HTTP client follows redirects automatically, you have delivered the webhook to the metadata service.

Do not follow redirects. Return the 3xx status code as the delivery result and log it for investigation. Legitimate webhook consumers do not redirect POST requests. A CDN in front of a receiver forwards POST requests transparently. Load balancers proxy them. Neither sends a 3xx back to the webhook sender.

const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)

try {
  const response = await fetch(url, {
    method: 'POST',
    body: payload,
    redirect: 'error',
    signal: controller.signal,
    headers: { 'Content-Type': 'application/json' },
  })
  // status 2xx = delivered, 4xx = rejected, 5xx = retry
  return response.status
} finally {
  clearTimeout(timeout)
}

There are cases where enterprise load balancers do redirect POST requests. If a customer reports this, validate the redirect target with the same IP-resolution logic before following it. One level deep, no more.

Timeout and Body Size Limits

Slow-drip responses are the second abuse vector beyond SSRF itself. A receiver can hold a connection open for minutes by trickling back response bytes. If your delivery workers run one thread or async task per in-flight request, an attacker with a handful of endpoint registrations can exhaust the pool. Set a total request timeout that covers the entire round trip including response download. Ten seconds is enough for any legitimate consumer. One that cannot acknowledge within ten seconds has a problem that faster retrying will not fix.

Response body size matters for related reasons. Your delivery worker has no operational need to read more than a few hundred bytes of the response. Read the status code, capture a small excerpt for delivery logs if you store them, discard the rest. An endpoint streaming megabytes back on every delivery is either broken or probing whether your system persists response content somewhere.

Registration-Time Versus Delivery-Time Checks

Registration-time validation gives developers immediate feedback. Type http://192.168.1.1 and you get an error right then, not after watching the first delivery attempt fail silently. It also keeps your endpoint table free of URLs you will never successfully reach.

Delivery-time validation catches things registration cannot. Public IPs get reassigned. Hostnames get taken over. A URL that was fine six months ago may now resolve somewhere private. You cannot know this at registration time.

Run both checks. Log SSRF-blocked deliveries as a distinct failure reason in your retry queue, separate from connection refused and timeout. That distinction makes it possible to alert on SSRF attempts rather than letting them vanish into general failure noise.

What the Major Platforms Do

Stripe validates webhook endpoint URLs at registration and blocks private IP ranges. Their documentation states that webhook deliveries require HTTPS and that self-signed certificates are rejected. GitHub's webhook infrastructure rejects private and loopback destinations. Neither platform publishes the full details of their internal blocklists.

Svix documents their SSRF mitigations explicitly: DNS resolution before connection, RFC 1918 and link-local blocking, redirect refusal, hard delivery timeouts. Worth cross-checking against your own implementation.

If you are delegating delivery to Svix, Hookdeck, or a similar provider, confirm which protections apply at the infrastructure layer. Do not assume. URLs your users register still flow through your application first, and filtering at that layer is your responsibility regardless of what happens downstream.

Frequently asked questions about SSRF in webhook senders

Why can't I just block the literal address 169.254.169.254 rather than the entire link-local range?

Because cloud providers sometimes use other addresses in the link-local range for metadata and health services, and because blocking a single address is fragile: providers update their infrastructure and new endpoints appear. Blocking the entire 169.254.0.0/16 range eliminates the entire class of link-local targets in IPv4 at once. The cost is zero: no legitimate production webhook consumer has a link-local address.

What if a customer legitimately needs to receive webhooks on their private network?

The correct architecture is a webhook relay: a small, publicly-addressable proxy on the customer's network that receives events and forwards them inward. This is exactly how ngrok, Cloudflare Tunnels, and similar tools work. The customer controls the relay and the internal routing; your platform delivers to a public address and stays out of their private network entirely. Internal delivery via a public relay benefits both parties: you avoid SSRF surface area, and the customer retains full control over what enters their internal network.

Does blocking private IP ranges at registration time count as complete SSRF protection?

No. Registration-time IP blocking is the first layer, not the whole defence. DNS rebinding bypasses it entirely: the hostname resolves to a public IP at registration, and later resolves to a private IP at delivery. Complete protection requires resolving DNS at delivery time and connecting to the resolved IP directly, so that a changed DNS record cannot redirect the actual HTTP request. Both layers are necessary.

Should I block HTTP entirely and require HTTPS for webhook endpoints?

Yes, for production endpoints. HTTP webhook delivery exposes the payload and any signature headers to network interception. Requiring HTTPS also eliminates a class of SSRF where the attacker is targeting HTTP-only internal services that would reject HTTPS connections, narrowing the attack surface slightly. The practical cost is low: any legitimate webhook receiver in production should have a valid TLS certificate, and self-signed certificates should be rejected rather than accepted with verification disabled.

Related posts