thorsten/phpmyfaq: phpMyFAQ group-permission API grants rights without preserving language/category scope; a GROUP_EDIT admin widens scope
What fixes it
- 4.2.0-betathorsten/phpmyfaq, composerGitHub
composer require "thorsten/phpmyfaq:^4.2.0-beta"
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
A 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.
Root Cause
The 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.
Affected functions: - phpMyFAQ\Controller\Administration\Api\GroupController::updatePermissions (scope-blind guard then unrestricted grant) - phpMyFAQ\Controller\Administration\Api\GroupController::updateMembers (scope-blind guard permitting self-join) - phpMyFAQ\Permission\MediumPermissionRepository::grantGroupRight (writes a grant row with no restriction row) Source (repository-relative): - phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php (updatePermissions lines 564-571, 578-589; updateMembers lines 476-493; updateGroup auto-join lines 393-408) - phpmyfaq/src/phpMyFAQ/Permission/MediumPermission.php (hasPermission lines 83-100) - phpmyfaq/src/phpMyFAQ/Permission/MediumPermissionRepository.php (grantGroupRight lines 158-172; checkUserGroupRight lines 116-150) - phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php (checkUserGroupRightForLanguage lines 312-355)
Impact
An 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.
Proof of Concept
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.
- As A, call
POST /admin/api/group/permissionswith 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), submittingrightIds=[R]. The scope-blind guard passes because A holds R in some scope; the controller runsrefuseAllGroupRights(G)thengrantGroupRight(G,R), writing an unrestricted grant. - As A, call
POST /admin/api/group/membersfor 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. - Observe that A can now add, edit, and publish FAQ content in language
deand all other languages and categories.
Before: hasPermissionForLanguage(A, edit_faq, 'de') == false After : hasPermissionForLanguage(A, edit_faq, 'de') == true
The repository test GroupControllerTest::testUpdatePermissionsRejectsRightNotHeldByNonSuperAdmin only exercises the right-held-nowhere dimension; no existing test asserts scope preservation across updatePermissions or updateMembers.
Attack Chain
- Exposure: The attacker is a provisioned non-superadmin admin with
GROUP_EDIT; the entry points are the shipped admin API routesadmin.api.group.permissionsandadmin.api.group.members, gated by login,GROUP_EDIT, and a CSRF token. - Control: The attacker controls the
rightIdslist and the target group, submitting a right they hold only in a narrow language or category scope. - Path:
updatePermissionsiterates the submitted rights throughhasPermission, then callsrefuseAllGroupRightsandgrantGroupRightfor the group. - Guard:
hasPermissionresolves tocheckUserGroupRightandcheckUserRight, whose SQL never reads the*_right_languageor*_right_categorytables, so it cannot constrain the scope of a right the attacker genuinely holds; the scope-awaremayAssignLanguagesandmayAssignCategorieschecks are never invoked on this path. - Primitive:
grantGroupRightwrites afaqgroup_rightrow with no restriction row, and the enforcement querycheckUserGroupRightForLanguagetreats a missing restriction row as global (itsNOT EXISTSbranch returns the row). - Result: After a self-join via
updateMembers(or theupdateGroupauto-join path),hasPermissionForLanguageflips from false to true for the attacker in every language and category, granting unrestricted create, edit, and publish access enforced atFaqControllerandAbstractController.
Bypass Evidence
The 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:
- Hypothesis:
hasPermissionis scope-aware. Refuted by reading the SQL ofcheckUserGroupRight(MediumPermissionRepository.php:116-150) andcheckUserRight(BasicPermissionRepository.php:130-159); neither joins a restriction table. - Hypothesis: the grant copies the granter's restriction or a fresh group inherits scope. Refuted;
grantGroupRightinserts only the grant row andrefuseAllGroupRightsdeletes any prior restriction rows. - Hypothesis: enforcement fails closed on a missing restriction row. Refuted;
checkUserGroupRightForLanguagefails open by design (NOT EXISTSdisjunct means global). - Hypothesis: widening a group right globally is intended for a
GROUP_EDITdelegate. Refuted by the project's own model;saveLanguageRestrictionsandsaveCategoryRestrictionsenforcemayAssignLanguagesandmayAssignCategories, which forbid a non-superadmin from widening a group right beyond their own scope. The grant path omits that check. - 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.
Affected Versions
Ecosystem: composerPackage: thorsten/phpmyfaqConfirmed affected range: >= 4.2.0-alpha.2Latest release checked: 4.1-nightly-2026-09-14 (git tag on thorsten/phpmyfaq main; CHANGELOG top entry is 4.2.0-beta, unreleased)Fix status: not fixed
The 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.
Suggested Fix
Enforce 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.
Reported by zx (GitHub: @manus-pi).
Text from GitHub Advisory Database
Affected versions 1
- thorsten/phpmyfaq, composer>= 4.2.0-alpha.2GitHub