Contao has improper privilege management for page and article fields
Description
Contao is an Open Source CMS. In versions starting from 5.3.0 and prior to 5.3.38 and 5.6.1, under certain conditions, back end users may be able to edit fields of pages and articles without having the necessary permissions. This issue has been patched in versions 5.3.38 and 5.6.1. There are no workarounds.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Contao CMS 5.3.0–5.3.38/5.6.1 allows back-end users to edit page/article fields without proper permissions due to a privilege management flaw.
Vulnerability
Overview
CVE-2025-57759 is a privilege management vulnerability in Contao, an open-source CMS. In versions 5.3.0 up to (but not including) 5.3.38 and 5.6.1, the PagePermissionVoter class failed to properly enforce permission checks when back-end users edited pages or articles. Under certain conditions, users could modify fields without having the necessary permissions [1][4].
Root
Cause and Attack Vector
The issue stems from incomplete authorization logic in the Contao core. A commit shows that the fix adds a preloadPageTypes method and ensures that page-type-related data is fetched before performing access checks [2]. Without this preloading, the canCreate and canRead methods could mistakenly grant edit access based on stale or missing cache data. An attacker who is already a back-end user (authenticated) may exploit this by interacting with the management interface under conditions where the permission cache is not properly populated.
Impact
Successful exploitation allows a back-end user to edit fields of pages and articles they should not have access to. This could lead to unauthorized modification of site content, potential privilege escalation, or integrity violations. The vulnerability does not grant arbitrary code execution but directly undermines the CMS role-based access controls [1][4].
Mitigation
The vulnerability has been patched in Contao versions 5.3.38 and 5.6.1. There are no known workarounds; administrators must upgrade to the patched releases. Neither the NVD nor the advisory indicates active exploitation in the wild as of the publication date [1][4].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
contao/core-bundlePackagist | >= 5.3.0, < 5.3.38 | 5.3.38 |
contao/core-bundlePackagist | >= 5.4.0-RC1, < 5.6.1 | 5.6.1 |
contao/contaoPackagist | >= 5.3.0, < 5.3.38 | 5.3.38 |
contao/contaoPackagist | >= 5.4.0-RC1, < 5.6.1 | 5.6.1 |
Affected products
2- Range: >=5.3.0 <5.3.38 or >=5.6.x <5.6.1
- contao/contaov5Range: >= 5.3.0, < 5.3.38
Patches
180ee7db12d55Merge commit from fork
5 files changed · +72 −28
core-bundle/config/services.yaml+1 −0 modified@@ -975,6 +975,7 @@ services: arguments: - '@contao.framework' - '@security.access.decision_manager' + - '@database_connection' contao.security.data_container.page_type_access_voter: class: Contao\CoreBundle\Security\Voter\DataContainer\PageTypeAccessVoter
core-bundle/src/EventListener/DataContainer/DefaultOperationsListener.php+1 −1 modified@@ -246,7 +246,7 @@ private function copyChildrenCallback(string $table): \Closure private function toggleCallback(string $table, string $toggleField): \Closure { return function (DataContainerOperation $operation) use ($toggleField, $table): void { - $new = [$toggleField => !($operation['record'][$toggleField] ?? false)]; + $new = [$toggleField => !($operation->getRecord()[$toggleField] ?? false)]; if (!$this->isGranted(UpdateAction::class, $table, $operation, $new)) { // Do not use DataContainerOperation::disable() because it would not show the
core-bundle/src/Security/Voter/DataContainer/ArticleContentVoter.php+18 −11 modified@@ -22,7 +22,10 @@ */ class ArticleContentVoter extends AbstractDynamicPtableVoter { - private array $pageIds = []; + /** + * @var array<int, array{id: int, type: string}> + */ + private array $pageMap = []; public function __construct( private readonly AccessDecisionManagerInterface $accessDecisionManager, @@ -35,7 +38,7 @@ public function reset(): void { parent::reset(); - $this->pageIds = []; + $this->pageMap = []; } protected function getTable(): string @@ -53,20 +56,24 @@ protected function hasAccessToRecord(TokenInterface $token, string $table, int $ return false; } - $pageId = $this->getPageId($id); + $page = $this->getPage($id); - return $pageId - && $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_PAGE], $pageId) - && $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_EDIT_ARTICLES], $pageId); + return $page + && $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_PAGE], $page['id']) + && $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_EDIT_ARTICLES], $page['id']) + && $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_PAGE_TYPE], $page['type']); } - private function getPageId(int $articleId): int|null + /** + * @return array{id: int, type: string}|null + */ + private function getPage(int $articleId): array|null { - if (!\array_key_exists($articleId, $this->pageIds)) { - $pid = $this->connection->fetchOne('SELECT pid FROM tl_article WHERE id=?', [$articleId]); - $this->pageIds[$articleId] = false !== $pid ? (int) $pid : null; + if (!\array_key_exists($articleId, $this->pageMap)) { + $record = $this->connection->fetchAssociative('SELECT id, type FROM tl_page WHERE id=(SELECT pid FROM tl_article WHERE id=?)', [$articleId]); + $this->pageMap[$articleId] = false !== $record ? $record : null; } - return $this->pageIds[$articleId]; + return $this->pageMap[$articleId]; } }
core-bundle/src/Security/Voter/DataContainer/PagePermissionVoter.php+51 −5 modified@@ -20,6 +20,8 @@ use Contao\CoreBundle\Security\DataContainer\ReadAction; use Contao\CoreBundle\Security\DataContainer\UpdateAction; use Contao\Database; +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Connection; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; @@ -33,9 +35,15 @@ class PagePermissionVoter implements VoterInterface, CacheableVoterInterface, Re { private array $pagemountsCache = []; + /** + * @var array<int, string|false> + */ + private array $pageTypeCache = []; + public function __construct( private readonly ContaoFramework $framework, private readonly AccessDecisionManagerInterface $accessDecisionManager, + private readonly Connection $connection, ) { } @@ -52,6 +60,7 @@ public function supportsType(string $subjectType): bool public function reset(): void { $this->pagemountsCache = []; + $this->pageTypeCache = []; } public function vote(TokenInterface $token, $subject, array $attributes): int @@ -99,6 +108,7 @@ private function canCreate(CreateAction $action, TokenInterface $token): bool // Check access to any page for the "new" operation. if (null === $action->getNewPid()) { $pageIds = $this->getPagemounts($token); + $this->preloadPageTypes($pageIds); } else { $pageIds = [(int) $action->getNewPid()]; } @@ -119,7 +129,7 @@ private function canCreate(CreateAction $action, TokenInterface $token): bool private function canRead(ReadAction $action, TokenInterface $token): bool { - return $this->canAccessPage($token, $this->getCurrentPageId($action)); + return $this->canAccessPage($token, $this->getCurrentPageId($action), false); } private function canUpdate(UpdateAction $action, TokenInterface $token): bool @@ -156,7 +166,7 @@ private function canUpdate(UpdateAction $action, TokenInterface $token): bool unset($newRecord['pid'], $newRecord['sorting'], $newRecord['tstamp']); // Record was possibly only moved (pid, sorting), no need to check edit permissions - if ([] === array_diff($newRecord, $action->getCurrent())) { + if ([] === array_diff_assoc($newRecord, $action->getCurrent())) { return true; } @@ -173,7 +183,7 @@ private function canDelete(DeleteAction $action, TokenInterface $token): bool default => throw new \UnexpectedValueException('Unsupported data source "'.$action->getDataSource().'"'), }; - return $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_PAGE], $pageId) + return $this->canAccessPage($token, $pageId) && $this->accessDecisionManager->decide($token, [$permission], $pageId); } @@ -225,8 +235,44 @@ private function canChangeHierarchy(CreateAction|UpdateAction $action, TokenInte return $this->accessDecisionManager->decide($token, $attributes, $pageId); } - private function canAccessPage(TokenInterface $token, int $pageId): bool + private function canAccessPage(TokenInterface $token, int $pageId, bool $checkType = true): bool + { + if (!$this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_PAGE], $pageId)) { + return false; + } + + if (!$checkType) { + return true; + } + + return $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_PAGE_TYPE], $this->getPageType($pageId)); + } + + private function getPageType(int $id): string { - return $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_PAGE], $pageId); + if (!isset($this->pageTypeCache[$id])) { + $this->pageTypeCache[$id] = $this->connection->fetchOne('SELECT type FROM tl_page WHERE id=?', [$id]); + } + + if (false === $this->pageTypeCache[$id]) { + throw new \UnexpectedValueException('Page ID "'.$id.'" not found'); + } + + return $this->pageTypeCache[$id]; + } + + private function preloadPageTypes(array $pageIds): void + { + $pageIds = array_values(array_flip(array_diff_key(array_flip($pageIds), $this->pageTypeCache))); + + if ([] === $pageIds) { + return; + } + + $this->pageTypeCache += $this->connection->fetchAllKeyValue( + 'SELECT id, type FROM tl_page WHERE id IN (?)', + [$pageIds], + [ArrayParameterType::INTEGER], + ); } }
core-bundle/src/Security/Voter/DataContainer/PageTypeAccessVoter.php+1 −11 modified@@ -54,20 +54,10 @@ protected function getTable(): string protected function hasAccess(TokenInterface $token, CreateAction|DeleteAction|ReadAction|UpdateAction $action): bool { - return $this->validateAccessToPageType($token, $action) - && $this->validateFirstLevelType($action) + return $this->validateFirstLevelType($action) && $this->validateRootType($action); } - private function validateAccessToPageType(TokenInterface $token, CreateAction|DeleteAction|ReadAction|UpdateAction $action): bool - { - if ($action instanceof ReadAction) { - return true; - } - - return $this->hasAccessToType($token, ContaoCorePermissions::USER_CAN_ACCESS_PAGE_TYPE, $action); - } - private function validateFirstLevelType(CreateAction|DeleteAction|ReadAction|UpdateAction $action): bool { if ($action instanceof ReadAction || $action instanceof DeleteAction) {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-qqfq-7cpp-hcqjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-57759ghsaADVISORY
- contao.org/en/security-advisories/improper-privilege-management-for-page-and-article-fieldsghsax_refsource_MISCWEB
- github.com/contao/contao/commit/80ee7db12d55ad979d9b1b180f273d4e2668851fghsax_refsource_MISCWEB
- github.com/contao/contao/security/advisories/GHSA-qqfq-7cpp-hcqjghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.