GitHub Advisoryadvisory · Packagist

GHSA-mf8r-wm2w-f8c5 phpMyFAQ public FAQ APIs expose inactive FAQ content

MEDIUM Published 2026-08-25 Modified 2026-08-25 Official page ↗
Ecosystem
Packagist
Packages
thorsten/phpmyfaq, phpmyfaq/phpmyfaq
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
Weaknesses
CWE-200, CWE-863
Reviewed
2026-08-25

Affected packages

• Packagist thorsten/phpmyfaq: >= 4.1.0, < 4.1.5 → fixed in 4.1.5 • Packagist phpmyfaq/phpmyfaq: >= 4.1.0, < 4.1.5 → fixed in 4.1.5

Description

Affected Product phpMyFAQ Affected Versions • Confirmed affected: 4.1.4, API v3.1. • Confirmed affected: current main / 4.2-style source, API v4.0, for `GET /api/v4.0/faqs/tags/{tagId}` when `api.onlyActiveFaqs=true`. Patched Versions 4.1.5. Description The 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: • `GET /api/v3.1/faq/{categoryId}/{faqId}` returns the inactive FAQ title and full answer. • `GET /api/v3.1/faqs/tags/{tagId}` returns the inactive FAQ title and answer preview. On 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. Inactive FAQs are commonly used as drafts or review-only content, so these unauthenticated public API paths may disclose non-public content. Root Cause `FaqController::getByCategoryId()` calls `Faq::getAllAvailableFaqsByCategoryId()`, which filters: ```sql fd.date_start = now AND fd.active = 'yes' ``` `FaqController::getByTagId()` instead resolves record IDs through `Tags::getFaqsByTagId()` and then calls `Faq::getFaqsByIds($recordIds)`. `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`. In phpMyFAQ 4.1.4, `FaqController::getById()` calls `Faq::getFaqByIdAndCategoryId()`, which also lacks an inactive/publication-window filter and returns the full answer. Proof of Concept The attached PoC uses phpMyFAQ's real Composer autoloader, real public `FaqController`, and a temporary copy of `tests/test.db`. Run from a local phpMyFAQ 4.1.4 source checkout after dependencies are installed and `tests/test.db` exists: ```bash php poc_phpmyfaq_414_inactive_faq_api_exposure.php /path/to/phpMyFAQ-4.1.4 ``` Expected output: ```text phpMyFAQ version: 4.1.4 Inserted FAQ: id=991414, active=no, anonymous-readable, category=991414, tag=991414 GET /api/v3.1/faqs/991414 status: 200 Category response contains inactive title: no GET /api/v3.1/faq/991414/991414 status: 200 Direct-by-id response contains inactive full title+answer: yes GET /api/v3.1/faqs/tags/991414 status: 200 Tag response contains inactive title+preview: yes VERDICT: reproduced inactive FAQ exposure through public API controller paths. ``` Suggested Fix Apply one consistent public visibility check across all public FAQ API routes: • `fd.active = 'yes'` • `fd.date_start = now` Suggested implementation options: • Add `Faq::getActiveFaqsByIds(array $faqIds)` and use it in public tag API routes. • Or add an `$onlyActive` / `$publicOnly` argument to `Faq::getFaqsByIds()` and default public controllers to enabled filtering. • Update `Faq::getFaqByIdAndCategoryId()` or the public controller wrapper so inactive records return 404 for unauthenticated public API requests. • Add regression tests with an inactive, anonymous-readable FAQ that has both category and tag relations. Reporter Credit Please credit: Yaohui Wang CVE Request Because 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. Full PoC Source ~~~php query($sql); if ($result === false) { throw new RuntimeException('SQL failed: ' . $db->error() . "\nSQL: " . $sql); } } $tempDb = tempnam(sys_get_temp_dir(), 'pmf-414-api-poc-'); if ($tempDb === false || !copy($repoRoot . '/tests/test.db', $tempDb)) { fwrite(STDERR, "Cannot create temporary SQLite database.\n"); exit(2); } try { Strings::init(); Translation::create() ->setTranslationsDir(PMF_ROOT_DIR . '/translations') ->setDefaultLanguage('en') ->setCurrentLanguage('en') ->setMultiByteLanguage(); Database::setTablePrefix(''); $db = Database::factory('pdo_sqlite'); if (!$db instanceof DatabaseDriver) { throw new RuntimeException('Could not create PDO SQLite database driver.'); } $db->connect($tempDb, '', ''); $configuration = new Configuration($db); $configuration->getAll(); $configuration->set('api.enableAccess', 'true'); $configuration->set('main.currentVersion', System::getVersion()); $configuration->set('main.language', 'en'); $configuration->set('main.referenceURL', 'https://localhost/'); $configuration->set('security.enableLoginOnly', 'false'); $configuration->set('security.permLevel', 'basic'); $configuration->set('records.numberOfRecordsPerPage', '25'); $configuration->getAll(); $session = new Session(new MockArraySessionStorage()); $language = new Language($configuration, $session); $language->setLanguageFromConfiguration('en'); $configuration->setLanguage($language); $faqId = 991414; $tagId = 991414; $categoryId = 991414; $question = 'Inactive tagged API probe 4.1.4'; $answer = 'This inactive FAQ preview is returned by the public tag API in phpMyFAQ 4.1.4.'; pocQuery($db, sprintf('DELETE FROM faqdata_tags WHERE record_id = %d OR tagging_id = %d', $faqId, $tagId)); pocQuery($db, sprintf('DELETE FROM faqtags WHERE tagging_id = %d', $tagId)); pocQuery($db, sprintf('DELETE FROM faqdata_user WHERE record_id = %d', $faqId)); pocQuery($db, sprintf('DELETE FROM faqdata_group WHERE record_id = %d', $faqId)); pocQuery($db, sprintf('DELETE FROM f…

Related

Weakness · CWE (2)

  • CWE-200 Exposure of Sensitive Information to an Unauthorized Actor weakness
  • CWE-863 Incorrect Authorization weakness

References