Skip to content

CONCEPT Cited by 1 source

Webhook URL validation

What it is

Webhook URL validation is the set of submission-time checks a webhook-sender service runs against a user-supplied URL before storing or dispatching to it. It is the first layer of a two-layer SSRF defence — the second layer being an isolated egress-proxy tier that re-enforces rules at send time (required because URL validation alone does not defeat DNS rebinding).

The primary value of URL validation is fast, clear feedback to the user — the receiver URL they typed won't work, told at submission time rather than silently via delivery failures. The security value is real but partial: validation catches naive misuse, not a determined attacker.

The four orthogonal checks

Canonicalised from PlanetScale's Ruby-on-Rails implementation in sources/2026-04-21-planetscale-webhook-security-a-hands-on-guide:

1. Require HTTPS

Reject URLs whose scheme is not https. Rationale: webhook payloads often carry sensitive customer data; sending them in plaintext over HTTP is indefensible in 2023+. Verbatim framing: "These days, running a web service without SSL is rare. We felt that making https a requirement for any webhook we send is a fair request."

2. Block private and loopback IPs

If the URL's host parses as an IP literal (not a hostname), check it against private / loopback ranges and reject. Ruby snippet from the post:

uri = URI.parse(url)
host_ip = begin
  IPAddr.new(uri.host)
rescue
  nil
end
return false if host_ip && (ip.private? || ip.loopback?)

Ruby's IPAddr#private? covers RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). loopback? covers 127.0.0.0/8 and ::1. In practice should also extend to link-local 169.254.0.0/16 (AWS IMDS lives there) and IPv6 unique-local fc00::/7.

3. Block own-product domains

Maintain a blocklist of the operator's own public-service domains and reject URLs whose host matches. Rationale: prevents the webhook sender from being used to attack the operator's own other services (which may accept traffic from the webhook sender's IP range as "internal"). Ruby snippet from the post:

uri = URI.parse(url)
if BLOCKED_DOMAINS.any? { |domain| uri.host&.include?(domain) }
  return false
end

4. Resolve DNS + re-check IPs

After (2) and (3), resolve the hostname and verify every returned IP is not private / loopback / blocked. Ruby snippet from the post:

def host_resolves_valid_ips?(host)
  ip_addresses = Resolv.getaddresses(host)
  return false if ip_addresses.none?
  if ip_addresses.any? { |ip| blocked_ip?(IPAddr.new(ip)) }
    return false
  end
  true
end

def blocked_ip?(ip)
  ip.private? || ip.loopback?
end

This catches the "attacker registers user-supplied-hostname.attacker.com with an A-record pointing at 10.0.0.1" case at submission time.

The fundamental limit: DNS-rebinding

Verbatim caveat from the post: "Remember, the user can always update the host's DNS after this check has passed. This alone is not enough to protect from SSRFs." The check-time IP and the send-time IP are not guaranteed to be the same host — the attacker controls their authoritative DNS and can flip the answer after validation passes. This is DNS rebinding. URL validation is necessary but not sufficient; the second layer (egress proxy) is load- bearing. Pattern: patterns/defense-in-depth-webhook-abuse-mitigation.

Seen in

  • sources/2026-04-21-planetscale-webhook-security-a-hands-on-guideCanonical wiki disclosure of the four-check URL- validation shape for webhook senders (2023-11-21, Mike Coutermarsh). Worked Ruby implementation via URI.parse + IPAddr#private? + IPAddr#loopback? + domain-include blocklist + Resolv.getaddresses post- DNS re-check. Canonical limitation framing: URL validation is a UX benefit (immediate user feedback on invalid endpoints) and a first security layer, but cannot defeat DNS rebinding — the egress proxy layer is what closes that gap.
Last updated · 470 distilled / 1,213 read