{"schema":"threatline.record/2","record":{"id":"GHSA-9pf9-6jr6-fvjr","aliases":["GHSA-9pf9-6jr6-fvjr"],"summary":"CSRF on legacy playlist/video endpoints whose filenames don't match the `.json.php` auto-guard pattern","details":"# CSRF on legacy playlist/video endpoints whose filenames don't match the `.json.php` auto-guard pattern\r\n\r\n- **Severity:** Medium\r\n- **CVSS 3.1:** `AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N` → 4.3 (per-endpoint; worst case is `playlistStatus.php` exposing a private playlist)\r\n- **Affected asset:** WWBN/AVideo, `master` branch, commit `8a32cc70a4` (HEAD at audit time)\r\n- **Reporter:** (this audit)\r\n- **Date:** 2026-10-05\r\n- **Status:** Draft — ready for GHSA/MITRE submission, no CVE self-assigned\r\n\r\n## Summary\r\n\r\nThe codebase's global CSRF mitigation (`objects/include_config.php:333-339`, `autoCSRFGuard()`) only runs for request filenames whose basename ends in the literal string `.json.php`. Several legacy state-changing endpoints — `objects/playlistStatus.php`, `objects/videoSuggest.php`, `objects/playlistRemoveVideo.php`, `objects/videoPinOnChannel.php` — are named `*.php` (not `*.json.php`) and were never given the explicit `forbidIfIsUntrustedRequest()` / `forbidIfInvalidToken()` retrofit that sibling legacy endpoints (`objects/playlistRemove.php`, `objects/playlistSort.php`, `objects/videoEditLight.php`, `objects/userSavePhoto.php`) received for exactly this reason (confirmed by an explicit comment in `playlistRemove.php`: `// filename does not end in .json.php, so autoCSRFGuard() never runs for this endpoint`). This is the direct sibling gap to the CSRF hardening the vendor did in commit `c1901c7f37`, which addressed `objects/functions.php` and `objects/pluginImport.json.php` but did not cover this class of legacy filename.\r\n\r\n\r\n## Affected versions\r\n\r\nTested on branch `master`, commit `8a32cc70a4` (2026-10-05/06). This project ships rolling weekly releases from `master` with no in-repo semantic version string (per `RELEASING.md`), so there is no fixed version tag to cite — treat all deployments running `master` at or before this commit as affected until patched.\r\n\r\n## Reproduction steps\r\n\r\n1. Victim is logged into AVideo in one browser tab.\r\n2. Victim visits an attacker-controlled page containing an auto-submitting cross-origin form targeting one of the vulnerable endpoints, e.g.:\r\n   ```html\r\n   <form id=\"f\" action=\"https://victim-avideo.example/objects/playlistStatus.php\" method=\"POST\">\r\n     <input name=\"playlist_id\" value=\"123\">\r\n     <input name=\"status\" value=\"public\">\r\n   </form>\r\n   <script>document.getElementById('f').submit();</script>\r\n   ```\r\n3. The victim's browser sends the authenticated session cookie along with the cross-origin POST. `objects/playlistStatus.php` only checks `User::isLogged()` and playlist ownership (`User::getId() === $obj->getUsers_id()`) — no CSRF/Origin check — so the request succeeds and the victim's playlist (which may be `private`/`unlisted`) is forced to `public`.\r\n\r\nThe same pattern applies, with different impact, to:\r\n- `objects/videoSuggest.php` — forces `isSuggested` on arbitrary video IDs (requires moderator/admin session; cross-site forged moderation action).\r\n- `objects/playlistRemoveVideo.php` — removes a video from a playlist the victim owns (or manages, if `PlayLists::canManageAllPlaylists()`).\r\n- `objects/videoPinOnChannel.php` — forces `isChannelSuggested` on videos the victim can manage (lower impact: attacker can only affect content the victim already controls).\r\n\r\n## Impact\r\n\r\nAn attacker can force state changes on behalf of a logged-in victim without their knowledge or consent, purely by getting them to load an attacker-controlled page while authenticated:\r\n- **Privacy exposure:** a victim's `private`/`unlisted` playlist can be forcibly made `public` (`playlistStatus.php`) — the most impactful of the four, since it discloses previously-private content to the public without any further action.\r\n- **Content integrity:** forged moderation (`videoSuggest.php`) or playlist-content tampering (`playlistRemoveVideo.php`) performed as the victim.\r\n\r\n## Proof of concept\r\n\r\n```html\r\n<!-- Forces playlist 123 to 'public' for whichever logged-in victim loads this page -->\r\n<form id=\"f\" action=\"https://TARGET/objects/playlistStatus.php\" method=\"POST\">\r\n  <input name=\"playlist_id\" value=\"123\">\r\n  <input name=\"status\" value=\"public\">\r\n</form>\r\n<script>document.getElementById('f').submit();</script>\r\n```\r\n\r\nConfirmed by direct source read — none of the four files call `forbidIfIsUntrustedRequest()` or `forbidIfInvalidToken()`, and none end in `.json.php`:\r\n```php\r\n// objects/include_config.php:333-339\r\nif (\r\n    isset($_SERVER['REQUEST_METHOD']) &&\r\n    substr($baseName, -9) === '.json.php'\r\n) {\r\n    autoCSRFGuard($baseName, $_SERVER['SCRIPT_FILENAME']);\r\n    autoRateLimitGuard($baseName, $_SERVER['SCRIPT_FILENAME']);\r\n}\r\n```\r\n```php\r\n// objects/playlistStatus.php (full file, no CSRF check anywhere)\r\n...\r\nif (!User::isLogged()) { forbiddenPage('Permission denied', true); }\r\n$obj = new PlayList($_POST['playlist_id']);\r\nif (User::getId() !== $obj->getUsers_id()) { forbiddenPage('Permission denied', true); }\r\n$obj->setStatus($_POST['status']);\r\necho '{\"status\":\"'.$obj->save().'\"}';\r\n```\r\nContrast with the already-patched sibling:\r\n```php\r\n// objects/playlistRemove.php\r\n// filename does not end in .json.php, so autoCSRFGuard() never runs for this endpoint\r\nforbidIfNotPost();\r\nforbidIfInvalidToken();\r\n```\r\n\r\n## Remediation\r\n\r\n- Immediate: add `forbidIfIsUntrustedRequest()` (or `forbidIfNotPost(); forbidIfInvalidToken();`, matching the pattern used in `objects/playlistRemove.php`) to each of the four affected files.\r\n- Systemic: change the `autoCSRFGuard()` trigger in `objects/include_config.php:335` from a filename-suffix check to something that can't silently miss a file — e.g. explicit opt-out list (protect by default, name exceptions) rather than explicit opt-in by naming convention. The current design means every *newly added* legacy-named endpoint is CSRF-unprotected by default unless a developer remembers the retrofit pattern.\r\n- Grep the full `objects/*.php` and `plugin/*/*.php` tree (excluding `*.json.php`) for state-changing endpoints (anything calling `->save()`, `->delete()`, `setStatus`, `setIsSuggested`, etc. on `$_POST` input) to confirm these four are the complete set — this audit was not exhaustive beyond the playlist/video-suggestion surface.\r\n\r\n## References\r\n\r\n- CWE-352 (Cross-Site Request Forgery)\r\n- Direct sibling of the fix in commit `c1901c7f37` (\"address CSRF token concerns in functions and plugin import handling\") — same bug class, different (legacy-named) files\r\n- Report to: developer@streamphp.com per `.github/SECURITY.md`\r\n","categories":["Unclassified"],"product":"Avideo","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-9pf9-6jr6-fvjr","source_modified_at":"2026-10-06T12:14:40Z","fetched_at":"2026-10-24T00:31:04.721601Z","package":{"ecosystem":"","name":"Avideo"},"range":"< 24.0"}],"fixed_versions":[],"cvss":3.5,"severity":"Low","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-06T12:14:40Z","modified_at":"2026-10-06T12:14:40Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-9pf9-6jr6-fvjr","fetched_at":"2026-10-24T00:31:04.721601Z","source_modified_at":"2026-10-06T12:14:40Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-06T12:14:40Z","content_hash":"f89150141ffd766a1e15adcf56abd7e458b77e897711dd77b5c13529da38ec68"}}
