VYPR
Medium severity4.3NVD Advisory· Published May 15, 2026· Updated May 18, 2026

CVE-2026-45007

CVE-2026-45007

Description

phpMyFAQ before 4.1.2 contains missing permission checks in ConfigurationTabController.php where 12 endpoints use userIsAuthenticated() instead of userHasPermission(CONFIGURATION_EDIT). Any authenticated user can enumerate system configuration metadata including permission model, cache backend, mail provider, and translation provider by querying /admin/api/configuration endpoints, violating least privilege access control.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

In phpMyFAQ before 4.1.2, 12 admin API endpoints check only authentication, not configuration edit permission, allowing any authenticated user to enumerate sensitive system metadata.

The vulnerability lies in phpMyFAQ's ConfigurationTabController.php, where 12 out of 15 admin API endpoints incorrectly use userIsAuthenticated() instead of userHasPermission(CONFIGURATION_EDIT) [1]. This means any authenticated user, regardless of permissions, can access these endpoints. The three endpoints that correctly enforce permissions are list, save, and uploadTheme [1].

Attackers can exploit this by simply being logged in – no admin privileges required. They can query /admin/api/configuration endpoints to enumerate system configuration metadata, including the permission model, cache backend, mail provider, and translation provider [1][2]. This violates the principle of least privilege.

The impact is information disclosure of internal system configuration, which could aid attackers in further attacks or reconnaissance. No authentication bypass occurs, but there is a privilege escalation allowing read-access to sensitive metadata that should be restricted to users with CONFIGURATION_EDIT permission.

The issue is fixed in phpMyFAQ version 4.1.2. Users should upgrade immediately to the latest version [2]. There is no workaround available; upgrading is the recommended mitigation.

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

4
657b52b10db4

fix: added missing termination of permission check

https://github.com/thorsten/phpmyfaqThorsten RinneApr 6, 2026Fixed in 4.1.2via llm-release-walk
2 files changed · +3 45
  • phpmyfaq/assets/templates/admin/error/forbidden.twig+0 23 removed
    @@ -1,23 +0,0 @@
    -{% extends '@admin/index.twig' %}
    -
    -{% block content %}
    -  <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 mb-3 border-bottom">
    -    <h1 class="h2">
    -      <i aria-hidden="true" class="bi bi-exclamation-triangle"></i>
    -      {{ 'msgError403' | translate }}
    -    </h1>
    -  </div>
    -
    -  <p class="alert alert-danger">
    -    {{ 'msgError403Description' | translate }}
    -  </p>
    -
    -  <p class="small">
    -   {{ 'msgError403Hint' | translate }}
    -  </p>
    -
    -  {% if debugMode == true and errorMessage is not empty %}
    -  <pre class="code-block">{{ errorMessage }}</pre>
    -  {% endif %}
    -
    -{% endblock %}
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php+3 22 modified
    @@ -24,15 +24,13 @@
     use phpMyFAQ\Controller\AbstractController;
     use phpMyFAQ\Controller\Exception\ForbiddenException;
     use phpMyFAQ\Enums\PermissionType;
    -use phpMyFAQ\Environment;
     use phpMyFAQ\Helper\LanguageHelper;
     use phpMyFAQ\Service\Gravatar;
     use phpMyFAQ\Session\Token;
     use phpMyFAQ\System;
     use phpMyFAQ\Translation;
     use phpMyFAQ\Twig\TwigWrapper;
     use Symfony\Component\HttpFoundation\Request;
    -use Symfony\Component\HttpFoundation\Response;
     
     abstract class AbstractAdministrationController extends AbstractController
     {
    @@ -366,29 +364,12 @@ private function getGravatarImage(): string
             return '';
         }
     
    -    protected function userHasPermission(PermissionType $permissionType): void
    -    {
    -        try {
    -            parent::userHasPermission($permissionType);
    -        } catch (ForbiddenException $exception) {
    -            $response = $this->getForbiddenPage($exception->getMessage());
    -            $response->send();
    -        } catch (Exception $exception) {
    -            $this->configuration->getLogger()->error($exception->getMessage());
    -        }
    -    }
    -
         /**
    -     * @throws Exception
    +     * @throws ForbiddenException
          */
    -    protected function getForbiddenPage(string $message = ''): Response
    +    protected function userHasPermission(PermissionType $permissionType): void
         {
    -        return $this->render(file: '@admin/error/forbidden.twig', context: [
    -            ...$this->getHeader(Request::createFromGlobals()),
    -            ...$this->getFooter(),
    -            'debugMode' => Environment::isDebugMode(),
    -            'errorMessage' => $message,
    -        ]);
    +        parent::userHasPermission($permissionType);
         }
     
         /**
    
96a7777828d5

fix: corrected and improved permission checks

https://github.com/thorsten/phpmyfaqThorsten RinneApr 10, 2026Fixed in 4.1.2via llm-release-walk
17 files changed · +109 16
  • phpmyfaq/admin/assets/src/api/tags.test.ts+11 8 modified
    @@ -12,7 +12,7 @@ describe('Tags API', () => {
             { id: '1', name: 'Tag1' },
             { id: '2', name: 'Tag2' },
           ];
    -      global.fetch = vi.fn(() =>
    +      globalThis.fetch = vi.fn(() =>
             Promise.resolve({
               ok: true,
               json: () => Promise.resolve(mockResponse),
    @@ -23,7 +23,7 @@ describe('Tags API', () => {
           const result = await fetchTags(searchString);
     
           expect(result).toEqual(mockResponse);
    -      expect(global.fetch).toHaveBeenCalledWith('./api/content/tags?search=Tag', {
    +      expect(globalThis.fetch).toHaveBeenCalledWith('./api/content/tags?search=Tag', {
             method: 'GET',
             cache: 'no-cache',
             headers: {
    @@ -36,7 +36,7 @@ describe('Tags API', () => {
     
         it('should throw an error if fetch fails', async () => {
           const mockError = new Error('Fetch failed');
    -      global.fetch = vi.fn(() => Promise.reject(mockError));
    +      globalThis.fetch = vi.fn(() => Promise.reject(mockError));
     
           const searchString = 'Tag';
     
    @@ -47,34 +47,37 @@ describe('Tags API', () => {
       describe('deleteTag', () => {
         it('should delete tag and return JSON response if successful', async () => {
           const mockResponse = { success: true, message: 'Tag deleted' };
    -      global.fetch = vi.fn(() =>
    +      globalThis.fetch = vi.fn(() =>
             Promise.resolve({
               ok: true,
               json: () => Promise.resolve(mockResponse),
             } as Response)
           );
     
           const tagId = '1';
    -      await deleteTag(tagId);
    +      const csrfToken = 'test-csrf';
    +      await deleteTag(tagId, csrfToken);
     
    -      expect(global.fetch).toHaveBeenCalledWith('./api/content/tags/1', {
    +      expect(globalThis.fetch).toHaveBeenCalledWith('./api/content/tags/1', {
             method: 'DELETE',
             cache: 'no-cache',
             headers: {
               'Content-Type': 'application/json',
             },
    +        body: JSON.stringify({ csrfToken: 'test-csrf' }),
             redirect: 'follow',
             referrerPolicy: 'no-referrer',
           });
         });
     
         it('should throw an error if fetch fails', async () => {
           const mockError = new Error('Fetch failed');
    -      global.fetch = vi.fn(() => Promise.reject(mockError));
    +      globalThis.fetch = vi.fn(() => Promise.reject(mockError));
     
           const tagId = '1';
    +      const csrfToken = 'test-csrf';
     
    -      await expect(deleteTag(tagId)).rejects.toThrow(mockError);
    +      await expect(deleteTag(tagId, csrfToken)).rejects.toThrow(mockError);
         });
       });
     });
    
  • phpmyfaq/admin/assets/src/api/tags.ts+2 1 modified
    @@ -32,13 +32,14 @@ export const fetchTags = async (searchString: string): Promise<TagResponse[]> =>
       return await response.json();
     };
     
    -export const deleteTag = async (tagId: string): Promise<{ success?: string; error?: string }> => {
    +export const deleteTag = async (tagId: string, csrfToken: string): Promise<{ success?: string; error?: string }> => {
       const response = await fetch(`./api/content/tags/${tagId}`, {
         method: 'DELETE',
         cache: 'no-cache',
         headers: {
           'Content-Type': 'application/json',
         },
    +    body: JSON.stringify({ csrfToken: csrfToken }),
         redirect: 'follow',
         referrerPolicy: 'no-referrer',
       });
    
  • phpmyfaq/admin/assets/src/content/tags.ts+2 1 modified
    @@ -62,8 +62,9 @@ export const handleTags = (): void => {
           element.addEventListener('click', async (event: Event) => {
             const target = event.target as HTMLElement;
             const tagId = target.getAttribute('data-pmf-id') as string;
    +        const csrfToken = (document.querySelector('input[name=pmf-csrf-token]') as HTMLInputElement).value;
     
    -        const response = await deleteTag(tagId);
    +        const response = await deleteTag(tagId, csrfToken);
             if (response.success) {
               pushNotification(response.success);
               const row = document.getElementById(`pmf-row-tag-id-${tagId}`) as HTMLElement;
    
  • phpmyfaq/assets/templates/admin/configuration/instances.edit.twig+1 0 modified
    @@ -9,6 +9,7 @@
         </h1>
    
       </div>
    
       <form action="./instance/update" method="post" accept-charset="utf-8">
    
    +    <input type="hidden" name="pmf-csrf-token" value="{{ csrfTokenUpdateInstance }}"/>
    
         <input type="hidden" name="id" value="{{ instance.id }}"/>
    
         <div class="row mb-2">
    
           <label for="url" class="col-lg-2 col-form-label">{{ ad_instance_url }}:</label>
    
    
  • phpmyfaq/assets/templates/admin/user/group.twig+3 0 modified
    @@ -46,6 +46,7 @@
               <i class="bi bi-info-circle" aria-hidden="true"></i> {{ 'ad_group_details' | translate }}
    
             </h5>
    
             <form action="./group/update" method="post">
    
    +          <input type="hidden" name="pmf-csrf-token" value="{{ csrfTokenUpdateGroup }}">
    
               <input id="update_group_id" type="hidden" name="group_id" value="0">
    
               <div class="card-body">
    
                 <div class="row mb-2">
    
    @@ -91,6 +92,7 @@
     
    
         <div class="col-lg-4" id="groupMemberships">
    
           <form id="group_membership" name="group_membership" method="post" action="./group/update/members">
    
    +        <input type="hidden" name="pmf-csrf-token" value="{{ csrfTokenUpdateGroupMembers }}">
    
             <input id="update_member_group_id" type="hidden" name="group_id" value="0">
    
             <div class="card shadow mb-4">
    
               <h5 class="card-header py-3">
    
    @@ -170,6 +172,7 @@
     
    
           <div id="groupRights" class="card shadow mb-4">
    
             <form id="rightsForm" action="./group/update/permissions" method="post">
    
    +          <input type="hidden" name="pmf-csrf-token" value="{{ csrfTokenUpdateGroupPermissions }}">
    
               <input id="rights_group_id" type="hidden" name="group_id" value="0">
    
               <h5 class="card-header py-3" id="user_rights_legend">
    
                 <i aria-hidden="true" class="bi bi-lock"></i> {{ 'ad_group_rights' | translate }}
    
    
  • phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php+1 1 modified
    @@ -161,7 +161,7 @@ protected function isSecured(): void
         /**
          * @throws UnauthorizedHttpException
          */
    -    protected function userIsAuthenticated(): void
    +    public function userIsAuthenticated(): void
         {
             if (!$this->currentUser->isLoggedIn()) {
                 throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/CategoryController.php+2 2 modified
    @@ -94,7 +94,7 @@ public function delete(Request $request): JsonResponse
         #[Route(path: 'admin/api/category/permissions', name: 'admin.api.category.permissions', methods: ['GET'])]
         public function permissions(Request $request): JsonResponse
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CATEGORY_EDIT);
     
             $categoryPermission = $this->container->get(id: 'phpmyfaq.category.permission');
     
    @@ -119,7 +119,7 @@ public function permissions(Request $request): JsonResponse
         #[Route(path: 'admin/api/category/translations', name: 'admin.api.category.translations', methods: ['GET'])]
         public function translations(Request $request): JsonResponse
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CATEGORY_EDIT);
     
             $category = new Category($this->configuration, [], false);
     
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/MarkdownController.php+3 0 modified
    @@ -25,6 +25,7 @@
     use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
     use League\CommonMark\MarkdownConverter;
     use phpMyFAQ\Controller\AbstractController;
    +use phpMyFAQ\Enums\PermissionType;
     use phpMyFAQ\Filter;
     use Symfony\Component\HttpFoundation\JsonResponse;
     use Symfony\Component\HttpFoundation\Request;
    @@ -39,6 +40,8 @@ final class MarkdownController extends AbstractController
         #[Route(path: 'admin/api/content/markdown')]
         public function renderMarkdown(Request $request): JsonResponse
         {
    +        $this->userHasPermission(PermissionType::FAQ_EDIT);
    +
             $data = json_decode($request->getContent());
     
             $answer = Filter::filterVar($data->text, FILTER_SANITIZE_SPECIAL_CHARS);
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/StatisticsController.php+1 1 modified
    @@ -40,7 +40,7 @@ final class StatisticsController extends AbstractController
         #[Route(path: './admin/api/statistics/admin-log', methods: ['DELETE'])]
         public function deleteAdminLog(Request $request): JsonResponse
         {
    -        $this->userHasPermission(PermissionType::STATISTICS_VIEWLOGS);
    +        $this->userHasPermission(PermissionType::STATISTICS_ADMINLOG);
     
             $data = json_decode($request->getContent(), false, 512, JSON_THROW_ON_ERROR);
     
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/TagController.php+6 0 modified
    @@ -116,6 +116,12 @@ public function delete(Request $request): JsonResponse
         {
             $this->userHasPermission(PermissionType::FAQ_EDIT);
     
    +        $data = json_decode($request->getContent());
    +
    +        if (!Token::getInstance($this->container->get(id: 'session'))->verifyToken('tags', $data->csrfToken ?? '')) {
    +            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
    +        }
    +
             $tagId = (int) Filter::filterVar($request->attributes->get('tagId'), FILTER_VALIDATE_INT);
     
             if ($this->container->get(id: 'phpmyfaq.tags')->delete($tagId)) {
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/AuthenticationController.php+1 1 modified
    @@ -30,7 +30,7 @@
     use Symfony\Component\HttpFoundation\Response;
     use Symfony\Component\Routing\Attribute\Route;
     
    -final class AuthenticationController extends AbstractAdministrationController
    +final class AuthenticationController extends AbstractAdministrationController implements SkipsAuthenticationCheck
     {
         #[Route(path: '/authenticate', name: 'admin.auth.authenticate', methods: ['POST'])]
         public function authenticate(Request $request): Response
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/GroupController.php+30 0 modified
    @@ -194,6 +194,11 @@ public function update(Request $request): Response
         {
             $this->userHasPermission(PermissionType::GROUP_EDIT);
     
    +        $csrfToken = Filter::filterVar($request->request->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
    +        if (!Token::getInstance($this->container->get(id: 'session'))->verifyToken('update-group', $csrfToken)) {
    +            throw new UnauthorizedHttpException('Invalid CSRF token');
    +        }
    +
             $groupId = (int) Filter::filterVar($request->request->get('group_id'), FILTER_VALIDATE_INT);
     
             $groupData = [];
    @@ -242,6 +247,14 @@ public function updateMembers(Request $request): Response
         {
             $this->userHasPermission(PermissionType::GROUP_EDIT);
     
    +        $csrfToken = Filter::filterVar($request->request->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
    +        if (!Token::getInstance($this->container->get(id: 'session'))->verifyToken(
    +            'update-group-members',
    +            $csrfToken,
    +        )) {
    +            throw new UnauthorizedHttpException('Invalid CSRF token');
    +        }
    +
             $groupId = (int) Filter::filterVar($request->request->get('group_id'), FILTER_VALIDATE_INT);
             $groupMembers = $request->request->all()['group_members'];
     
    @@ -280,6 +293,14 @@ public function updatePermissions(Request $request): Response
         {
             $this->userHasPermission(PermissionType::GROUP_EDIT);
     
    +        $csrfToken = Filter::filterVar($request->request->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
    +        if (!Token::getInstance($this->container->get(id: 'session'))->verifyToken(
    +            'update-group-permissions',
    +            $csrfToken,
    +        )) {
    +            throw new UnauthorizedHttpException('Invalid CSRF token');
    +        }
    +
             $groupId = (int) Filter::filterVar($request->request->get('group_id'), FILTER_VALIDATE_INT);
             $groupPermissions = $request->request->all()['group_rights'];
     
    @@ -317,6 +338,15 @@ private function getBaseTemplateVars(): array
             $user = $this->container->get(id: 'phpmyfaq.user');
             return [
                 'rightData' => $user->perm->getAllRightsData(),
    +            'csrfTokenUpdateGroup' => Token::getInstance($this->container->get(id: 'session'))->getTokenString(
    +                'update-group',
    +            ),
    +            'csrfTokenUpdateGroupMembers' => Token::getInstance($this->container->get(id: 'session'))->getTokenString(
    +                'update-group-members',
    +            ),
    +            'csrfTokenUpdateGroupPermissions' => Token::getInstance($this->container->get(
    +                id: 'session',
    +            ))->getTokenString('update-group-permissions'),
             ];
         }
     }
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/InstanceController.php+9 0 modified
    @@ -28,6 +28,7 @@
     use phpMyFAQ\Translation;
     use Symfony\Component\HttpFoundation\Request;
     use Symfony\Component\HttpFoundation\Response;
    +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
     use Symfony\Component\Routing\Attribute\Route;
     use Twig\Error\LoaderError;
     
    @@ -77,6 +78,9 @@ public function edit(Request $request): Response
                 'ad_instance_config' => Translation::get(key: 'ad_instance_config'),
                 'ad_entry_back' => Translation::get(key: 'ad_entry_back'),
                 'instance' => $instanceData,
    +            'csrfTokenUpdateInstance' => Token::getInstance($this->container->get(id: 'session'))->getTokenString(
    +                'update-instance',
    +            ),
             ]);
         }
     
    @@ -90,6 +94,11 @@ public function update(Request $request): Response
         {
             $this->userHasPermission(PermissionType::INSTANCE_EDIT);
     
    +        $csrfToken = Filter::filterVar($request->request->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
    +        if (!Token::getInstance($this->container->get(id: 'session'))->verifyToken('update-instance', $csrfToken)) {
    +            throw new UnauthorizedHttpException('Invalid CSRF token');
    +        }
    +
             $instanceId = (int) Filter::filterVar($request->attributes->get('id'), FILTER_VALIDATE_INT);
     
             $fileSystem = new Filesystem(PMF_ROOT_DIR);
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/PluginController.php+3 0 modified
    @@ -20,6 +20,7 @@
     namespace phpMyFAQ\Controller\Administration;
     
     use phpMyFAQ\Core\Exception;
    +use phpMyFAQ\Enums\PermissionType;
     use Symfony\Component\HttpFoundation\Request;
     use Symfony\Component\HttpFoundation\Response;
     use Symfony\Component\Routing\Attribute\Route;
    @@ -35,6 +36,8 @@ final class PluginController extends AbstractAdministrationController
         #[Route(path: '/plugins')]
         public function index(Request $request): Response
         {
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
    +
             $pluginManager = $this->container->get(id: 'phpmyfaq.plugin.plugin-manager');
             $pluginManager->loadPlugins();
     
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/SkipsAuthenticationCheck.php+28 0 added
    @@ -0,0 +1,28 @@
    +<?php
    +
    +/**
    + * Marker interface for admin controllers that intentionally bypass the
    + * automatic authentication enforcement performed by the
    + * ControllerContainerListener.
    + *
    + * Only the AuthenticationController itself (handling login/logout/token
    + * endpoints) should implement this interface. Every other controller in
    + * the Administration namespace must require an authenticated user.
    + *
    + * This Source Code Form is subject to the terms of the Mozilla Public License,
    + * v. 2.0. If a copy of the MPL was not distributed with this file, You can
    + * obtain one at https://mozilla.org/MPL/2.0/.
    + *
    + * @package   phpMyFAQ
    + * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
    + * @copyright 2026 phpMyFAQ Team
    + * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
    + * @link      https://www.phpmyfaq.de
    + * @since     2026-04-10
    + */
    +
    +declare(strict_types=1);
    +
    +namespace phpMyFAQ\Controller\Administration;
    +
    +interface SkipsAuthenticationCheck {}
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/TagController.php+2 1 modified
    @@ -20,6 +20,7 @@
     namespace phpMyFAQ\Controller\Administration;
     
     use phpMyFAQ\Core\Exception;
    +use phpMyFAQ\Enums\PermissionType;
     use phpMyFAQ\Session\Token;
     use phpMyFAQ\Translation;
     use Symfony\Component\HttpFoundation\Request;
    @@ -37,7 +38,7 @@ final class TagController extends AbstractAdministrationController
         #[Route(path: '/tags', name: 'admin.tags', methods: ['GET'])]
         public function index(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::FAQ_EDIT);
     
             $tagData = $this->container->get(id: 'phpmyfaq.tags')->getAllTags();
     
    
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/UserController.php+4 0 modified
    @@ -84,6 +84,10 @@ public function edit(Request $request): Response
         #[Route(path: '/user/list', name: 'admin.user.list', methods: ['GET'])]
         public function list(Request $request): Response
         {
    +        $this->userHasPermission(PermissionType::USER_ADD);
    +        $this->userHasPermission(PermissionType::USER_DELETE);
    +        $this->userHasPermission(PermissionType::USER_EDIT);
    +
             $user = $this->container->get(id: 'phpmyfaq.user');
             $allUsers = $user->getAllUsers(false);
             $numUsers = is_countable($allUsers) ? count($allUsers) : 0;
    
21ceafd51681

fix: added missing permission checks

https://github.com/thorsten/phpmyfaqThorsten RinneApr 6, 2026Fixed in 4.1.2via llm-release-walk
1 file changed · +9 9
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php+9 9 modified
    @@ -206,7 +206,7 @@ public function save(Request $request): JsonResponse
         )]
         public function translations(): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             $response = new Response();
     
    @@ -232,7 +232,7 @@ public function translations(): Response
         #[Route(path: 'admin/api/configuration/templates', name: 'admin.api.configuration.templates', methods: ['GET'])]
         public function templates(): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             $response = new Response();
             $faqSystem = $this->container->get(id: 'phpmyfaq.system');
    @@ -254,7 +254,7 @@ public function templates(): Response
         )]
         public function faqsSortingKey(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             return new Response(Helper::sortingKeyOptions($request->attributes->get(key: 'current')));
         }
    @@ -266,7 +266,7 @@ public function faqsSortingKey(Request $request): Response
         )]
         public function faqsSortingOrder(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             return new Response(Helper::sortingOrderOptions($request->attributes->get(key: 'current')));
         }
    @@ -278,15 +278,15 @@ public function faqsSortingOrder(Request $request): Response
         )]
         public function faqsSortingPopular(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             return new Response(Helper::sortingPopularFaqsOptions($request->attributes->get(key: 'current')));
         }
     
         #[Route(path: 'admin/api/configuration/perm-level', name: 'admin.api.configuration.perm-level', methods: ['GET'])]
         public function permLevel(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             return new Response(PermissionHelper::permOptions($request->attributes->get(key: 'current')));
         }
    @@ -298,7 +298,7 @@ public function permLevel(Request $request): Response
         )]
         public function releaseEnvironment(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             return new Response(Helper::renderReleaseTypeOptions($request->attributes->get(key: 'current')));
         }
    @@ -310,7 +310,7 @@ public function releaseEnvironment(Request $request): Response
         )]
         public function searchRelevance(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             return new Response(Helper::searchRelevanceOptions($request->attributes->get(key: 'current')));
         }
    @@ -322,7 +322,7 @@ public function searchRelevance(Request $request): Response
         )]
         public function seoMetaTags(Request $request): Response
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
     
             return new Response(Helper::renderMetaRobotsDropdown($request->attributes->get(key: 'current')));
         }
    
9ad8356016f0

fix: added missing permission check

https://github.com/thorsten/phpmyfaqThorsten RinneApr 6, 2026Fixed in 4.1.2via llm-release-walk
1 file changed · +1 1
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/TagController.php+1 1 modified
    @@ -114,7 +114,7 @@ public function search(Request $request): JsonResponse
         #[Route(path: 'admin/api/content/tag/:tagId')]
         public function delete(Request $request): JsonResponse
         {
    -        $this->userIsAuthenticated();
    +        $this->userHasPermission(PermissionType::FAQ_EDIT);
     
             $tagId = (int) Filter::filterVar($request->attributes->get('tagId'), FILTER_VALIDATE_INT);
     
    

Vulnerability mechanics

Root cause

"Twelve endpoints in ConfigurationTabController.php use `userIsAuthenticated()` instead of `userHasPermission(PermissionType::CONFIGURATION_EDIT)`, allowing any authenticated user to access configuration metadata without the required permission."

Attack vector

An attacker with any authenticated session (no special administrative role required) can send GET requests to any of the twelve `/admin/api/configuration/*` endpoints — such as `translations`, `templates`, `perm-level`, `release-environment`, `search-relevance`, or `seo-meta-tags`. The server only checks that the user is logged in via `userIsAuthenticated()` [CWE-862] rather than verifying the `CONFIGURATION_EDIT` permission. This exposes system configuration metadata including the permission model, cache backend, mail provider, and translation provider to low-privilege users.

Affected code

The vulnerability is in `phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php` [patch_id=1068634]. Twelve route handlers — `translations()`, `templates()`, `faqsSortingKey()`, `faqsSortingOrder()`, `faqsSortingPopular()`, `permLevel()`, `releaseEnvironment()`, `searchRelevance()`, `seoMetaTags()`, and others — all called `$this->userIsAuthenticated()` instead of `$this->userHasPermission(PermissionType::CONFIGURATION_EDIT)`. The base class method `userHasPermission()` in `AbstractAdministrationController.php` [patch_id=1068635] was also refactored to properly propagate `ForbiddenException` rather than catching it silently.

What the fix does

The patch [patch_id=1068634] replaces every call to `$this->userIsAuthenticated()` with `$this->userHasPermission(PermissionType::CONFIGURATION_EDIT)` in ConfigurationTabController.php, ensuring that only users with the `CONFIGURATION_EDIT` permission can access these twelve endpoints. Additionally, [patch_id=1068635] refactors the `userHasPermission` method in the base controller to re-throw the `ForbiddenException` instead of catching it and rendering a page, so that permission failures propagate correctly through the API layer. Related patches [patch_id=1068636] and [patch_id=1068637] apply the same pattern to other controllers (GroupController, InstanceController, TagController, etc.) to close similar missing-permission gaps.

Preconditions

  • authAttacker must have any valid authenticated session (any user account, no special role required)
  • networkAttacker must be able to send HTTP GET requests to the phpMyFAQ admin API endpoints
  • inputNo special payload required; simply accessing the endpoint URLs is sufficient

Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.