---
title: "Threat model — Safe Fetch for Laravel | Roundly"
description: "What the guard defends against — rebinding, disguised addresses, redirects, proxies, parser tricks, leaky errors — and what it does not guarantee."
url: https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/threat-model
language: en
---

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

[Safe Fetch for Laravel](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)

# 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 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/safe-fetch-for-laravel.md)
