thorsten/phpmyfaq: phpMyFAQ updateUserRights drops per-right language restrictions on re-grant, letting a USER_EDIT admin widen a scoped right
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 USER_EDIT right can escalate the language scope of a content right they legitimately hold in a narrow scope, gaining that right across all languages, for themselves in a single request or for any non-superadmin user they can edit. The admin API endpoint UserController::updateUserRights gates a non-superadmin only with a scope-blind "rights you hold" check, then revokes and re-grants the target's rights in a way that destroys the existing per-right language restrictions. Because enforcement treats a missing restriction row as global, the re-granted right becomes unrestricted.
Root Cause
Per-right language restrictions are stored in faquser_right_language and enforced by scope-aware queries, but the re-grant path in updateUserRights neither checks scope nor preserves the restriction rows. For a non-superadmin, the endpoint iterates the submitted rights through hasPermission($actingUserId, $rightId), whose SQL joins faqright, faquser_right, and faquser alone and never reads faquser_right_language, so a right held in any scope passes. The endpoint then calls refuseAllUserRights, which revokes the rights and calls deleteAllForUser to drop all faquser_right_language rows for the target, and re-grants each right via grantUserRight, which inserts only a faquser_right row and no restriction row. The developers documented the ordering hazard inside refuseAllUserRights (revoking before deleting restrictions so a revoke failure leaves the right scoped), but that ordering protects only the revoke-failure case; on success the right is re-granted unrestricted by construction. The endpoint also omits the scope-aware mayAssignLanguages check that the restriction-setting endpoints apply.
Affected functions: - phpMyFAQ\Controller\Administration\Api\UserController::updateUserRights (scope-blind guard then restriction-dropping re-grant) - phpMyFAQ\Permission\BasicPermission::refuseAllUserRights (drops all faquser_right_language rows) - phpMyFAQ\Permission\BasicPermissionRepository::grantUserRight (re-grants with no restriction row) Source (repository-relative): - phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php (updateUserRights lines 615-688; guard 651-658; target check 665-667; re-grant 669-675) - phpmyfaq/src/phpMyFAQ/Permission/BasicPermission.php (refuseAllUserRights lines 279-292) - phpmyfaq/src/phpMyFAQ/Permission/BasicPermissionRepository.php (grantUserRight lines 38-48; checkUserRight lines 130-159) - phpmyfaq/src/phpMyFAQ/Permission/LanguagePermissionRepository.php (checkUserRightForLanguage lines 153-188; deleteAllForUser lines 134-147)
Impact
A USER_EDIT delegate escalates any language-scoped content right to all languages, either for themselves in one request (the endpoint has no cannot-edit-self guard) or for any non-superadmin user they can edit. Where language scoping is the intended editorial trust boundary, it is fully defeated: the attacker gains unrestricted create, edit, and publish access over FAQ content in previously denied languages. The escalation is bounded to rights already held in some scope and does not reach superadmin, because the guard still requires holding the right somewhere and the target check blocks editing superadmin or protected accounts. Confidentiality impact is limited to reading content (including drafts or restricted-language items) in the newly reachable languages.
Proof of Concept
Preconditions: attacker A is a non-superadmin admin holding USER_EDIT and holding a content right R (for example add_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
PUT /admin/api/user/update-rightswith a valid CSRF token,userId=A(self-target), anduserRightsset to A's currently held rights (including R and USER_EDIT). The scope-blind guard passes because A holds each submitted right in some scope; the target check passes because A is not a superadmin or protected account. - The endpoint runs
refuseAllUserRights(A), dropping A'sfaquser_right_languagerows, then re-grants each right viagrantUserRight(A, R)with no restriction row. - Observe that A can now add, edit, and publish FAQ content in language
deand all other languages.
Before: hasPermissionForLanguage(A, add_faq, 'de') == false After : hasPermissionForLanguage(A, add_faq, 'de') == true
No existing test asserts scope preservation across updateUserRights.
Attack Chain
- Exposure: The attacker is a provisioned non-superadmin admin with
USER_EDIT; the entry point is the shipped admin API routeadmin.api.user.update-rights, gated by login,USER_EDIT, and a CSRF token. - Control: The attacker controls
userId(which may be their own id) and theuserRightslist, submitting rights they hold only in a narrow language scope. - Path:
updateUserRightsiterates the submitted rights throughhasPermission, passes the target-superadmin check for a self or normal target, then callsrefuseAllUserRightsandgrantUserRight. - Guard:
hasPermissionresolves tocheckUserRight, whose SQL never readsfaquser_right_language, so it cannot constrain scope; there is no cannot-edit-self guard and nomayAssignLanguagescheck on this path. - Primitive:
refuseAllUserRightsdeletes allfaquser_right_languagerows viadeleteAllForUser, andgrantUserRightre-inserts the right with no restriction row; the enforcement querycheckUserRightForLanguagetreats the absence of a restriction row as global. - Result:
hasPermissionForLanguageflips from false to true for the target in every language, granting unrestricted create, edit, and publish access enforced atFaqControllerandAbstractController.
Bypass Evidence
The relevant prior fixes are the existing advisory GHSA-pg62-f8g4-4wqh and its sibling GHSA-28cc-v39j-vr95, which added the scope-blind "rights you hold" self-check to the group endpoints. That check is present and correct on the user endpoint as well, and it correctly rejects a right the attacker holds in no scope; it cannot, by construction, constrain the scope of a right genuinely held. This finding is a distinct code path (the user endpoint) with a distinct sink (restriction-dropping re-grant rather than group inheritance) and a distinct remediation site, so it is not blocked by either published fix. The following disproof attempts were made and defeated:
- Hypothesis:
hasPermissionis scope-aware. Refuted by reading the SQL ofcheckUserRight(BasicPermissionRepository.php:130-159); language tables are never joined. - Hypothesis: a missing restriction row denies rather than allows. Refuted;
checkUserRightForLanguage(LanguagePermissionRepository.php:153-188) returns global access on itsNOT EXISTSdisjunct. - Hypothesis: the re-grant preserves the restriction. Refuted;
refuseAllUserRightscallsdeleteAllForUserandgrantUserRightwrites no restriction row; the documented ordering comment protects only the revoke-failure case. - Hypothesis: a self-edit guard blocks self-targeting. Refuted;
updateUserRightshas no cannot-edit-self guard, and the target-superadmin or protected check passes for a normal or self target. - Hypothesis: clearing restrictions on a rights reset is intended behavior. Refuted; the project's
mayAssignLanguageslogic documents that clearing a restriction is a privilege grant to be blocked for non-superadmins, and this endpoint performs exactly that clear without the check.
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 user-level language restriction feature, whose faquser_right_language table is created by the Migration420Alpha2 migration (getVersion() returns 4.2.0-alpha.2) and by the fresh-install schema. Releases before 4.2.0-alpha.2 (including the stable 4.1.x line) lack the feature and 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 on the re-grant path: a non-superadmin must not be able to widen the language scope of a right during a rights update, and the update must not silently drop restriction rows for a right that survives the re-grant. Apply the scope-aware mayAssignLanguages check (as the restriction-setting endpoints already do) before writing rights, preserve or re-apply the target's existing faquser_right_language rows for any right that remains granted, and add a cannot-edit-self or self-scope constraint. As an interim mitigation, restrict USER_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