URLs and blocked networks
Canonicalisation
Every URL is parsed by the guard itself, never by PHP’s own URL parser: that parser and curl read some hosts differently (http://a.com\@127.0.0.1/, http://127.0.0.1#@a.com/), so a guard would vet one host while curl dials another. Anything a parser could read two ways is refused, and the request is sent to the URL rebuilt from the parts that were vetted. In order, first failure wins:
- A C0 control character or DEL anywhere → invalid_url.
- No scheme: → invalid_url; a scheme other than http or https → scheme.
- No // or an empty authority → invalid_url.
- A space, \, %, Unicode whitespace or U+FEFF in the authority → non_canonical_host (a percent-encoded host, an IPv6 zone id).
- @ in the authority → credentials.
- A port that is not digits → invalid_url; outside 1–65535 → port.
- The host: a bracketed IPv6 address must be valid and is compressed ([0:0:0:0:0:0:0:1] → [::1]); a non-ASCII or xn-- label goes through UTS #46 (needs ext-intl); lowercased; one trailing dot dropped; a host whose last label is numeric or hex must be exactly four decimal labels 0–255 without leading zeros, typed as such — else non_canonical_host.
- A \ in the path, query or fragment → invalid_url.
The fragment is dropped. The result is a CanonicalUrl — toString() gives a lowercase scheme and host, no user info, no fragment, no default port, IPv6 compressed and bracketed, path and query verbatim (empty → /); origin() gives scheme://host:port with the port always written. The scheme policy is applied afterwards, so a canonical http:// URL parses fine and is refused (scheme) at vetting.
The blocked-network table
| Range | What |
|---|---|
0.0.0.0/8 | “this” network |
10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16 | private |
100.64.0.0/10 | carrier-grade NAT (Alibaba Cloud metadata at 100.100.100.200) |
127.0.0.0/8 | loopback |
168.63.129.16/32 | Azure WireServer (public address space, reachable from every Azure VM and AKS node) |
169.254.0.0/16 | link-local: AWS, GCP, Azure, OpenStack metadata at 169.254.169.254 |
192.0.0.0/24 | IETF protocol assignments (Oracle Cloud metadata at 192.0.0.192) |
192.0.2.0/24 · 198.51.100.0/24 · 203.0.113.0/24 | documentation |
192.88.99.0/24 | 6to4 relay anycast |
198.18.0.0/15 | benchmarking |
224.0.0.0/4 | multicast |
240.0.0.0/4 | reserved, broadcast |
64:ff9b:1::/48 | NAT64, local use |
100::/64 | discard |
2001::/23 | IETF protocol assignments (Teredo included) |
2001:db8::/32 · 3fff::/20 | documentation |
5f00::/16 | SRv6 SIDs |
fc00::/7 | unique local (AWS metadata over IPv6 at fd00:ec2::254) |
fe80::/10 | link-local |
fec0::/10 | site-local |
ff00::/8 | multicast |
- An IPv6 address that carries an IPv4 address is judged by the IPv4 address — the first match of ::ffff:0:0/96 (mapped), ::/96 (compatible, incl. :: and ::1), 64:ff9b::/96 (NAT64), ::ffff:0:0:0/96 (translated) and 2002::/16 (6to4). So [::ffff:127.0.0.1] and [2002:a9fe:a9fe::] are blocked, and 64:ff9b::808:808 (NAT64 for 8.8.8.8) is public.
- localhost and every *.localhost are refused by name before any lookup.
- Anything unparseable counts as private. An IPv6 zone id on an address is ignored for the judgement — in a URL it is refused (%).
PrivateNetworks
RoundlyConsulting\SafeFetch\Support\PrivateNetworks holds the table — public, pure and static, no configuration, no lookup. The policy is applied on top of it by the guard; SafeFetch::networks() wraps isPrivate(), isBlockedHostname() and BLOCKED for code that prefers the facade:
| Member | What |
|---|---|
BLOCKED | list<string> — the 26 ranges above. |
IPV4_EMBEDDING | The 5 IPv6 prefixes that carry an IPv4 address, and the byte it starts at. |
METADATA | The 6 cloud-metadata ranges and addresses. |
BLOCKED_HOSTNAMES · BLOCKED_HOSTNAME_SUFFIXES | ['localhost'] · ['.localhost'] — refused by spelling. |
isPrivate(string $address): bool | In the table, or carries an address that is; unparseable → true; a zone id is ignored. |
isMetadata(string $address): bool | A cloud-metadata endpoint, or an IPv6 address carrying one. |
isBlockedHostname(string $host): bool | localhost or *.localhost, any case, one trailing dot dropped. |
carriedIpv4(string $address): ?string | The IPv4 address an IPv6 one carries, dotted; null for IPv4, an address that carries none, or anything unparseable. |
use RoundlyConsulting\SafeFetch\Support\PrivateNetworks;
PrivateNetworks::carriedIpv4('2002:a9fe:a9fe::'); // '169.254.169.254' — 6to4
PrivateNetworks::carriedIpv4('64:ff9b::808:808'); // '8.8.8.8' — NAT64
PrivateNetworks::isMetadata('::ffff:169.254.169.254'); // trueInspection helpers
The handles and the vetted destination expose what the guard computes, without sending anything:
SafeFetch::networks()->isPrivate('169.254.169.254'); // true — cloud metadata
SafeFetch::networks()->isPrivate('::ffff:10.0.0.1'); // true — judged by the IPv4 address it carries
SafeFetch::networks()->isBlockedHostname('api.localhost'); // true
SafeFetch::networks()->blockedRanges(); // the 26 ranges, IPv4 and IPv6
$url = SafeFetch::urls()->canonicalise('HTTPS://Example.COM.:443/a?b#frag');
$url->toString(); // 'https://example.com/a?b'
$url->origin(); // 'https://example.com:443'
SafeFetch::urls()->resolveRedirect('https://example.com/a/b', '../c'); // 'https://example.com/c'
$vetted = SafeFetch::vet('https://hooks.example/in');
$vetted->addresses; // every address the name resolved to, each judged
$vetted->resolveEntry(); // 'hooks.example:443:93.184.216.34,[2606:2800:220:1:248:1893:25c8:1946]'
$vetted->connectsTo('::ffff:93.184.216.34'); // true — the mapped form is the same addresscanonicalise() throws DestinationRejected with the parser’s reason (invalid_url, scheme, credentials, non_canonical_host, port); resolveRedirect() throws it (invalid_url) for a relative reference no parser agrees on.
A resolver of your own
Every lookup goes through Contracts\HostResolver — one method, resolve(string $host): array, returning every IPv4 and IPv6 address (IPv6 without brackets), [] when the name does not resolve. The host it receives is already canonical. The default, Support\SystemHostResolver, uses gethostbynamel() (the system resolver, hosts file included) and dns_get_record(DNS_AAAA). Whatever a resolver returns is still judged and pinned: it cannot make a private address pass, and an answer that is no address is refused.
use RoundlyConsulting\SafeFetch\Contracts\HostResolver;
final class DnsRecordResolver implements HostResolver
{
public function resolve(string $host): array
{
$addresses = [];
foreach (@dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $record) {
$addresses[] = $record['ip'] ?? $record['ipv6'] ?? null;
}
return array_values(array_unique(array_filter($addresses, is_string(...))));
}
}// config/safe-fetch.php
'resolver' => App\Support\DnsRecordResolver::class,
// or in a service provider
app()->instance(HostResolver::class, new DnsRecordResolver);A class that is not an instantiable HostResolver throws InvalidConfigurationException when resolved. In tests, use SafeFetch::fakeDns() instead.
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.