CVE-2026-55190 · GHSA-664j-55hw-p8fm · eventschedule · CWE-918 · Medium (CVSS 6.5)
The lead
This one didn't come from a fix — it came from a pattern. I'd been grepping PHP codebases for a specific SSRF smell: a guard that calls gethostbyname() (which resolves only A records) and then makes decisions on the URL string. It's a reliable tell that the guard reasons about representation instead of the resolved destination. eventschedule's UrlUtils::isUrlSafe() matched.
The bug
isUrlSafe() does the sensible-looking thing: pull the host out of the URL, check it against private IP ranges, block if it's internal. Here's the host it gets:
$host = parse_url('http://[::1]:6379/', PHP_URL_HOST);
// $host === "[::1]" — WITH the brackets PHP's parse_url returns the IPv6 host with its square brackets still on. And [::1] is not ::1:
- filter_var("[::1]", FILTER_VALIDATE_IP) → fails (not a valid IP literal)
- the $blockedHosts string comparison → no match
- gethostbyname("[::1]") → returns the string unchanged
So the entire IPv6 private-range branch is skipped and the function falls through to return true. The unbracketed http://127.0.0.1/ is correctly blocked — which is exactly what makes this sneaky. The guard looks like it works, because for the case everyone tests, it does.
Every private IPv6 destination sails through:
isUrlSafe('http://[::1]:6379/') = true # localhost Redis
isUrlSafe('http://[fc00::1]/admin') = true # unique-local
isUrlSafe('http://[fe80::1]/') = true # link-local
isUrlSafe('http://[::ffff:7f00:1]/') = true # == 127.0.0.1, mapped
isUrlSafe('http://[64:ff9b::a9fe:a9fe]/...') = true # NAT64 -> 169.254.169.254
isUrlSafe('http://127.0.0.1/') = false # control: blocked There's a second, quieter gap: for real hostnames, gethostbyname only follows A records and returns the input unchanged when there's no A record — and the code reads "unchanged" as "safe, skip the check." An AAAA-only host pointing at ::1 passes too, and because the validated value is never pinned for the actual fetch, plain DNS rebinding works as well.
Why it actually matters
SSRF findings live or die on whether they're blind. This one isn't. The reached sink (ImportCuratorEvents.php → Http::get($url)) parses the response body and imports it as the user's events, and a second sink does file_get_contents($ogImage). So an internal service's response comes back to the attacker, rendered into their event data. Semi-reflected SSRF, triggered by any authenticated curator who can set an import URL.
The proof, and what it became
I confirmed each row above against the real isUrlSafe() logic before filing — [::1] blocked would have made this a non-finding, and the control row (127.0.0.1 → false) proves the guard isn't just broken across the board.
Then it became a tool. This exact shape — a host taken from parse_url/URL parsing that keeps the [], feeding IPv6 checks that expect a bare address — is now a signature in my SSRF auditor (ssrf_audit.py, rule S4: bracketed-IPv6 bypass). Turned out to be the dominant 2026 SSRF residual: I've since seen the same bracket-keeping gap in several other projects. One finding, one detector, many future catches.
The lesson
Canonicalize before you classify. Strip the brackets, normalize the address (::ffff:7f00:1 and 127.0.0.1 are the same host), resolve dual-stack and fail closed when resolution fails, and pin the validated IP for the request itself (CURLOPT_RESOLVE or a bound resolver) so the value you checked is the value you fetch. A guard that reasons about the URL string is guarding a spelling, not a destination.
Disclosed via GitHub Security Advisory; published as GHSA-664j-55hw-p8fm / CVE-2026-55190.
No comments yet