Events\DestinationRefused is dispatched for every refusal — at vetting (every hop, allows(), precheck() and the rule included) and when a request connected outside its vetted set (cannot_pin):
final readonly class DestinationRefused
{
public Reason $reason;
public string $host; // canonical; '' when the URL had none the guard could read
public ?string $blockedBy; // for blocked: hostname, blocked_host, app_host, blocked_range, private_address
public string $via; // vet, allows, precheck, request, fetch, download
}It never carries the URL (it can be a capability URL) and never a resolved address. via says where the URL was judged:
| via | Meaning |
|---|---|
vet | SafeFetch::vet(). |
allows | SafeFetch::allows(), the SafeUrl rule and safe_url. |
precheck | SafeFetch::precheck() and SafeUrl::withoutLookup(). |
request | A hop of a guarded request (Http::safe(), guard(), http()). |
fetch · download | A hop of SafeFetch::fetch() or SafeFetch::download(). |
Refusals are what an SSRF attempt looks like — worth a listener that alerts, and one that can skip what a form refused:
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use RoundlyConsulting\SafeFetch\Events\DestinationRefused;
Event::listen(function (DestinationRefused $event): void {
if (! in_array($event->via, ['allows', 'precheck'], true)) {
Log::warning('Outbound request refused', ['reason' => $event->reason->value, 'host' => $event->host, 'rule' => $event->blockedBy, 'via' => $event->via]);
}
});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.