GHSA-6qvr-wjmv-v8mm
Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths
Details
### 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 `be1e867` shows 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, attacker_router.php, internal_router.php) is available on request.
End-to-end on a real instance: an authenticated user `POST /api/podcasts` (or Subsonic `createPodcastChannel`) with a feed URL on an attacker host that returns `302 Location: http://169.254.169.254/latest/meta-data/...` (or `http://127.0.0.1:<port>/`); the server follows it. The response is reflected back via parsed podcast fields / `getStreamableUrl` when the internal endpoint returns `Access-Control-Allow-Origin: *`; otherwise blind SSRF via status/timing.
### Impact
Authenticated (any user) SSRF: access to cloud instance metadata (IAM credentials on IMDSv1), internal-only admin panels, and internal network service probing — from the Koel server's network position. Same threat model as CVE-2026-47260.
**Attack scenario (fully remote, no user interaction):** the only precondition is a single low-privilege account. On AWS/GCP/Azure-hosted instances, redirecting to the metadata IP and reflecting the body discloses temporary IAM credentials → cloud-account pivot. (AWS IMDSv2's token-via-PUT is not reachable through a simple GET-redirect SSRF; IMDSv1 instances are fully exposed.) "Koel only runs on an internal/trusted network" does not reduce the risk — the bug makes the Koel server itself the attacker's pivot **into** that trusted network and cloud control plane.
Suggested severity: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N (7.1); lower where exploitation is blind.
### Remediation
Do not fix per-call-site. Centralize: route **all** outbound HTTP through a shared Guzzle handler/middleware that, on **every** connection and **every redirect hop**, resolves the target and rejects private/reserved IPs, and **pins** the validated IP for the actual connection (defeats DNS rebinding). Apply to `EpisodePlayable`, `getStreamableUrl`, `Poddle::fromUrl` usage, `isPodcastObsolete`, `HasAudioContentType`, and the `SafeUrl` rule.
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/koel/koel/security/advisories/GHSA-6qvr-wjmv-v8mm [WEB]
- https://github.com/koel/koel/pull/2546 [WEB]
- https://github.com/koel/koel/pull/2549 [WEB]
- https://github.com/koel/koel/commit/5f6ce2cefd08f437a269236b677ad971517ccbb6 [WEB]
- https://github.com/koel/koel/commit/c264a3d52513a83b21e1cc3a20e895caea97fc4a [WEB]
- https://github.com/koel/koel [PACKAGE]
- https://github.com/koel/koel/releases/tag/v9.7.1 [WEB]