GHSA-w79m-f3jx-779v Koel: Authenticated Blind SSRF via Subsonic Podcast Channel Creation
SummaryKoel `v9.6.0` protects the regular podcast subscription API with `SafeUrl`, but the Subsonic-compatible `createPodcastChannel.view` route does not apply the same protection. An authenticated user can supply a private URL and cause Koel to fetch it server-side during podcast parsing.This was validated against `v9.6.0` (`352ea5ec27fa22294da8fb6beacb3d5552f0d09c`) using the official `phanan/koel:9.6.0` image.This is distinct from `GHSA-7j2f-6h2r-6cqc`, which fixed unsafe episode enclosure URLs in versions `<= 9.3.4`. The issue here is a newer validation gap in the Subsonic route itself, still present in `v9.6.0`.DetailsSafeUrl protects the regular podcast API onlyThe regular podcast subscription path validates the feed URL with `SafeUrl`:`app/Http/Requests/API/Podcast/PodcastStoreRequest.php`The Subsonic-compatible route does not:`routes/subsonic.php``createPodcastChannel.view``app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php`That creates the same kind of trust-boundary mismatch as the radio issue: the main API rejects private targets, while the compatibility route accepts them.The URL is fetched immediately by the podcast parserThe attacker-controlled URL is used by the podcast service during channel creation:`app/Http/Controllers/Subsonic/CreatePodcastChannelController.php``app/Services/Podcast/PodcastService.php``PodcastService::addPodcast()` calls:and `createParser()` resolves to:This means the SSRF happens as part of the channel creation flow itself. No separate playback step is needed.This bypasses Koel's intended SSRF control for podcast URLsKoel already added `SafeUrl` to the regular podcast API and has already published a podcast-related SSRF advisory. The Subsonic route does not reuse that same control, so it reintroduces a server-side fetch primitive for private destinations.PoCThe following steps were validated against the official `phanan/koel:9.6.0` image.Authenticate and obtain an API token:Obtain the user's Subsonic API key:Prepare an internal-only target URL. In my validation, I used a host-side RSS fixture reachable from the container through the Docker bridge:Confirm the regular web API blocks the URL:Expected result:HTTP `422`Error includes `The url must point to a public URL.`Trigger the Subsonic route with the same URL:Expected result:HTTP `200`JSON includes `"status":"ok"`Confirm the server-side request happened by checking the internal HTTP service logs.During validation, the local HTTP test server received `HEAD` and `GET ` requests for `/feed.xml?run=1`.ImpactAn authenticated user can make Koel send server-side HTTP requests to internal destinations that are intentionally blocked by the main web API.Validated impact:SSRF to loopback, Docker-bridge, and RFC1918 HTTP destinations reachable from the Koel serverInternal service discovery and request execution through the podcast parserGeneric response-body exfiltration was not validated through this exact route. The confirmed impact is SSRF-based internal request execution.RemediationThe Subsonic podcast request validator should apply `SafeUrl`, and the parser entry point should reject unsafe targets as defense in depth.Suggested patch for `app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php`:Suggested defense-in-depth patch for `app/Services/Podcast/PodcastService.php`:
CVSS v3.1 score: 4.3