Outbound safety (SSRF)
Outbound URLs come from your users. Without a guard, https://169.254.169.254/… or a name that resolves into your private network turns your workers into a proxy for your internal systems. The guard is Safe Fetch for Laravel’s; Webhooks wires it in on save and on every attempt and redirect hop, because DNS can change between them.
The policy
The policy starts from your app’s SafeFetch::policy() with its blocks kept (blocked hosts and ranges, its app hosts) and its carve-outs dropped — private hosts, allowed ranges and a host allowlist were set up for your own fetches, not for tenant endpoints — then applies webhooks.ssrf.* and webhooks.outbound.*. It is the pattern Safe Fetch recommends for packages. Block beats allow:
| Rule | Setting |
|---|---|
| https only; http too for local development | outbound.allow_http |
| Canonical URLs only: no userinfo, no % / backslash / whitespace in the authority, no numeric IPv4 spellings (2130706433, 0x7f.1, 0177.0.0.1, 127.1), IDN via UTS #46 (ext-intl) | — |
| Every resolved address (A + AAAA) public — loopback, RFC 1918, link-local incl. 169.254.169.254, CGNAT incl. 100.100.100.200, Azure 168.63.129.16, ULA incl. fd00:ec2::254, multicast, reserved, documentation and IPv4-embedded IPv6 are refused; one private answer blocks the name | — |
| Refused by name before any lookup: localhost, *.localhost, the app’s own hosts, ssrf.blocked_hosts | ssrf.block_app_hosts |
| Extra blocked CIDRs (always win) | ssrf.blocked_ranges |
| CIDRs exempt from the private table | ssrf.allowed_ranges |
| A strict host allowlist — an allowed host is still resolved, vetted against the blocked ranges and pinned | ssrf.allowed_hosts |
| Ports | ssrf.allowed_ports |
On save
With outbound.verify_on_save (on), creating or updating an endpoint runs every rule including the DNS lookup; off, every rule except the lookup. Either way the canonical URL is stored. A refused or unresolvable URL throws DestinationBlockedException with one message for both — no internal-DNS oracle for whoever typed the URL — while reason(), blockedBy() and unresolved() tell your code and your operators the difference:
use RoundlyConsulting\Webhooks\Exceptions\DestinationBlockedException;
try {
Webhooks::for($team)->outbound()->create($request->validated('url'), events: ['invoice.*']);
} catch (DestinationBlockedException $e) {
// one message for refused and unresolvable hosts — no internal-DNS oracle for whoever typed the URL
$e->reason(); // safe-fetch's Reason, for your code and your operators
$e->blockedBy(); // the rule, or null
$e->unresolved(); // true when the host did not resolve
}On every attempt
- The request goes through SafeFetch::guard() with the policy — the manager, never the Http::safe() macro another package could replace — with the throttle added before it, so one throttle runs per attempt before any DNS lookup.
- Per hop the guard vets the URL and resolves the name once, pins the connection to the vetted addresses (CURLOPT_RESOLVE, fresh connection, no reuse, environment proxies bypassed), forces its own curl handler, aborts a connection outside the vetted set before a byte is sent and checks the connected address afterwards.
- A blocked destination fails the delivery for good (blocked, stored with the rule, e.g. blocked (private_address)); an unresolvable host is retried (unresolved). The connected address is recorded on the attempt.
Redirects
Off by default: a 3xx is a failed attempt (redirect_not_followed, retried). With outbound.follow_redirects only 307 and 308 are followed — 301, 302 and 303 would turn the POST into a GET — at most outbound.max_redirects (3), never https → http (redirect_downgrade), each hop vetted and pinned again, within one time budget. Custom headers and credentials go to the first origin only; the webhook-id, webhook-timestamp and webhook-signature headers go to every hop.
Local development
To deliver to a receiver on your own machine, allow http and the loopback address you need — never in production:
WEBHOOKS_ALLOW_HTTP=on// config/webhooks.php
'ssrf' => ['allowed_ranges' => ['127.0.0.1/32'], /* … */],WEBHOOKS_SSRF_GUARD=off removes the guard entirely — no pinning, no vetting; redirects are still not followed — and belongs only behind a filtering egress proxy you trust; webhooks:doctor warns. The response cap holds either way: unguarded, the answer goes through a sink that keeps outbound.max_response_bytes and discards the rest. Sending through an egress proxy while the guard stays on is not supported. The guard needs ext-curl; a host-wide custom Guzzle handler does not bypass it.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.