# Guzzle: Noncanonical host can bypass host-based checks

Guzzle passes the raw URI host to libcurl while validating a different spelling, so `http://127.0.0.%31/` bypasses IP-literal denylists and reaches loopback.

- Payout: Research
- Severity: high
- Program: Guzzle
- Found by: bilguunbicktivism
- Disclosed: 2026-08-03
- CVE: CVE-2026-69246
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-v5mv-p594-2x33
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-v5mv-p594-2x33
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:curl tech:php technique:parser-differential vuln:access-control vuln:ssrf

**Target:** PHP HTTP client library (cURL/StreamHandler transport URI handling)
**Root cause:** Guzzle validates/decides on the URI host as written but hands the same text to libcurl, which percent-decodes and IDNA-maps the authority before resolving; the separately supplied Host header suppresses libcurl's aligned one, so validation and connection target diverge.
**Steps:**
1. Application builds a request URI from untrusted input and applies a host denylist/IP-literal check via `filter_var()` or `Uri::getHost()`.
2. Attacker supplies `http://127.0.0.%31/`; `filter_var()` rejects it as an IP literal so the check passes.
3. Guzzle sets `CURLOPT_URL` to the URI verbatim and pushes `Host: 127.0.0.%31` into `CURLOPT_HTTPHEADER`.
4. libcurl percent-decodes the authority to `127.0.0.1`, connects to loopback with no DNS lookup, and sends the attacker's Host header.
5. Response body is returned to the attacker-influenced fetch path.
**How found:** Noticed Guzzle gives the transport the URI as text plus a separate Host header, then reasoned that libcurl re-parses the authority (percent-decoding, IDNA) while the supplied Host suppresses libcurl's aligned one — so validation and connection target can differ. Tested `127.0.0.%31` against `filter_var()` and confirmed loopback reach.
**Exploitation:** Attacker controls a fetched URI; the noncanonical host spelling evades the app's denylist/private-range check while libcurl resolves it to loopback or an internal address, and the response content is read back. Also affects `no_proxy` proxy selection, RedirectMiddleware's Authorization/Cookie stripping decisions, and cookie storage keyed to the wrong host.
**Escalation:** Chaining with cookie middleware: a divergent Host causes Set-Cookie to be stored under the canonical name and replayed on later legitimate requests; a third-party UriInterface with `blocked.example.com@127.0.0.1` reaches loopback and generates `Authorization: Basic` from userinfo the app never wrote.
**Insight:** When a library passes a URL string to a lower-level transport that re-parses it, the validated spelling and the connected spelling can diverge: percent-escapes, IDNA mapping, and userinfo (`user@host`) are all re-interpreted downstream. Check what the transport actually resolves, not what `getHost()` returns.

