GitHub Advisoryadvisory · Packagist

GHSA-rjg7-r26h-cfp2 Koel: Full-read SSRF via podcast enclosure URL: isPublicHost() filter_var guard does not reject NAT64 (64:ff9b::/96) or 6to4 (2002::/16) IPv6-transition wrappers of internal IPv4

MEDIUM Published 2026-07-15 Modified 2026-07-15 Official page ↗
Aliases
CVE-2026-54494
Ecosystem
Packagist
Packages
phanan/koel
Severity
Moderate
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Weaknesses
CWE-918
Reviewed
2026-07-15

Affected packages

• Packagist phanan/koel: < 9.7.1 → fixed in 9.7.1

Description

Summary Koel's outbound-URL guard `App\Helpers\Network::isPublicHost()` classifies an IP as "public" using PHP's `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)`. That flag set does not recognise IPv6 transition-address forms that embed a private/loopback/link-local IPv4: NAT64 well-known prefix `64:ff9b::/96` (RFC 6052) and 6to4 `2002::/16` (RFC 3056). An address such as `64:ff9b::7f00:1` (= `127.0.0.1`), `64:ff9b::a9fe:a9fe` (= `169.254.169.254`, the cloud metadata endpoint), or `2002:a00:1::` (= `10.0.0.1`) is reported as a public address, so the guard returns `true` and Koel proceeds to fetch the URL. The guard is the only SSRF defense in front of `App\Values\Podcast\EpisodePlayable::createForEpisode()`, which downloads a podcast episode with `Http::sink($file)->get($url)` and streams the response body back to the requesting user. Because an attacker fully controls the `` of any RSS feed they host (and any authenticated user can subscribe to a feed), they can publish an enclosure whose hostname has an `AAAA` record that is a NAT64/6to4 wrapper of an internal IP. On hosts with NAT64 or 6to4/dual-stack routing (the standard configuration on IPv6-only AWS/GCP subnets and 6to4-relayed networks), the kernel routes the wrapper to the embedded IPv4, and Koel performs a full-read SSRF against the internal endpoint — returning the response body to the attacker. This is a server-side request forgery with full response disclosure (CWE-918) against internal services and cloud instance metadata. Vulnerable code `app/Helpers/Network.php` — `isPublicHost()` (the literal-IP branch and the per-resolved-record branch use the identical predicate): ```php public function isPublicHost(string $host): bool { if (filter_var($host, FILTER_VALIDATE_IP)) { return ( filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false ); } try { $records = array_merge(dns_get_record($host, DNS_A) ?: [], dns_get_record($host, DNS_AAAA) ?: []); } catch (Throwable) { return false; } if ($records === []) { return false; } foreach ($records as $record) { $ip = $record['ip'] ?? $record['ipv6'] ?? null; if ( !$ip || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false ) { return false; } } return true; } ``` `PHP`'s `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE` rejects RFC 1918, loopback, link-local and IPv4-mapped IPv6 (`::ffff:a.b.c.d`), but treats NAT64 `64:ff9b::/96` and 6to4 `2002::/16` as ordinary global addresses — even though both forms deterministically embed an IPv4 the kernel will route to. The sink, `app/Values/Podcast/EpisodePlayable.php` — `createForEpisode()`: ```php $network = app(Network::class); $url = (string) $episode->path; if (!$network->isSafeUrl($url)) { // isSafeUrl() -> isPublicHost(), the only guard throw UnsafeUrlException::forUrl($url); } Http::sink($file) ->withOptions([ 'allow_redirects' => [ 'max' => 5, 'on_redirect' => static function ( RequestInterface $request, ResponseInterface $response, UriInterface $uri, ) use ($network): void { if (!$network->isSafeUrl((string) $uri)) { // same guard on redirects -> same bypass throw UnsafeUrlException::forUrl((string) $uri); } }, ], ]) ->get($url) // full-read SSRF: response streamed into $file ->throw(); ``` `$episode->path` is the `` from the subscribed podcast RSS feed. The redirect callback reuses the same `isSafeUrl()`, so a redirect to a NAT64/6to4 host is also accepted. Attack scenario / How input reaches the sink 1. Attacker hosts a podcast RSS feed and serves an item whose enclosure is ``, where `int.attacker.example` publishes `AAAA = 64:ff9b::a9fe:a9fe` (NAT64 wrapper of `169.254.169.254`) or `2002:a00:1::` (6to4 wrapper of `10.0.0.1`). The attacker may also use a bare IPv6-literal enclosure host directly. 2. A Koel user subscribes to the feed (a standard, intended feature — the podcast subscription endpoint accepts an arbitrary feed URL) and plays / streams the episode. 3. `EpisodePlayable::createForEpisode()` calls `isSafeUrl($url)`. The host resolves to the NAT64/6to4 address; `isPublicHost()` runs `filter_var(NO_PRIV_RANGE | NO_RES_RANGE)` over the embedded-IPv4 transition form and returns `true`. 4. `Http::sink($file)->get($url)` connects. On a NAT64/dual-stack/6to4-routed host the kernel forwards to the embedded internal IPv4. The internal response body is written to `$file` and served back to the user — full-read SSRF against internal services / cloud IMDS. Proof of concept (a) Guard-predicate proof (PHP 8.5, the exact `filter_var` call) ```php loopback ip -6 addr add 2002:7f00:1::/128 dev lo # 6to4 wrapper of 127.0.0.1 -> loopback ``` A localhost stand-in "internal IMDS" server listens on those literals and returns `SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64`. The harness boots a real Laravel container, resolves the genuine released `App\Helpers\Network` (from `app/Helpers/Network.php`), invokes its real `isPublicHost()` on each attacker `AAAA`-record value, then runs the verbatim `EpisodePlayable::createForEpisode()` body (`isSafeUrl` guard, then `Http::sink($file)->get($url)` via Laravel's real Guzzle-backed client): ```php $network = $app->make(App\Helpers\Network::class); // resolved from app/Helpers/Network.php // STEP 1: genuine guard decision on the attacker AAAA-record value for…

Related

Alias · CVE (1)

  • CVE-2026-54494 Koel is a free, open-source music streaming solution. vulnerability

Weakness · CWE (1)

  • CWE-918 Server-Side Request Forgery weakness

References