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-guide
— Canonical 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.getaddressespost- 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.