CVE-2026-54491
Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths
Koel is a free, open-source music streaming solution. Prior to 9.7.1, outbound podcast and radio fetch paths perform a point-in-time App\Helpers\Network::isPublicHost() or isSafeUrl() check without pinning the validated address, and most paths lack redirect-hop validation and do not revalidate every redirect target. PhanAn\Poddle\Poddle::fromUrl(), PodcastService::getStreamableUrl(), PodcastService::isPodcastObsolete(), App\Rules\HasAudioContentType, and App\Rules\SafeUrl can therefore follow an attacker-controlled redirect to an internal address or connect after DNS rebinding changes a public resolution to a private one. These paths are reachable through podcast and radio APIs, including createPodcastChannel, createInternetRadioStation, refreshPodcasts, apiResource podcasts, and radio/stations, allowing an authenticated user to request internal services or cloud metadata and potentially receive parsed or streamed response content. This issue is fixed in version 9.7.1.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- Low
- User interaction
- None
What an attacker can affect
- Confidentiality
- High
- Integrity
- Low
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
7.1
Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths
Koel is a free, open-source music streaming solution. Prior to 9.7.1, outbound podcast and radio fetch paths perform a point-in-time App\Helpers\Network::isPublicHost() or isSafeUrl() check without pinning the validated address, and most paths lack redirect-hop validation and do not revalidate every redirect target. PhanAn\Poddle\Poddle::fromUrl(), PodcastService::getStreamableUrl(), PodcastService::isPodcastObsolete(), App\Rules\HasAudioContentType, and App\Rules\SafeUrl can therefore follow an attacker-controlled redirect to an internal address or connect after DNS rebinding changes a public resolution to a private one. These paths are reachable through podcast and radio APIs, including createPodcastChannel, createInternetRadioStation, refreshPodcasts, apiResource podcasts, and radio/stations, allowing an authenticated user to request internal services or cloud metadata and potentially receive parsed or streamed response content. This issue is fixed in version 9.7.1.
- Record
CVE-2026-54491- CVSS 3.1
- 7.1 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
EUVDEUVD-2026-63088
7.1
Koel is a free, open-source music streaming solution. Prior to 9.7.1, outbound podcast and radio fetch paths perform a point-in-time App\Helpers\Network::isPublicHost() or isSafeUrl() check without pinning the validated address, and most paths lack redirect-hop validation and do not revalidate every redirect target. PhanAn\Poddle\Poddle::fromUrl(), PodcastService::getStreamableUrl(), PodcastService::isPodcastObsolete(), App\Rules\HasAudioContentType, and App\Rules\SafeUrl can therefore follow an attacker-controlled redirect to an internal address or connect after DNS rebinding changes a public resolution to a private one. These paths are reachable through podcast and radio APIs, including createPodcastChannel, createInternetRadioStation, refreshPodcasts, apiResource podcasts, and radio/stations, allowing an authenticated user to request internal services or cloud metadata and potentially receive parsed or streamed response content. This issue is fixed in version 9.7.1.
- Record
EUVD-2026-63088- CVSS 3.1
- 7.1 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-6qvr-wjmv-v8mm
7.1
Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths
Summary
The fix for CVE-2026-47260 (v9.3.5) added an initial isSafeUrl() check to several fetchers (synchronizeEpisodes, getStreamableUrl, AddRadioStation, EpisodePlayable), but the redirect-target validation — the per-hop Guzzle on_redirect callback added in follow-up commit be1e867 — was applied to only one path, EpisodePlayable. Every other server-side fetcher therefore has only the initial check, which an HTTP 302 redirect to an internal address bypasses, or no check at all. DNS rebinding (validation and connection resolve DNS separately, with no IP pinning) bypasses the initial check on every path.
An authenticated, non-admin user can thus cause the Koel server to issue requests to arbitrary internal / cloud-metadata endpoints (SSRF) by supplying a URL on an attacker-controlled host that 302-redirects to an internal address.
Note: commit
be1e867shows the redirect-based SSRF vector was recognised, but the redirect defense was applied to a single call site rather than generalised — so the class survives in the sibling paths below.
Details — Root cause
App\Helpers\Network::isPublicHost() / isSafeUrl() perform a point-in-time host check with no pinning of the resolved IP, and per-redirect-hop re-validation exists only in App\Values\Podcast\EpisodePlayable (the on_redirect callback from commit be1e867). Consequently every other fetcher is exposed to (1) redirect SSRF — initial URL passes isSafeUrl, then the HTTP client follows a cross-host 302 to an internal target without re-validating the hop; and (2) DNS rebinding (TOCTOU) — isPublicHost resolves DNS at validation, the HTTP client resolves again at connect time.
Affected paths (all reachable by any authenticated user)
| # | Location | Issue |
|---|----------|-------|
| 1 | PhanAn\Poddle\Poddle::fromUrl() → Http::timeout()->get($url) (used by PodcastService::addPodcast/refreshPodcast) | Plain Http::get, follows redirects, no per-hop validation; refreshPodcast does not re-run isSafeUrl at all |
| 2 | PodcastService::getStreamableUrl() (PodcastService.php:244/251) | Has the initial isSafeUrl() (line 244) but the request uses ALLOW_REDIRECTS => ['track_redirects' => true] with no on_redirect → 302 to internal is followed. Called at episode stream time via PodcastStreamerAdapter. Also DNS-rebinding-exposed |
| 3 | PodcastService::isPodcastObsolete() (:221) Http::head($podcast->url) | No isSafeUrl, no redirect validation |
| 4 | App\Rules\HasAudioContentType (:45/:54) Http::head/Http::get | Self-documented "use after SafeUrl"; ordering-dependent, no own validation, no per-hop check. Extends the surface to the internet-radio feature (RadioStationStore/UpdateRequest) |
| 5 | App\Rules\SafeUrl validator (:52/:56) | Follows redirects, validates only the final effective host — intermediate-hop requests still fire |
Reachable via the native API (apiResource podcasts, radio/stations; PodcastController::store has no authorization check, only #[DisabledInDemo]) and the Subsonic API (createPodcastChannel, createInternetRadioStation, refreshPodcasts).
PoC
A mechanism PoC that runs the exact Guzzle/Laravel-Http call shapes Koel uses (attacker-redirect server + internal-target listener on loopback), verified on PHP 8.2 + Guzzle 7:
isPublicHost('127.0.0.1') = false # a per-hop check WOULD block this
Case1 Poddle::fromUrl -> [VULNERABLE] leaked INTERNAL-SECRET-TOKEN
Case2 getStreamableUrl -> [VULNERABLE] leaked INTERNAL-SECRET-TOKEN
Case3 EpisodePlayable -> [BLOCKED] UnsafeUrl on redirect
internal_hits.log: 2 hits # internal service actually reached by Case1 + Case2
Case1/Case2 reaching the internal target while Case3 (the fixed path) blocks under identical conditions demonstrates the incomplete remediation. The full PoC kit (poc.php,…
- Record
GHSA-6qvr-wjmv-v8mm- CVSS 3.1
- 7.1 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
NVD
7.1
Koel is a free, open-source music streaming solution. Prior to 9.7.1, outbound podcast and radio fetch paths perform a point-in-time App\Helpers\Network::isPublicHost() or isSafeUrl() check without pinning the validated address, and most paths lack redirect-hop validation and do not revalidate every redirect target. PhanAn\Poddle\Poddle::fromUrl(), PodcastService::getStreamableUrl(), PodcastService::isPodcastObsolete(), App\Rules\HasAudioContentType, and App\Rules\SafeUrl can therefore follow an attacker-controlled redirect to an internal address or connect after DNS rebinding changes a public resolution to a private one. These paths are reachable through podcast and radio APIs, including createPodcastChannel, createInternetRadioStation, refreshPodcasts, apiResource podcasts, and radio/stations, allowing an authenticated user to request internal services or cloud metadata and potentially receive parsed or streamed response content. This issue is fixed in version 9.7.1.
- Record
CVE-2026-54491- CVSS 3.1
- 7.1 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
OSV
7.1
Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths
Koel is a free, open-source music streaming solution. Prior to 9.7.1, outbound podcast and radio fetch paths perform a point-in-time App\Helpers\Network::isPublicHost() or isSafeUrl() check without pinning the validated address, and most paths lack redirect-hop validation and do not revalidate every redirect target. PhanAn\Poddle\Poddle::fromUrl(), PodcastService::getStreamableUrl(), PodcastService::isPodcastObsolete(), App\Rules\HasAudioContentType, and App\Rules\SafeUrl can therefore follow an attacker-controlled redirect to an internal address or connect after DNS rebinding changes a public resolution to a private one. These paths are reachable through podcast and radio APIs, including createPodcastChannel, createInternetRadioStation, refreshPodcasts, apiResource podcasts, and radio/stations, allowing an authenticated user to request internal services or cloud metadata and potentially receive parsed or streamed response content. This issue is fixed in version 9.7.1.
- Record
CVE-2026-54491- CVSS 3.1
- 7.1 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
OSVGHSA-6qvr-wjmv-v8mm
7.1
Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths
Summary
The fix for CVE-2026-47260 (v9.3.5) added an initial isSafeUrl() check to several fetchers (synchronizeEpisodes, getStreamableUrl, AddRadioStation, EpisodePlayable), but the redirect-target validation — the per-hop Guzzle on_redirect callback added in follow-up commit be1e867 — was applied to only one path, EpisodePlayable. Every other server-side fetcher therefore has only the initial check, which an HTTP 302 redirect to an internal address bypasses, or no check at all. DNS rebinding (validation and connection resolve DNS separately, with no IP pinning) bypasses the initial check on every path.
An authenticated, non-admin user can thus cause the Koel server to issue requests to arbitrary internal / cloud-metadata endpoints (SSRF) by supplying a URL on an attacker-controlled host that 302-redirects to an internal address.
Note: commit
be1e867shows the redirect-based SSRF vector was recognised, but the redirect defense was applied to a single call site rather than generalised — so the class survives in the sibling paths below.
Details — Root cause
App\Helpers\Network::isPublicHost() / isSafeUrl() perform a point-in-time host check with no pinning of the resolved IP, and per-redirect-hop re-validation exists only in App\Values\Podcast\EpisodePlayable (the on_redirect callback from commit be1e867). Consequently every other fetcher is exposed to (1) redirect SSRF — initial URL passes isSafeUrl, then the HTTP client follows a cross-host 302 to an internal target without re-validating the hop; and (2) DNS rebinding (TOCTOU) — isPublicHost resolves DNS at validation, the HTTP client resolves again at connect time.
Affected paths (all reachable by any authenticated user)
| # | Location | Issue |
|---|----------|-------|
| 1 | PhanAn\Poddle\Poddle::fromUrl() → Http::timeout()->get($url) (used by PodcastService::addPodcast/refreshPodcast) | Plain Http::get, follows redirects, no per-hop validation; refreshPodcast does not re-run isSafeUrl at all |
| 2 | PodcastService::getStreamableUrl() (PodcastService.php:244/251) | Has the initial isSafeUrl() (line 244) but the request uses ALLOW_REDIRECTS => ['track_redirects' => true] with no on_redirect → 302 to internal is followed. Called at episode stream time via PodcastStreamerAdapter. Also DNS-rebinding-exposed |
| 3 | PodcastService::isPodcastObsolete() (:221) Http::head($podcast->url) | No isSafeUrl, no redirect validation |
| 4 | App\Rules\HasAudioContentType (:45/:54) Http::head/Http::get | Self-documented "use after SafeUrl"; ordering-dependent, no own validation, no per-hop check. Extends the surface to the internet-radio feature (RadioStationStore/UpdateRequest) |
| 5 | App\Rules\SafeUrl validator (:52/:56) | Follows redirects, validates only the final effective host — intermediate-hop requests still fire |
Reachable via the native API (apiResource podcasts, radio/stations; PodcastController::store has no authorization check, only #[DisabledInDemo]) and the Subsonic API (createPodcastChannel, createInternetRadioStation, refreshPodcasts).
PoC
A mechanism PoC that runs the exact Guzzle/Laravel-Http call shapes Koel uses (attacker-redirect server + internal-target listener on loopback), verified on PHP 8.2 + Guzzle 7:
isPublicHost('127.0.0.1') = false # a per-hop check WOULD block this
Case1 Poddle::fromUrl -> [VULNERABLE] leaked INTERNAL-SECRET-TOKEN
Case2 getStreamableUrl -> [VULNERABLE] leaked INTERNAL-SECRET-TOKEN
Case3 EpisodePlayable -> [BLOCKED] UnsafeUrl on redirect
internal_hits.log: 2 hits # internal service actually reached by Case1 + Case2
Case1/Case2 reaching the internal target while Case3 (the fixed path) blocks under identical conditions demonstrates the incomplete remediation. The full PoC kit (poc.php,…
- Record
GHSA-6qvr-wjmv-v8mm- CVSS 3.1
- 7.1 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N - Weakness
- CWE-918
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
phanan/koelPackagist |
through 9.7.0 | 9.7.1 |
phanan/koelPackagist |
before 9.7.1, plus listed versions 1.0.0-betav0.0.0-betav1.1.1v1.1.2v2.0.0 Show 45 moreShow lessv2.0.1v2.0.2v2.1.0v2.2.0v2.2.1v3.0.0v3.0.1v3.1.0v3.1.1v3.2.0v3.3.0v3.3.1v3.4.0v3.4.1v3.5.0v3.5.1v3.5.2v3.5.3v3.5.4v3.5.5v3.6.0v3.6.1v3.6.2v3.7.0v3.7.1v3.7.2v4.0.0v4.1.0v4.1.1v4.2.0v4.2.1v4.2.2v4.3.0v4.3.1v4.4.0v5.0.0v5.0.1v5.0.2v5.1.0v5.1.1v5.1.10v5.1.11v5.1.12v5.1.13v5.1.14 |
9.7.1 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-54491 is a high vulnerability (CVSS 7.1) in Koel, published July 15, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.38%, the 32nd percentile. Fixed in phanan/koel 9.7.1.
References
Left out here: 120 versions and the rest of 2 source descriptions. The JSON record has them.