All packages
Safe Fetch for Laravel
Threat model
The attacker controls a URL the app fetches, the DNS of that URL’s name (TTL 0, alternating answers) and the HTTP server at a public address (any status, Location and body). The asset is anything reachable from the app host that the attacker cannot reach: loopback services, the private network, cloud metadata, the app itself.
| Attack | Defence |
|---|---|
| Private or metadata literal (http://169.254.169.254/, [fd00:ec2::254]) | The blocked-network table. |
| Alternate IPv4 spellings (2130706433, 0x7f.1, 0177.0.0.1, 127.1) | Refused by the canonicaliser, never normalised. |
| IPv4 inside IPv6 ([::ffff:127.0.0.1], NAT64, 6to4, Teredo) | Embedding rules; 2001::/23 (Teredo included) blocked wholesale. |
| A name with a private answer, or one private answer of many | Resolved once, every address judged; one private answer blocks the name. |
| DNS rebinding | Every vetted address pinned into curl (CURLOPT_RESOLVE); curl never resolves the name itself. |
| An environment proxy resolves the name itself | proxy: '' on every hop; a proxy the caller sets is refused. |
| A pooled connection opened to another address | A handler owned by the guard, CURLOPT_FRESH_CONNECT, CURLOPT_FORBID_REUSE. |
| A non-curl handler ignores the pin | The guard forces its own curl handler and checks primary_ip afterwards. |
| Code after the guard re-points the request or drops the pin | The handler re-checks the URL, the Host header and the pin options before dialling; CURLOPT_PREREQFUNCTION aborts a connection outside the vetted set before a byte is sent. |
| A caller curl option that bypasses the pin (CURLOPT_UNIX_SOCKET_PATH, CURLOPT_CONNECT_TO, CURLOPT_PRE_PROXY …) | Caller curl options go through an allow-list. |
| Redirect to a private address, a downgrade, a loop | Redirects off by default; when on, every hop vetted and pinned, downgrade and the limit refused. |
| Credentials sent to another origin on a redirect | auth, Authorization and curl HTTPAUTH / USERPWD dropped on another origin (CVE-2022-31090); headers to the first origin only, plus an explicit forward list. |
| An unbounded response | maxResponseBytes on the policy (truncates); fetch()’s maxBytes (too_large). |
| Parser differential (a.com\@127.0.0.1, #@, zone ids, %) | A strict parser; the request goes to the rebuilt URL. |
| Self-SSRF (the app’s own host behind a public load balancer) | The app’s host names refused by name. |
| Error text maps the internal network | Fixed per-reason messages, never a URL or an address; blocked and unresolved share one message. |
| A trusted private name answers with a metadata address | A private host never exempts a cloud-metadata address; only an allowed range does. |
| A package inherits the host’s carve-outs for its own users’ URLs | withoutHostAllowlist(), withoutPrivateHosts(), withoutAllowedRanges() drop them while the blocks stay; allowHosts([]) throws. |
| “Only these hosts” bypassed with an address literal | A strict host allowlist refuses every address literal. |
What is not guaranteed
- No protection when the guard is not on the path — a plain Http::get() is not guarded.
- A host that re-points Http::safe()’s handler after the macro (->setHandler()) removes the guard’s handler and its re-check; the pre-send check still rides on the hop’s curl options (when that handler is curl), and the primary_ip post-check catches any other handler only after the request left. Replacing the client with setClient() after the macro removes the guard entirely (a client set before the macro is refused).
- Another package registering a safe macro after this one silently replaces it — security-critical code calls SafeFetch::guard() or SafeFetch::http().
- DNS lookups cannot be time-bounded in PHP; the request timeout covers the transfer only.
- AAAA records come from DNS, not /etc/hosts — PHP has no API for a hosts-file IPv6 entry.
- An allowed range or a private host is trusted entirely by your own config — except that a private host never reaches a cloud-metadata address.
- precheck() and SafeUrl::withoutLookup() do not resolve the name: a name they pass can resolve privately. The guard judges it in full when it is fetched.
- reason(), retryable() and blockedBy() distinguish “private” from “not found” — keep that difference away from whoever submitted the URL.
- The app reached through a public name that is not in its app hosts (another DNS name for the same IP) is not recognised — list extra names in app_hosts.extra.
- Laravel’s retry() retries every throwable unless its when callback says no, so filter SafeFetchException to stop at the first refusal.
- A fresh connection per request: no keep-alive across guarded requests — the price of no cross-request reuse.
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.