🛡️ CVE-2026-54494 — koel
Description
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
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):
```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 <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) conn
How this vulnerability can be exploited
This issue can be reached over the network, attack complexity is low, an attacker needs low-level privileges on the target. No user interaction is required. Rated impact: confidentiality low, integrity low, availability none.
Weakness class
CVE-2026-54494 is classified as CWE-918: Server-Side Request Forgery (SSRF). The server fetches a URL supplied by the caller, which can be pointed at internal systems it alone can reach.
Affected software
CVE-2026-54494 is recorded against 1 package.
- phanan/koel (fixed in 9.7.1)
Timeline and source
Published on 15 July 2026. No public exploit is currently recorded for this entry. Record sourced from OSV.
References
github.com (Web)
github.com (Web)
github.com (Web)
github.com (Package)
github.com (Web)
Details
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
Affected Packages
| Software | From version | Fixed in |
|---|---|---|
| phanan/koel | — | 9.7.1 |
References
Similar Threats
- High CVE-2026-54491
- Unknown CVE-2026-54492
- High CVE-2026-54493
- Unknown GHSA-8q6q-m837-fv64
- Medium CVE-2026-50552
More CVE 2026 advisories
Browse all of CVE 2026 in the advisory index.
Free Vulnerability Check
Is your site affected by CVE-2026-54494?
BotEraser helps you identify potentially vulnerable plugins and themes by checking your installation against CVE-2026-54494 and other known CVE records.
Scan My Site Free →No credit card required · Results in minutes
ⓘ Data Notice: The information presented above has been compiled from publicly available internet sources. Boteraser aggregates this data solely for informational purposes and does not independently classify, evaluate, or endorse any findings about the vulnerabilities listed. The accuracy and completeness of this information is the sole responsibility of the original publishers. Boteraser and its operators accept no liability for any decisions made based on this data.