{"schema":"threatline.record/2","record":{"id":"GHSA-6vf7-mrj2-7c37","aliases":["GHSA-6vf7-mrj2-7c37"],"summary":"phpMyFAQ group-permission API grants rights without preserving language/category scope; a GROUP_EDIT admin widens scope","details":"## Summary\r\n\r\nA non-superadmin administrator who holds the delegable `GROUP_EDIT` right can escalate the language and category scope of a content right they legitimately hold in a narrow scope, gaining that right across all languages and categories. The admin group-permission API endpoints `GroupController::updatePermissions` and `GroupController::updateMembers` gate a non-superadmin only with a scope-blind \"rights you hold\" check, then write an unrestricted group grant. Because the per-right restriction tables treat a missing restriction row as global, the widened right takes effect for the attacker and any user they can place in the group.\r\n\r\n## Root Cause\r\n\r\nThe application enforces per-right language and category restrictions through the `faqgroup_right_language`, `faqgroup_right_category`, `faquser_right_language`, and `faquser_right_category` tables. Enforcement is scope-aware, but the delegation guard on the grant path is not. When a non-superadmin submits rights to grant, `updatePermissions` only calls `MediumPermission::hasPermission($actingUserId, $rightId)`, whose SQL joins `faqgroup_right` and `faquser_right` alone and never reads the restriction tables. A right the attacker holds in a single restricted scope therefore passes the guard identically to a globally held right. The controller then calls `refuseAllGroupRights` followed by `grantGroupRight`, which inserts a single `faqgroup_right` row and no restriction row, so the group holds the right unrestricted. The restriction-setting endpoints do apply the scope-aware `mayAssignLanguages` and `mayAssignCategories` checks, but the grant path does not, so the intended anti-widening boundary is absent exactly where the grant is written.\r\n\r\n```text\r\nAffected functions:\r\n- phpMyFAQ\\Controller\\Administration\\Api\\GroupController::updatePermissions (scope-blind guard then unrestricted grant)\r\n- phpMyFAQ\\Controller\\Administration\\Api\\GroupController::updateMembers (scope-blind guard permitting self-join)\r\n- phpMyFAQ\\Permission\\MediumPermissionRepository::grantGroupRight (writes a grant row with no restriction row)\r\nSource (repository-relative):\r\n- phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php (updatePermissions lines 564-571, 578-589; updateMembers lines 476-493; updateGroup auto-join lines 393-408)\r\n- phpmyfaq/src/phpMyFAQ/Permission/MediumPermission.php (hasPermission lines 83-100)\r\n- phpmyfaq/src/phpMyFAQ/Permission/MediumPermissionRepository.php (grantGroupRight lines 158-172; checkUserGroupRight lines 116-150)\r\n- phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php (checkUserGroupRightForLanguage lines 312-355)\r\n```\r\n\r\n## Impact\r\n\r\nAn administrator whose content rights were deliberately scoped to specific languages or categories (a common editorial delegation model) can escalate those rights to all languages and categories, and can propagate the widened scope to other users through group membership. The capability gained is unrestricted create, edit, and publish access over the FAQ knowledge base in scopes the attacker was explicitly denied. The escalation is bounded to rights the attacker already holds in some scope; it does not reach the superadmin flag or superadmin-only rights, because the scope-blind check still requires holding the right somewhere. Confidentiality impact is limited to reading content (including drafts or restricted-language items) in the newly reachable scopes.\r\n\r\n## Proof of Concept\r\n\r\n`Preconditions: MediumPermission (group permissions) enabled; attacker A is a non-superadmin admin holding GROUP_EDIT and holding a content right R (for example edit_faq) restricted to language en via faquser_right(A,R) plus faquser_right_language(A,R,'en'). Baseline hasPermissionForLanguage(A,R,'de') is false.`\r\n\r\n1. As A, call `POST /admin/api/group/permissions` with a valid CSRF token for a group G the attacker controls (a fresh group, or any group whose current rights are a subset of A's rights), submitting `rightIds=[R]`. The scope-blind guard passes because A holds R in some scope; the controller runs `refuseAllGroupRights(G)` then `grantGroupRight(G,R)`, writing an unrestricted grant.\r\n2. As A, call `POST /admin/api/group/members` for group G submitting A as a member. The guard checks A against G's current rights (only R) and passes; A is added to G.\r\n3. Observe that A can now add, edit, and publish FAQ content in language `de` and all other languages and categories.\r\n\r\n```text\r\nBefore: hasPermissionForLanguage(A, edit_faq, 'de') == false\r\nAfter : hasPermissionForLanguage(A, edit_faq, 'de') == true\r\n```\r\n\r\nThe repository test `GroupControllerTest::testUpdatePermissionsRejectsRightNotHeldByNonSuperAdmin` only exercises the right-held-nowhere dimension; no existing test asserts scope preservation across `updatePermissions` or `updateMembers`.\r\n\r\n## Attack Chain\r\n\r\n1. **Exposure:** The attacker is a provisioned non-superadmin admin with `GROUP_EDIT`; the entry points are the shipped admin API routes `admin.api.group.permissions` and `admin.api.group.members`, gated by login, `GROUP_EDIT`, and a CSRF token.\r\n2. **Control:** The attacker controls the `rightIds` list and the target group, submitting a right they hold only in a narrow language or category scope.\r\n3. **Path:** `updatePermissions` iterates the submitted rights through `hasPermission`, then calls `refuseAllGroupRights` and `grantGroupRight` for the group.\r\n4. **Guard:** `hasPermission` resolves to `checkUserGroupRight` and `checkUserRight`, whose SQL never reads the `*_right_language` or `*_right_category` tables, so it cannot constrain the scope of a right the attacker genuinely holds; the scope-aware `mayAssignLanguages` and `mayAssignCategories` checks are never invoked on this path.\r\n5. **Primitive:** `grantGroupRight` writes a `faqgroup_right` row with no restriction row, and the enforcement query `checkUserGroupRightForLanguage` treats a missing restriction row as global (its `NOT EXISTS` branch returns the row).\r\n6. **Result:** After a self-join via `updateMembers` (or the `updateGroup` auto-join path), `hasPermissionForLanguage` flips from false to true for the attacker in every language and category, granting unrestricted create, edit, and publish access enforced at `FaqController` and `AbstractController`.\r\n\r\n## Bypass Evidence\r\n\r\nThe relevant prior fixes are the existing advisory GHSA-pg62-f8g4-4wqh (the self-rights constraint added to `updatePermissions`) and its sibling GHSA-28cc-v39j-vr95 (the same constraint on `updateMembers`). Both fixes are present and executing on this path: the `hasPermission` loop at `GroupController.php:564-571` and `:476-493` correctly returns 403 when the attacker submits a right they hold in no scope. The gap is that this guard operates on the existence of a right, not its scope. The following disproof attempts were made and defeated:\r\n\r\n- Hypothesis: `hasPermission` is scope-aware. Refuted by reading the SQL of `checkUserGroupRight` (`MediumPermissionRepository.php:116-150`) and `checkUserRight` (`BasicPermissionRepository.php:130-159`); neither joins a restriction table.\r\n- Hypothesis: the grant copies the granter's restriction or a fresh group inherits scope. Refuted; `grantGroupRight` inserts only the grant row and `refuseAllGroupRights` deletes any prior restriction rows.\r\n- Hypothesis: enforcement fails closed on a missing restriction row. Refuted; `checkUserGroupRightForLanguage` fails open by design (`NOT EXISTS` disjunct means global).\r\n- Hypothesis: widening a group right globally is intended for a `GROUP_EDIT` delegate. Refuted by the project's own model; `saveLanguageRestrictions` and `saveCategoryRestrictions` enforce `mayAssignLanguages` and `mayAssignCategories`, which forbid a non-superadmin from widening a group right beyond their own scope. The grant path omits that check.\r\n- Hypothesis: no real capability is gained. Refuted; baseline enforcement denies the attacker in the target language and post-attack enforcement allows it, a verified false to true transition at a throwing capability gate.\r\n\r\n## Affected Versions\r\n\r\n- `Ecosystem: composer`\r\n- `Package: thorsten/phpmyfaq`\r\n- `Confirmed affected range: >= 4.2.0-alpha.2`\r\n- `Latest release checked: 4.1-nightly-2026-09-14 (git tag on thorsten/phpmyfaq main; CHANGELOG top entry is 4.2.0-beta, unreleased)`\r\n- `Fix status: not fixed`\r\n\r\nThe vulnerable behavior requires the per-right language and category restriction feature, whose tables are created by the `Migration420Alpha2` migration (`getVersion()` returns `4.2.0-alpha.2`) and by the fresh-install schema. The scope-blind delegation guard predates the feature but is inert without it, so releases before 4.2.0-alpha.2 (including the stable 4.1.x line) are not affected. No stable 4.2 release has been published as of the checked date, so real-world exposure is currently limited to pre-release, nightly, and development installs of the 4.2 line. The exact first affected published pre-release artifact could not be pinned from a nightly-only checkout.\r\n\r\n## Suggested Fix\r\n\r\nEnforce the scope invariant at the point the grant is written: a non-superadmin must not be able to grant a right in a broader language or category scope than they themselves hold. Route `updatePermissions`, `updateMembers`, and the `updateGroup` auto-join through the same scope-aware `mayAssignLanguages` and `mayAssignCategories` logic already applied by the restriction-setting endpoints, or enforce scope in the permission layer at grant time so that a delegated grant copies (and cannot exceed) the granter's restriction rows. As an interim mitigation, restrict `GROUP_EDIT` to fully trusted administrators until the scope check is added.\r\n\r\nReported by zx (GitHub: @manus-pi).","categories":["Unclassified"],"product":"thorsten/phpmyfaq","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-6vf7-mrj2-7c37","source_modified_at":"2026-10-03T11:29:54Z","fetched_at":"2026-10-27T04:51:10.072820Z","package":{"ecosystem":"composer","name":"thorsten/phpmyfaq"},"range":">= 4.2.0-alpha.2"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-6vf7-mrj2-7c37","source_modified_at":"2026-10-03T11:29:54Z","fetched_at":"2026-10-27T04:51:10.072820Z","package":{"ecosystem":"composer","name":"thorsten/phpmyfaq"},"version":"4.2.0-beta"}],"cvss":5.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-03T11:29:54Z","modified_at":"2026-10-03T11:29:54Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-6vf7-mrj2-7c37","fetched_at":"2026-10-27T04:51:10.072820Z","source_modified_at":"2026-10-03T11:29:54Z"}],"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-03T11:29:54Z","content_hash":"522fa224e57b4cfdcff47a60853958bb3decb1971aed9ff8abf03cab31fba3ee"}}
