Architecture
Architecture
Three small pieces, wired through contracts so every part is swappable and testable.
UrlGuard (contract) ←impl─ Guard
├─ GuardPolicy (immutable policy, built from config)
└─ Resolver (contract) ←impl─ SystemResolver / FakeResolver
UrlGuard (contract) → Guard
The one capability: decide whether a URL is safe and produce pinned connection
options. Guard is stateless; it composes a GuardPolicy and a Resolver.
assertSafe()/isSafe()— validate for a server-side fetch (resolves DNS).assertSafeRedirect()— validate a browser redirect target (no DNS; the browser resolves at click time), still blocking IP literals and blocked hosts.pinnedOptions()— Guzzle options that pin the connection (CURLOPT_RESOLVE), add a post-connection IP check (on_stats), and disable redirects.
GuardPolicy
An immutable value object holding the normalized policy (allowed schemes, blocked
hosts/suffixes/IPs/CIDRs, enforce, pin_dns). Built once from config/ssrf.php
via GuardPolicy::fromConfig() and bound as a singleton.
Resolver (contract) → SystemResolver
Hostname → IP addresses. SystemResolver consults system DNS for both A and
AAAA records. Abstracting resolution is what makes the guard testable (bind a
FakeResolver) and lets a deployment supply a pinned/internal DNS view.
The validation pipeline
- Parse the URL; require a scheme + host.
- Reject a disallowed scheme and any embedded credentials.
- Normalize the host (lowercase, strip brackets/trailing dot); map an
internationalized host to punycode with UTS #46, as curl and browsers do (which
also folds
localhosttolocalhost); refuse a percent-encoded host; reject blocked names/suffixes. - Resolve to IPs (or normalize an IP literal in redirect mode).
- For every address: reject blocked IPs and blocked CIDRs, then extract and re-check any IPv4 embedded in an IPv6 transition form.
- Produce pinned options for the caller's HTTP client. The pin names every
spelling curl may look up — the normalized host, the host as written (trailing
dot included) in punycode, and the UTF-8 form a curl without IDN support
resolves — because curl matches
CURLOPT_RESOLVEagainst the name it is about to resolve and silently ignores a pin on any other spelling.
Why validate the resolved IP, not the URL string
Attackers obfuscate hosts a dozen ways — decimal/hex/octal IP literals, IPv6 transition forms, unicode dots, trailing dots. Validating the resolved address rather than the textual host neutralizes that entire class in one move, because the resolver and the checks agree on a canonical address.