Attach the policy to the connection, not the host string. Parse with Uri\WhatWg\Url, which reads any IPv4 notation as the address it names; allow only http and https on ports 80 and 443 without credentials; resolve names and refuse any address that fails FILTER_FLAG_GLOBAL_RANGE, plus multicast and NAT64; connect to the vetted address; vet every redirect; cap time and bytes.
- Find every place a request value reaches an HTTP client: Http::get(), Guzzle, file_get_contents() or fopen() on a URL, curl_exec(), image and webhook helpers, and anything that follows redirects by default (Laravel's HTTP client and Guzzle both do).
- Parse the URL with Uri\WhatWg\Url::parse() (PHP 8.5): it normalises an IPv4 host written in decimal, octal or hex to dotted decimal, so the address you check is the address a resolver would use. Allow only http and https, ports 80 and 443, and no user name or password.
- Turn the host into addresses: an IP literal as is, a name through DNS. Refuse the whole answer if any address fails filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) or is multicast, after unwrapping a NAT64 (64:ff9b::/96) address to the IPv4 address it carries.
- Connect to the address you vetted, not the name: stream_socket_client() to the IP (with ssl peer_name set to the host for https), or with Laravel's HTTP client the CURLOPT_CONNECT_TO or CURLOPT_RESOLVE curl option, which Guzzle's curl handler accepts.
- Do not let the client follow redirects (Http::withoutRedirecting(), allow_redirects false). Resolve Location against the current URL with $url->resolve() and run the whole policy again; stop after a fixed number of hops.
- Set connect and read timeouts, stop reading past a byte limit, and add regression tests that name internal addresses in every notation, through DNS and through a redirect.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
// No validation at all: parse the URL and hand the host to the resolver.
/** @return array{0: string, 1: string, 2: int, 3: string, 4: string} */
private function target(string $url): array
{
$parts = parse_url($url) ?: [];
$scheme = $parts['scheme'] ?? 'http';
$host = $parts['host'] ?? '';
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
$path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
return [$scheme, $host, $port, $path, $this->network->resolve(trim($host, '[]'))[0]];
}With IMPL=vulnerable, 12 of 15 tests fail (10 exploit tests).
The fix: vet every address
private const NAT64_PREFIX = "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00";
public static function isPublic(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
// NAT64 (64:ff9b::/96) carries an IPv4 address: judge the address it reaches.
if (strlen($packed) === 16 && str_starts_with($packed, self::NAT64_PREFIX)) {
$packed = substr($packed, 12);
}
// FILTER_FLAG_GLOBAL_RANGE refuses loopback, private, link-local, carrier-grade NAT,
// documentation, reserved and IPv4-mapped addresses, but accepts multicast: refuse that here.
$first = ord($packed[0]);
if ((strlen($packed) === 4 && ($first & 0xF0) === 0xE0) || (strlen($packed) === 16 && $first === 0xFF)) {
return false;
}
return filter_var(inet_ntop($packed), FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false;
}
/** @return array{0: Url, 1: int, 2: string} the parsed URL, its port and the vetted address */
private function vet(string $raw): array
{
$url = Url::parse($raw) ?? throw new BlockedRequest('invalid URL');
if (! in_array($url->getScheme(), Transport::ALLOWED_SCHEMES, true)) {
throw new BlockedRequest('scheme not allowed');
}
if ($url->getUsername() !== null || $url->getPassword() !== null) {
throw new BlockedRequest('credentials in URL not allowed');
}
$port = $url->getPort() ?? ($url->getScheme() === 'https' ? 443 : 80);
if (! in_array($port, Transport::ALLOWED_PORTS, true)) {
throw new BlockedRequest('port not allowed');
}
$host = trim((string) $url->getAsciiHost(), '[]');
$addresses = filter_var($host, FILTER_VALIDATE_IP) !== false ? [$host] : $this->network->resolve($host);
if ($addresses === []) {
throw new BlockedRequest('host did not resolve');
}
foreach ($addresses as $address) {
if (! self::isPublic($address)) { // refuse the whole answer if any address is internal
throw new BlockedRequest('non-public address');
}
}
return [$url, $port, $addresses[0]];
}With IMPL=fixed, 15 of 15 tests pass.
The fix: re-validate every redirect
// A 3xx is a new destination, not permission to follow: run the whole policy on every hop and
// connect each one to its own vetted address.
public function fetch(string $url): array
{
$target = $url;
for ($hop = 0; $hop <= Transport::MAX_REDIRECTS; $hop++) {
[$parsed, $port, $ip] = $this->vet($target);
$path = $parsed->getPath().($parsed->getQuery() !== null ? '?'.$parsed->getQuery() : '');
$result = Transport::sendOnce($this->network, $parsed->getScheme(), (string) $parsed->getAsciiHost(), $port, $path, $ip, Transport::MAX_RESPONSE_BYTES);
if ($result['status'] >= 300 && $result['status'] < 400 && $result['location'] !== null) {
try {
$target = $parsed->resolve($result['location'])->toAsciiString(); // vetted again at the top
} catch (Throwable) {
throw new BlockedRequest('invalid redirect');
}
continue;
}
return Transport::summarise($result);
}
throw new BlockedRequest('too many redirects');
}With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
private const BLOCKED_SUBSTRINGS = ['localhost', '127.0.0.1', '0.0.0.0', '169.254', '10.', '192.168', '172.16', '::1', 'metadata', 'internal'];
private function screen(string $host): void
{
foreach (self::BLOCKED_SUBSTRINGS as $blocked) {
if (str_contains(strtolower($host), $blocked)) {
throw new BlockedRequest('host is blocked');
}
}
}With IMPL=wrong, 7 of 15 tests fail (5 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: each payload names an internal destination a different way. The secure outcome is
// the same every time: 400 and no connection to the internal service.
public function test_exploit_loopback_ipv4_literal(): void
{
$this->assertBlocked('http://127.0.0.1/latest/meta-data/');
}
public function test_exploit_loopback_decimal_integer(): void
{
$this->assertBlocked('http://2130706433/');
}
public function test_exploit_loopback_octal(): void
{
$this->assertBlocked('http://0177.0.0.1/');
}
public function test_exploit_loopback_hex(): void
{
$this->assertBlocked('http://0x7f.0.0.1/');
}
public function test_exploit_ipv6_loopback_literal(): void
{
$this->assertBlocked('http://[::1]/');
}
public function test_exploit_ipv4_mapped_ipv6_loopback(): void
{
$this->assertBlocked('http://[::ffff:127.0.0.1]/');
}
public function test_exploit_cloud_metadata_address_by_hostname(): void
{
$this->assertBlocked('http://metadata.internal.example/latest/meta-data/');
}
public function test_exploit_private_address_by_hostname(): void
{
$this->assertBlocked('http://db.internal.example/');
}
public function test_exploit_name_resolving_to_loopback(): void
{
$this->assertBlocked('http://rebind.example/');
}
public function test_exploit_redirect_from_allowed_host_to_internal(): void
{
$this->assertBlocked('http://files.partner.example.test/redirect-internal');
}- A host name blocklist checks a string, not a destination: 2130706433, 0177.0.0.1 and 0x7f.0.0.1 all reach 127.0.0.1, and an ordinary-looking name can resolve to an internal address. That is this card's wrong implementation.
- parse_url() returns the host exactly as written (2130706433, or [::1] with its brackets) and does not normalise it; Uri\WhatWg\Url::parse() does, which is why the fixed code uses it.
- FILTER_FLAG_GLOBAL_RANGE (PHP 8.2+) refuses loopback, private, link-local, carrier-grade NAT, documentation, reserved and IPv4-mapped addresses, but in PHP 8.5.11 it accepts multicast (224.0.0.1, ff02::1) and the NAT64 form of loopback (64:ff9b::7f00:1); check those yourself. FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE is weaker still: it accepts 100.64.0.1, 192.0.2.1 and 198.18.0.1.
- Checking the resolved address and then handing the URL to Http::get() lets curl resolve the name again, which leaves a DNS rebinding window. Pin the connection to the vetted address.
- Laravel's HTTP client follows redirects by default, and a redirect from an allowed host to 169.254.169.254 is the classic bypass. Turn redirects off and vet each hop yourself.
- Proxy settings from the environment send the connection to the proxy, not the vetted address: Guzzle reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY. Pin the policy at the proxy too, or make sure no proxy is configured for this client.
- An egress firewall or proxy that denies internal ranges is a second layer, not a replacement: application checks still decide which public hosts a feature may reach.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs ssrf/php-laravel does this for you, in Docker if you do not have PHP).
- The tests use a fake network: a resolver that maps fixture names to labelled addresses and a dialer that routes connections to three upstream servers (PHP's built-in server on loopback) and counts every attempt on an internal address, so a blocked request must cause zero attempts, not just an error.
- Against your own endpoint, try http://127.0.0.1/, http://2130706433/, http://[::ffff:127.0.0.1]/, a name you control that resolves to 10.0.0.1, and an allowed host that redirects to http://169.254.169.254/; each must be refused before any connection to an internal address.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | r/php (every PHP rule in the Semgrep registry) | No findings on any implementation. The PHP taint rules take only the superglobals as sources, so request data read through Illuminate\Http\Request never reaches a sink, and none of them model the host or address handling that distinguishes the fetchers.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes config (47 rules on 13 files) |
| Psalm | taint analysis (not executed this round)Psalm taint analysis with the Laravel plugin | Psalm's TaintedSsrf would flag a request value reaching stream_socket_client or a cURL URL; here the vetted address is used, and the vulnerable and wrong paths differ only in the host and address checks, which Psalm does not model.Checked: Not executed in this round; the other Laravel cards run Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6 under --taint-analysis |
CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript. The regression tests are the control.
| Component | Version |
|---|---|
| Language | PHP |
| Runtime | PHP 8.5.11 (CLI) |
| Framework | Laravel 13.34.0 with PHP 8.5's Uri\WhatWg\Url, filter_var(FILTER_FLAG_GLOBAL_RANGE) and stream sockets |
| laravel/framework | 13.34.0 |
| PHP uri extension (Uri\WhatWg\Url, bundled with PHP 8.5) | PHP 8.5.11 |
| phpunit/phpunit | 13.4.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/php-laravelfolder.
composer install --no-interaction --no-progress# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xmlWithout PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'composer install --no-interaction --no-progress'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=fixed \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=vulnerable \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=wrong \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs ssrf/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
- Toolchain
- php 8.5.11 (CLI)
- laravel/framework 13.34.0
- phpunit/phpunit 13.4.0
- composer 2.10.3
- semgrep 1.180.0
- psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 12 of 15 tests fail (10 exploit tests); wrong: 7 of 15 tests fail (5 exploit tests)
- Test results
- fixed 15/15 pass; vulnerable 12/15 fail (all 10 exploit tests, plus the ftp URL, which parse_url and the HTTP/1.0 transport fetch as if it were http, and the over-size response); wrong 7/15 fail (5 exploit tests: decimal, octal and hex loopback, the name that resolves to loopback and the redirect to the metadata host; plus the ftp URL and the over-size response). Each test run in a container with --network=none after composer install; the three upstream servers are PHP built-in servers on loopback inside the container.
- Dependency freshness
- Same lock as the other Laravel cards: laravel/framework 13.34.0 (2026-09-29) with laravel/prompts held at 0.3.24 and phpunit/phpunit 13.4.0 (2026-10-02), because the 2026-10-06 releases were under seven days old and are not security releases. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- CWE-918: Server-Side Request Forgery (SSRF)
- PHP manual: Uri\WhatWg\Url
- PHP manual: Validate filters (FILTER_FLAG_GLOBAL_RANGE)
- PHP manual: stream_socket_client
- Laravel 13: HTTP client
- WHATWG URL Standard: IPv4 parser
- IANA IPv4 Special-Purpose Address Registry
- RFC 6052: IPv6 Addressing of IPv4/IPv6 Translators (64:ff9b::/96)
Attack patterns: