{"schema":"threatline.record/2","record":{"id":"GHSA-4378-pcwq-fc29","aliases":["GHSA-4378-pcwq-fc29"],"summary":"Missing authorization on the admin dashboard page discloses unpublished FAQs and recent-account identities to any authenticated user","details":"### Summary\r\n\r\n**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.\r\n\r\nEvery 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.\r\n\r\n### Details\r\n\r\n| ref | file:line |\r\n|---|---|\r\n| 4.1.8 | `phpmyfaq/src/phpMyFAQ/Controller/Administration/DashboardController.php:46` (`$this->userIsAuthenticated();`), data at `:79` and `:88` |\r\n| `origin/4.1` HEAD `0245df72b` | same file, `:46` |\r\n| `main` HEAD `0c4f44ae` | same file, `:62`, data at `:90` and `:99` |\r\n\r\n```php\r\n// DashboardController.php:79, :88\r\n'adminDashboardInactiveFaqs' => $faq->getInactiveFaqsData(),\r\n'adminDashboardLatestUsers'  => $latestUsers->getList(limit: 5),\r\n```\r\n\r\n`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\"*.\r\n\r\nThe 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.\r\n\r\n### Proof of concept\r\n\r\n**Lab A — released 4.1.8, fresh install through the real installer.**\r\n\r\nSetup, 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'`.\r\n\r\nRead-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.\r\n\r\nStep 1 — anonymous baseline, the record is invisible on every public path:\r\n\r\n```\r\nGET /index.php?action=faq&cat=1&id=1&artlang=en   -> 404   marker absent\r\nGET /api/v3.1/faq/1/1                             -> 404   body: []\r\nGET /api/v3.1/faqs/1                              -> 200   body: []   marker absent\r\nGET /admin/index.php/                             -> 302 -> ./login\r\n```\r\n\r\nStep 2 — the actor logs in through the ordinary front-end login form and never touches the admin UI:\r\n\r\n```\r\nPOST /index.php   faqusername=rzero&faqpassword=…&redirect-action=login   -> 200\r\nGET  /index.php?action=ucp                                                 -> 200\r\n```\r\n\r\nStep 3 — the same front-end session reads the admin dashboard:\r\n\r\n```\r\nGET /admin/index.php/   -> 200\r\n```\r\n\r\nVerbatim fragment of the response body:\r\n\r\n```html\r\n<i aria-hidden=\"true\" class=\"bi bi-ban\"></i> FAQs inactive </h5> <div class=\"card-body\">\r\n<ul class=\"list-unstyled\"> <li class=\"mb-2\"> <i class=\"bi bi-question-circle\"></i>\r\n<a href=\"http://localhost/admin/faq/edit/1/en\">DRAFT-MARKER-QX7L unreleased advisory draft</a> </li> </ul>\r\n...\r\n<i class=\"bi bi-person-circle\"></i> <a href=\"./user/edit/2\">rdelnews test</a>\r\n<div class=\"small text-muted\">Member since: 2026-09-17 05:32</div>\r\n<i class=\"bi bi-person-circle\"></i> <a href=\"./user/edit/3\">reditnews test</a>\r\n<div class=\"small text-muted\">Member since: 2026-09-17 05:32</div>\r\n```\r\n\r\nThe 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.\r\n\r\n**Controls, in both directions and for the named reason.** Debug mode was on so `ForbiddenException::getMessage()` renders; the result is identical with debug off.\r\n\r\nThe actor genuinely holds nothing, and each refusal names the right it wanted:\r\n\r\n```\r\nGET /admin/index.php/faqs                   -> 403  User has no \"FAQ_ADD\" permission.          (AbstractController.php:236)\r\nGET /admin/index.php/news                   -> 403  User has no \"NEWS_ADD\" permission.\r\nGET /admin/index.php/configuration          -> 403  User has no \"CONFIGURATION_EDIT\" permission.\r\nGET /admin/api/index.php/dashboard/topten   -> 403  {\"error\":\"Forbidden\"}   (STATISTICS_VIEWLOGS)\r\nGET /admin/api/index.php/dashboard/versions -> 403  {\"error\":\"Forbidden\"}   (CONFIGURATION_EDIT)\r\nGET /admin/api/index.php/user/users         -> 403  {\"error\":\"Forbidden\"}\r\n```\r\n\r\nThe 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.\r\n\r\n**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 `[]`.\r\n\r\nPositive 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*.\r\n\r\n**Lab B — `main` HEAD `0c4f44ae`, fresh install, user `mzero` with no rights.** Same procedure:\r\n\r\n```\r\nGET /admin/index.php/                       -> 200, contains \"DRAFT-MARKER-M42 unreleased draft on main\"\r\n                                                     and \"mzero test / Member since: 2026-09-17 05:39\"\r\nGET /admin/index.php/faqs                   -> 403  User has none of the required permissions: FAQ_ADD, FAQ_EDIT, FAQ_DELETE, FAQ_PUBLISH.\r\nGET /admin/api/index.php/dashboard/topten   -> 403  \"User has no \\\"STATISTICS_VIEWLOGS\\\" permission.\"\r\nGET /admin/api/index.php/dashboard/searches -> 403  \"User has no \\\"STATISTICS_VIEWLOGS\\\" permission.\"\r\n[anon] GET /api/v3.1/faq/1/1                -> 404\r\n[anon] GET /admin/index.php/                -> 302\r\n```\r\n\r\n`dashboard/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.\r\n\r\n### Impact\r\n\r\n`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.\r\n\r\n`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.\r\n\r\n`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.\r\n\r\n### Suggested remediation\r\n\r\nGate 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.\r\n\r\nA 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.\r\n\r\n### What this is not\r\n\r\nNot 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.\r\n\r\nNot draft-body disclosure. The question text and the edit URL come back; the answer body does not.\r\n\r\nNot 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.\r\n\r\n**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.\r\n","categories":["Unclassified"],"product":"phpmyfaq/phpmyfaq","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-4378-pcwq-fc29","source_modified_at":"2026-10-03T11:29:17Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"range":">= 4.1.0, <= 4.1.8"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-4378-pcwq-fc29","source_modified_at":"2026-10-03T11:29:17Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"range":"4.2.0-alpha"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-4378-pcwq-fc29","source_modified_at":"2026-10-03T11:29:17Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"version":"4.1.9"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-4378-pcwq-fc29","source_modified_at":"2026-10-03T11:29:17Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"version":"4.2.0-beta"}],"cvss":4.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-03T11:29:17Z","modified_at":"2026-10-03T11:29:17Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-4378-pcwq-fc29","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-10-03T11:29:17Z"}],"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:17Z","content_hash":"7e2a3544588fe8e03284b6c4878d09844bbc5ab1751afc64480c19e621ba3e88"}}
