NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Safe Fetch for Laravel

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

RangeWhat
0.0.0.0/8“this” network
10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16private
100.64.0.0/10carrier-grade NAT (Alibaba Cloud metadata at 100.100.100.200)
127.0.0.0/8loopback
168.63.129.16/32Azure WireServer (public address space, reachable from every Azure VM and AKS node)
169.254.0.0/16link-local: AWS, GCP, Azure, OpenStack metadata at 169.254.169.254
192.0.0.0/24IETF protocol assignments (Oracle Cloud metadata at 192.0.0.192)
192.0.2.0/24 · 198.51.100.0/24 · 203.0.113.0/24documentation
192.88.99.0/246to4 relay anycast
198.18.0.0/15benchmarking
224.0.0.0/4multicast
240.0.0.0/4reserved, broadcast
64:ff9b:1::/48NAT64, local use
100::/64discard
2001::/23IETF protocol assignments (Teredo included)
2001:db8::/32 · 3fff::/20documentation
5f00::/16SRv6 SIDs
fc00::/7unique local (AWS metadata over IPv6 at fd00:ec2::254)
fe80::/10link-local
fec0::/10site-local
ff00::/8multicast
  • 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:

MemberWhat
BLOCKEDlist<string> — the 26 ranges above.
IPV4_EMBEDDINGThe 5 IPv6 prefixes that carry an IPv4 address, and the byte it starts at.
METADATAThe 6 cloud-metadata ranges and addresses.
BLOCKED_HOSTNAMES · BLOCKED_HOSTNAME_SUFFIXES['localhost'] · ['.localhost'] — refused by spelling.
isPrivate(string $address): boolIn the table, or carries an address that is; unparseable → true; a zone id is ignored.
isMetadata(string $address): boolA cloud-metadata endpoint, or an IPv6 address carrying one.
isBlockedHostname(string $host): boollocalhost or *.localhost, any case, one trailing dot dropped.
carriedIpv4(string $address): ?stringThe 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');   // true

Inspection 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 address

canonicalise() 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 crypto

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