{"id":"GHSA-6qvr-wjmv-v8mm","summary":"Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths","details":"### Summary\n\nThe 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.\n\nAn **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.\n\n\u003e 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.\n\n### Details — Root cause\n\n`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.\n\n### Affected paths (all reachable by any authenticated user)\n\n| # | Location | Issue |\n|---|----------|-------|\n| 1 | `PhanAn\\Poddle\\Poddle::fromUrl()` → `Http::timeout()-\u003eget($url)` (used by `PodcastService::addPodcast`/`refreshPodcast`) | Plain `Http::get`, follows redirects, no per-hop validation; `refreshPodcast` does not re-run `isSafeUrl` at all |\n| 2 | `PodcastService::getStreamableUrl()` (`PodcastService.php:244`/`251`) | Has the initial `isSafeUrl()` (line 244) but the request uses `ALLOW_REDIRECTS =\u003e ['track_redirects' =\u003e true]` with **no `on_redirect`** → 302 to internal is followed. Called at episode stream time via `PodcastStreamerAdapter`. Also DNS-rebinding-exposed |\n| 3 | `PodcastService::isPodcastObsolete()` (`:221`) `Http::head($podcast-\u003eurl)` | No `isSafeUrl`, no redirect validation |\n| 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`) |\n| 5 | `App\\Rules\\SafeUrl` validator (`:52`/`:56`) | Follows redirects, validates only the **final** effective host — intermediate-hop requests still fire |\n\nReachable via the native API (`apiResource podcasts`, `radio/stations`; `PodcastController::store` has **no authorization check**, only `#[DisabledInDemo]`) and the Subsonic API (`createPodcastChannel`, `createInternetRadioStation`, `refreshPodcasts`).\n\n### PoC\n\nA 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:\n\n```\nisPublicHost('127.0.0.1') = false        # a per-hop check WOULD block this\nCase1 Poddle::fromUrl        -\u003e [VULNERABLE]  leaked INTERNAL-SECRET-TOKEN\nCase2 getStreamableUrl       -\u003e [VULNERABLE]  leaked INTERNAL-SECRET-TOKEN\nCase3 EpisodePlayable        -\u003e [BLOCKED]     UnsafeUrl on redirect\ninternal_hits.log: 2 hits    # internal service actually reached by Case1 + Case2\n```\n\nCase1/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.\n\nEnd-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:\u003cport\u003e/`); 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.\n\n### Impact\n\nAuthenticated (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.\n\n**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.\n\nSuggested 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.\n\n### Remediation\n\nDo 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.","aliases":["CVE-2026-54491"],"modified":"2026-07-15T18:26:42.358375Z","published":"2026-07-15T17:59:30Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-07-15T17:59:30Z","nvd_published_at":null,"cwe_ids":["CWE-918"]},"references":[{"type":"WEB","url":"https://github.com/koel/koel/security/advisories/GHSA-6qvr-wjmv-v8mm"},{"type":"WEB","url":"https://github.com/koel/koel/pull/2546"},{"type":"WEB","url":"https://github.com/koel/koel/pull/2549"},{"type":"WEB","url":"https://github.com/koel/koel/commit/5f6ce2cefd08f437a269236b677ad971517ccbb6"},{"type":"WEB","url":"https://github.com/koel/koel/commit/c264a3d52513a83b21e1cc3a20e895caea97fc4a"},{"type":"PACKAGE","url":"https://github.com/koel/koel"},{"type":"WEB","url":"https://github.com/koel/koel/releases/tag/v9.7.1"}],"affected":[{"package":{"name":"phanan/koel","ecosystem":"Packagist","purl":"pkg:composer/phanan/koel"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"9.7.1"}]}],"versions":["1.0.0-beta","v0.0.0-beta","v1.1.1","v1.1.2","v2.0.0","v2.0.1","v2.0.2","v2.1.0","v2.2.0","v2.2.1","v3.0.0","v3.0.1","v3.1.0","v3.1.1","v3.2.0","v3.3.0","v3.3.1","v3.4.0","v3.4.1","v3.5.0","v3.5.1","v3.5.2","v3.5.3","v3.5.4","v3.5.5","v3.6.0","v3.6.1","v3.6.2","v3.7.0","v3.7.1","v3.7.2","v4.0.0","v4.1.0","v4.1.1","v4.2.0","v4.2.1","v4.2.2","v4.3.0","v4.3.1","v4.4.0","v5.0.0","v5.0.1","v5.0.2","v5.1.0","v5.1.1","v5.1.10","v5.1.11","v5.1.12","v5.1.13","v5.1.14","v5.1.2","v5.1.3","v5.1.4","v5.1.5","v5.1.6","v5.1.7","v5.1.8","v5.1.9","v6.0.0","v6.0.1","v6.0.2","v6.0.3","v6.0.4","v6.0.5","v6.0.6","v6.1.0","v6.10.0","v6.11.0","v6.11.1","v6.11.2","v6.11.3","v6.11.4","v6.11.5","v6.12.0","v6.12.1","v6.2.0","v6.2.1","v6.2.2","v6.3.0","v6.4.0","v6.4.1","v6.4.2","v6.4.3","v6.5.0","v6.5.1","v6.5.2","v6.5.3","v6.6.0","v6.7.0","v6.7.1","v6.7.2","v6.7.3","v6.7.4","v6.7.5","v6.8.0","v6.8.1","v6.8.2","v6.8.3","v6.8.4","v6.8.5","v6.9.0","v7.0.0","v7.0.1","v7.0.10","v7.0.11","v7.0.12","v7.0.2","v7.0.3","v7.0.4","v7.0.5","v7.0.6","v7.0.7","v7.0.8","v7.0.9","v7.1.0","v7.10.0","v7.10.1","v7.10.2","v7.10.3","v7.10.4","v7.11.0","v7.12.0","v7.13.0","v7.14.0","v7.15.0","v7.15.1","v7.2.0","v7.2.1","v7.2.2","v7.3.0","v7.3.1","v7.4.0","v7.4.1","v7.4.2","v7.5.0","v7.5.1","v7.5.2","v7.6.0","v7.6.1","v7.6.2","v7.6.3","v7.7.0","v7.7.1","v7.8.0","v7.8.1","v7.9.0","v8.0.0","v8.1.0","v8.2.0","v8.3.0","v8.3.1","v9.0.0","v9.1.0","v9.1.1","v9.1.2","v9.2.0","v9.2.1","v9.3.0","v9.3.1","v9.3.2","v9.3.3","v9.3.4","v9.3.5","v9.3.6","v9.4.0","v9.4.1","v9.4.2","v9.5.0","v9.6.0","v9.7.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-6qvr-wjmv-v8mm/GHSA-6qvr-wjmv-v8mm.json","last_known_affected_version_range":"\u003c= 9.7.0"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N"}]}