craftcms/url-validator
Validate URLs and IP addresses before opening a connection to guard against SSRF and DNS rebinding. Rejects non-HTTP schemes, private/reserved/loopback addresses, cloud-metadata hosts, and IPv4-embedding IPv6, returning resolved IPs so you can pin the connection.
This is a framework-agnostic Composer library, not a Laravel package. There is no service provider, no facade, and no php artisan vendor:publish step — don't go hunting for config/url-validator.php, because it doesn't exist. Adoption is therefore a one-liner:
composer require craftcms/url-validator
Where to look first, in order:
README.md — it contains the entire threat model (schemes, IP literals, hex/encoded hostnames, cloud-metadata domains, IPv4-embedded IPv6). It's also the only document, so read it fully rather than skimming.src/UrlValidator.php and UrlValidationException.php — the package is small enough to read end-to-end in a few minutes. Confirm the actual signatures before writing call sites: per the README, validate() returns an array of resolved IPs and throws UrlValidationException on rejection. Plenty of write-ups (including earlier draft assessments of this package) describe a result object with isValid()/getIps(); verify against source before copying any such snippet.CHANGELOG.md — with ~1 star and zero dependents, the changelog is your only signal for how the blocklists evolve.Your first use case should be the highest-risk surface you own: anywhere a user supplies a URL and your server fetches it. Webhook registration, "import from URL" onboarding, avatar/OG-image unfurling, RSS ingestion, screenshot/PDF-from-URL. Wire it into the webhook registration form first — it's typically the smallest diff and the most obviously attacker-controlled.
The mental model is validate-then-pin, not "validate and forget":
use CraftCms\UrlValidator\UrlValidationException;
use CraftCms\UrlValidator\UrlValidator;
try {
// Returns the IPs the host resolved to — keep them.
$ips = $validator->validate($userProvidedUrl);
} catch (UrlValidationException $e) {
abort(422, 'That URL is not allowed.');
}
// $ips is now the set you must pin your outbound connection to.
Register the validator as a singleton in AppServiceProvider so the blocklist arrays aren't rebuilt per call, and so you have one seam to swap behaviour later. Don't reach for a facade or a config file — a thin SafeUrlFetcher wrapper class is the idiomatic Laravel answer here.
Wrap it in a ValidationRule. This is the single highest-leverage integration: URL safety lives next to required|url in the Form Request, and the rejection message is consistent everywhere.
class SafeUrl implements ValidationRule
{
public function __construct(private UrlValidator $validator) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$this->validator->validate((string) $value);
} catch (UrlValidationException $e) {
$fail('The :attribute must point to a public http(s) URL.');
}
}
}
Note the asymmetry: the rule discards the IPs, because a controller-time validation result is worthless by the time the fetch happens. The rule is UX; the fetch path is security.
Re-validate inside the job, not just the controller. The TOCTOU window is the entire reason this library exists. A URL that was safe when queued is not necessarily safe when the worker runs — DNS can change, and an attacker who controls the hostname will make it change. Re-validating costs one DNS lookup and no HTTP request. If your validation lives only in the Form Request and the job does Http::get($storedUrl), you have shipped an SSRF.
Pin the connection on the way out. Validation without pinning is theatre. With Guzzle:
$parts = parse_url($url);
$host = $parts['host'];
$port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
$response = $client->request('GET', $url, [
'allow_redirects' => false,
'curl' => [
CURLOPT_RESOLVE => ["{$host}:{$port}:" . implode(',', $ips)],
],
]);
If a third-party SDK owns its own HTTP client and you can't pin it, that SDK cannot be used for user-supplied URLs. Route the fetch through your own Guzzle client, or don't ship the feature.
Follow redirects manually. With allow_redirects => false, read Location, re-run the full validate-then-pin cycle on it, and loop with a hard hop cap (3–5). Every hop is a fresh user-controlled URL. Pinning a hostname does nothing for a 302 to http://169.254.169.254/.
Centralise it as a SafeUrlFetcher service. Once two features need this, the duplication becomes a security liability — one of them will drift. A single service that validates, pins, caps redirects, caps response size, and enforces a timeout gives you one place to audit and one place to add egress logging.
Inject a resolver for caching or testing. The constructor accepts fn(string $host): array, which is your seam for a caching resolver, an internal DNS split-horizon, and deterministic tests:
$validator = new UrlValidator(fn (string $host): array => ['93.184.216.34']);
Tune via the options array, not by editing the package. The second-constructor-argument accepts allowedSchemes, disallowedHostnames, disallowedIpv4Addresses, disallowedIpv4Ranges, ipv4FilterFlags, ipv6FilterFlags. Read the source for exact defaults and expected types before touching these — ipv4FilterFlags in particular silently changes the meaning of everything else:
// Allow private ranges (useful for local dev) but keep reserved ranges blocked.
$validator = new UrlValidator(options: ['ipv4FilterFlags' => FILTER_FLAG_NO_RES_RANGE]);
Validate IPs directly when that's what you have. validateIp() returning bool is the right tool for allowlist management, webhook IP allowlisting UIs, and verifying a user-supplied CIDR/address before storing it — no DNS involved, no exceptions.
Log every rejection as a structured event. A blocked URL is real threat intel: host, scheme, reason, user/tenant. It costs nothing and gives you something concrete to show in a pen-test debrief or a control narrative.
Read the actual return type from source before shipping. The README shows validate() returning array and throwing; other write-ups describe an isValid() result object. If you code against the wrong shape you'll get a TypeError at the worst possible moment — or worse, a truthy array check that always passes. Confirm validate(), validateIp(), validateScheme(), and validateHostname() signatures against src/ on install and again on every version bump.
validateScheme() and validateHostname() are partial checks. validateHostname() returning true means the hostname isn't on the literal blocklist — it does not mean the hostname resolves somewhere safe. Only validate() does the DNS and IP-range work. Treat the two granular methods as diagnostics, not gates.
An unpinned Http::get($url) re-introduces the exact bug. The library returns IPs specifically so you can pin them. The moment you throw that array away and hand the URL string to a client, DNS rebinding is back and the library has bought you only a false sense of safety.
Guzzle follows redirects by default. allow_redirects defaults to true, and Guzzle re-resolves DNS on each hop. The first hop passes validation; the second lands on 169.254.169.254. Set it to false explicitly — relying on defaults here is how this ships broken.
Check every address in the returned set, and pin deliberately. A hostname can return a public A record and a private AAAA record simultaneously. If you pin only $ips[0] and your resolver ordering is unstable, you're flipping a coin on each request. Pin the full validated set, or deterministically select one and ensure only that one is in CURLOPT_RESOLVE.
CURLOPT_RESOLVE format is fiddly. It's "host:port:ip" per entry, and IPv6 literals need brackets ("[::1]"). Multiple IPs for one host:port go comma-separated in a single entry. Getting the port wrong (defaulting to 80 for an https URL) means the pin is silently ignored and the connection re-resolves.
IPv6 is where homegrown checks fail — don't "simplify" it away. IPv4-mapped, NAT64, 6to4, and Teredo addresses are all ways to smuggle 127.0.0.1 past a naive filter. This explicit unwrapping is the main reason to use this package over a regex, so resist any instinct to strip IPv6 or narrow the filter flags for convenience.
You will absolutely block your own dev environment. localhost, *.local, 10.x, 172.16.x, 192.168.x, and Docker service names (http://nginx:8080) are all rejected by design. The common fix is app()->environment('local')-gated ipv4FilterFlags relaxation — make sure the relaxation is keyed on APP_ENV and never on a boolean env var someone can set to true in production. Docker Compose accidentally inheriting your local .env is the classic incident.
CGNAT surprises people. 100.64.0.0/10 is blocked. If you fetch from a managed egress range or a carrier-grade NAT, you'll get rejected. That's correct behaviour, but it's the kind of thing that only shows up in staging after a networking team's change.
The metadata-domain blocklist is supplementary, not the defence. AWS/GCP/Kubernetes hostnames are covered, but new providers and custom endpoints appear. The IP-range checks are the durable part; treat the hostname list as a convenience.
A custom resolver must return every address family. If you inject one, you own the "resolve A and AAAA" responsibility. A resolver that only returns A records silently narrows the check and reopens the AAAA path.
There's no config file, so build your own wrapper. With no config(), no publishable config, and no service provider, every tunable lives in code. Write a SafeUrlFetcher / UrlValidationService that owns the UrlValidator instance and reads from your own config/security.php. Patching the vendor package means your next composer update deletes your security config.
Pin the version and actually read the diff on bump. One star and zero dependents is real, and the blast radius of a regression here is a security hole rather than a broken page. It's from a reputable organisation (Craft CMS / Pixel & Tonic) and the changelog is thin but present, so a caret constraint plus a committed lockfile is acceptable — read the diff anyway on minor bumps, especially anything touching the IP range arrays.
Don't cache validation results long-term. A URL validated at request time and cached for an hour is a URL whose DNS rebinding window you just extended by an hour. Cache, if at all, for seconds and always re-pin at fetch time with the cached IPs — which is the same thing as not caching.
Testing against live DNS makes your suite flaky. Use the injected resolver to return fixed IPs and assert both the allow and reject paths deterministically. Also test with raw IP literals (10.0.0.5, 169.254.169.254, 8.8.8.8), which need no lookup and have known outcomes.
How can I help you explore Laravel packages today?