Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘
🐘 Packagist
Not in CISA KEV
HIGH severity

CVE-2026-54491 — koel

HIGHFix: koel/koel@5f6ce2c

CVE-2026-54491 is a high-severity (CVSS 7.1) Server-Side Request Forgery (SSRF) vulnerability in phanan/koel. A fix is available for phanan/koel — see the affected versions and patch details below.

Koel: Incomplete fix for CVE-2026-47260 — systemic SSRF in podcast & radio fetch paths

Also known asGHSA-6qvr-wjmv-v8mm
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 23, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for CVE-2026-54491.

EPSS Exploitation Probability

via FIRST.org ↗
0.4%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs31th percentile — riskier than 31% of all scored CVEsHighest risk
0.00%0.29%0.59%0.88%0.2%0.4%0.4%Sep 26Oct 26Oct 26

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

CVE-2026-54491 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 395,249 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐘phanan/koel

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.

Description

Summary

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.

Note: commit be1e867 shows the redirect-based SSRF vector was recognised, but the redirect defense was applied to a single call site rather than generalised — so the class survives in the sibling paths below.

Details — 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; and (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)

#LocationIssue
1PhanAn\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
2PodcastService::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
3PodcastService::isPodcastObsolete() (:221) Http::head($podcast->url)No isSafeUrl, no redirect validation
4App\Rules\HasAudioContentType (:45/:54) Http::head/Http::getSelf-documented "use after SafeUrl"; ordering-dependent, no own validation, no per-hop check. Extends the surface to the internet-radio feature (RadioStationStore/UpdateRequest)
5App\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 #[DisabledInDemo]) and the Subsonic API (createPodcastChannel, createInternetRadioStation, refreshPodcasts).

PoC

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        -> [VULNERABLE]  leaked INTERNAL-SECRET-TOKEN
Case2 getStreamableUrl       -> [VULNERABLE]  leaked INTERNAL-SECRET-TOKEN
Case3 EpisodePlayable        -> [BLOCKED]     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 (poc.php, attacker_router.php, internal_router.php) is available on request.

End-to-end on a real instance: an authenticated user POST /api/podcasts (or Subsonic createPodcastChannel) with a feed URL on an attacker host that returns 302 Location: http://169.254.169.254/latest/meta-data/... (or http://127.0.0.1:<port>/); the server follows it. The response is reflected back via parsed podcast fields / getStreamableUrl when the internal endpoint returns Access-Control-Allow-Origin: *; otherwise blind SSRF via status/timing.

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.

Suggested severity: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N (7.1); lower where exploitation is blind.

Remediation

Do not fix per-call-site. Centralize: route all outbound HTTP through a shared Guzzle handler/middleware that, on every connection and every redirect hop, resolves the target and rejects private/reserved IPs, and pins the validated IP for the actual connection (defeats DNS rebinding). Apply to EpisodePlayable, getStreamableUrl, Poddle::fromUrl usage, isPodcastObsolete, HasAudioContentType, and the SafeUrl rule.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistphanan/koelall versions9.7.1composer require phanan/koel:^9.7.1

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for phanan/koel, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update phanan/koel to 9.7.1 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-54491 is resolved across your whole dependency graph.

  3. Workarounds

    Restrict outbound requests from the affected component to an allowlist of hosts, block access to link-local and internal address ranges at the network layer, and require authentication on internal services so a forged request cannot reach them unauthenticated.

Frequently Asked Questions

### Summary 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 n
O3 Security · Impact-Aware SCA

Is CVE-2026-54491 in your dependencies?

Find it across Packagist, including transitive dependencies.

CVE-2026-54491: koel SSRF — Fixed in 9.7.1