phpmyfaq/phpmyfaq: Missing authorization on the admin dashboard page discloses unpublished FAQs and recent-account identities to any authenticated user
What fixes it
- 4.1.9phpmyfaq/phpmyfaq, composerGitHub
composer require "phpmyfaq/phpmyfaq:^4.1.9" - 4.2.0-betaphpmyfaq/phpmyfaq, composerGitHub
composer require "phpmyfaq/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
**Missing Authorization (CWE-862): GET /admin/index.php/ is the only route in the entire Administration namespace that stops at userIsAuthenticated() instead of a userHasPermission() check.** The page renders Administration\Faq::getInactiveFaqsData() — a bare SELECT id, lang, thema FROM faqdata WHERE active = 'no' with no user, group or category restriction — together with LatestUsers::getList(5) and the site-wide counters. Any authenticated account holding zero admin rights, logging in through the ordinary front-end form and never touching the admin UI, therefore reads the question text and admin edit URL of every unpublished FAQ, the user IDs, display names and join timestamps of the five newest accounts, the visit/online/FAQ/comment/question counts, and the last-backup date.
Every one of those data is permission-gated on its sibling routes — including the two dashboard API actions you fixed in GHSA-gpwm-82cg-4wfw. The page in front of that same dashboard was left open.
Details
| ref | file:line | |---|---| | 4.1.8 | phpmyfaq/src/phpMyFAQ/Controller/Administration/DashboardController.php:46 ($this->userIsAuthenticated();), data at :79 and :88 | | origin/4.1 HEAD 0245df72b | same file, :46 | | main HEAD 0c4f44ae | same file, :62, data at :90 and :99 |
// DashboardController.php:79, :88 'adminDashboardInactiveFaqs' => $faq->getInactiveFaqsData(), 'adminDashboardLatestUsers' => $latestUsers->getList(limit: 5),
Administration\Faq::getInactiveFaqsData() (phpmyfaq/src/phpMyFAQ/Administration/Faq.php:159) applies no restriction of any kind and returns each record's question plus an …/admin/faq/edit/{id}/{lang} URL. LatestUsers::getList() returns user_id, login, display_name and member_since. On main these two widgets are labelled, in your own templates, *"Unpublished FAQs"* and *"Recent registered users"*.
The permission checks that belong here already exist for exactly these data elsewhere: dashboard/visits and dashboard/topten require STATISTICS_VIEWLOGS, dashboard/versions requires CONFIGURATION_EDIT, /admin/faqs requires the FAQ rights, /admin/user requires the user rights. The page that aggregates all of them requires none.
Proof of concept
Lab A — released 4.1.8, fresh install through the real installer.
Setup, as admin: POST /admin/api/index.php/faq/create with active: "no", question DRAFT-MARKER-QX7L unreleased advisory draft. Row faqfaqdata id=1, active='no'.
Read-first: the object read below was written by admin (user 1), never by the actor rzero (user 4), and the recent-users list contains accounts the actor did not create.
Step 1 — anonymous baseline, the record is invisible on every public path:
GET /index.php?action=faq&cat=1&id=1&artlang=en -> 404 marker absent GET /api/v3.1/faq/1/1 -> 404 body: [] GET /api/v3.1/faqs/1 -> 200 body: [] marker absent GET /admin/index.php/ -> 302 -> ./login
Step 2 — the actor logs in through the ordinary front-end login form and never touches the admin UI:
POST /index.php faqusername=rzero&faqpassword=…&redirect-action=login -> 200 GET /index.php?action=ucp -> 200
Step 3 — the same front-end session reads the admin dashboard:
GET /admin/index.php/ -> 200
Verbatim fragment of the response body:
<i aria-hidden="true" class="bi bi-ban"></i> FAQs inactive </h5> <div class="card-body"> <ul class="list-unstyled"> <li class="mb-2"> <i class="bi bi-question-circle"></i> <a href="http://localhost/admin/faq/edit/1/en">DRAFT-MARKER-QX7L unreleased advisory draft</a> </li> </ul> ... <i class="bi bi-person-circle"></i> <a href="./user/edit/2">rdelnews test</a> <div class="small text-muted">Member since: 2026-09-17 05:32</div> <i class="bi bi-person-circle"></i> <a href="./user/edit/3">reditnews test</a> <div class="small text-muted">Member since: 2026-09-17 05:32</div>
The oracle is the content itself — the question text of a record that 404s on every public path, its admin edit URL, and the identities and join times of accounts the actor cannot enumerate. Not a status code, and not the absence of an exception.
Controls, in both directions and for the named reason. Debug mode was on so ForbiddenException::getMessage() renders; the result is identical with debug off.
The actor genuinely holds nothing, and each refusal names the right it wanted:
GET /admin/index.php/faqs -> 403 User has no "FAQ_ADD" permission. (AbstractController.php:236)
GET /admin/index.php/news -> 403 User has no "NEWS_ADD" permission.
GET /admin/index.php/configuration -> 403 User has no "CONFIGURATION_EDIT" permission.
GET /admin/api/index.php/dashboard/topten -> 403 {"error":"Forbidden"} (STATISTICS_VIEWLOGS)
GET /admin/api/index.php/dashboard/versions -> 403 {"error":"Forbidden"} (CONFIGURATION_EDIT)
GET /admin/api/index.php/user/users -> 403 {"error":"Forbidden"}The actor is not a super admin — {"id":4,"userName":"rzero","isSuperAdmin":false} and /admin/api/index.php/user/permissions/4 → []. BasicPermission::hasPermission() short-circuits for super admins, so this is load-bearing rather than decorative.
No control operation granted the right under test: the rights were set once before the run by admin via POST /admin/api/user/update-rights with userRights: [], and re-read afterwards — still [].
Positive direction: the same GET /admin/index.php/ as admin returns 200 with the same widgets, and every guarded sibling that 403s for rzero returns 200 for admin. Unauthenticated: 302 → ./login, marker absent — so the boundary crossed is exactly *authenticated but unprivileged*, not *anyone*.
**Lab B — main HEAD 0c4f44ae, fresh install, user mzero with no rights.** Same procedure:
GET /admin/index.php/ -> 200, contains "DRAFT-MARKER-M42 unreleased draft on main"
and "mzero test / Member since: 2026-09-17 05:39"
GET /admin/index.php/faqs -> 403 User has none of the required permissions: FAQ_ADD, FAQ_EDIT, FAQ_DELETE, FAQ_PUBLISH.
GET /admin/api/index.php/dashboard/topten -> 403 "User has no \"STATISTICS_VIEWLOGS\" permission."
GET /admin/api/index.php/dashboard/searches -> 403 "User has no \"STATISTICS_VIEWLOGS\" permission."
[anon] GET /api/v3.1/faq/1/1 -> 404
[anon] GET /admin/index.php/ -> 302dashboard/searches is the endpoint GHSA-gpwm-82cg-4wfw closed. On the development line this is the un-closed half of that advisory; on the stable line it is untouched code.
Impact
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N — 4.3 Medium. CWE-862, with the exposure itself CWE-200 / CWE-1220.
PR:L and not PR:N: 4.1.8 self-registration creates the account with setStatus('blocked') (Helper/RegistrationHelper.php:82), so a freshly self-registered user cannot do this. The realistic actor is any ordinary activated member — a commenter, a bookmark user, a reader of a restricted category, or an LDAP/SSO-provisioned account.
C:L and not C:H: getInactiveFaqsData() selects only id, lang, thema, so this is questions, edit URLs and identities — metadata and titles, not draft bodies.
Suggested remediation
Gate each widget on the right its data already requires elsewhere — adminDashboardInactiveFaqs behind FAQ_EDIT/FAQ_APPROVE, adminDashboardLatestUsers behind USER_EDIT, the counters behind STATISTICS_VIEWLOGS — leaving an empty dashboard for a user who holds none, rather than a single check on the page.
A route-level regression test asserting that every Administration route other than the authentication endpoints invokes at least one permission check would have caught this and GHSA-gpwm-82cg-4wfw together.
What this is not
Not a pre-auth issue. It needs a valid, activated session; anonymous requests redirect to ./login and the unpublished record 404s on every public path. *"Any self-registered user"* would be an overclaim and is not made.
Not draft-body disclosure. The question text and the edit URL come back; the answer body does not.
Not a duplicate of GHSA-gpwm-82cg-4wfw, whose own text states its endpoints *"do not exist on the 4.1 stable branch"* — and both of them return 403 on main in my lab. Not GHSA-jrc5, whose four endpoints are all CONFIGURATION_EDIT-guarded at 4.1.8 (403 verified). Not GHSA-hpgw, which was a non-terminating check; here there is no check at all.
4.0.x appears affected and was not exercised. phpmyfaq/admin/dashboard.php:66 in 4.0.19 renders getInactiveFaqsData() behind the same login-only admin entry, but the recent-users widget did not exist before f7cac631e (2025-11-01, first released in 4.1.0). I claim the 4.1 line and main, where I ran it.
Text from GitHub Advisory Database
Affected versions 2
- phpmyfaq/phpmyfaq, composer>= 4.1.0, <= 4.1.8GitHub
- phpmyfaq/phpmyfaq, composer4.2.0-alphaGitHub