---
title: "Outbound safety (SSRF) — Webhooks for Laravel | Roundly"
description: "How Safe Fetch guards every customer URL on save and on every attempt — the policy, DNS pinning, redirects and local development."
url: https://roundly-consulting.com/open-source/docs/webhooks-for-laravel/outbound-safety
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Webhooks for Laravel](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)

# 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](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)’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](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/consumer-packages.md). 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:

```php
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:

```bash
WEBHOOKS_ALLOW_HTTP=on
```

```php
// 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)
