Contao has improper access control in the back end voters
Description
Contao is an Open Source CMS. In versions starting from 5.0.0 and prior to 5.3.38 and 5.6.1, the table access voter in the back end doesn't check if a user is allowed to access the corresponding module. This issue has been patched in versions 5.3.38 and 5.6.1. A workaround involves not relying solely on the voter and additionally to check USER_CAN_ACCESS_MODULE.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Contao CMS back end table access voter fails to check module permissions, allowing unauthorized data operations until patched in 5.3.38/5.6.1.
Vulnerability
The table access voter in Contao's back end, responsible for controlling access to data operations (create, read, update, delete), does not verify whether a user is permitted to access the corresponding back end module [1]. This missing check means the voter grants or denies access based solely on the table name, without consulting the broader module-level permissions that should gate visibility and actions for each administrative area. The flaw affects Contao versions 5.0.0 through 5.3.37 and 5.6.0 [1].
Exploitation
An authenticated back end user can potentially perform operations (such as CRUD actions) on tables whose associated module they are not authorized to access [2]. The attacker needs a valid back end session and some knowledge of the target table structure. The attack complexity is low, as the missing check is triggered automatically when the voter evaluates a request for a table prefix [4]. No special privileges beyond being a logged-in back end user are required, and no user interaction beyond the attacker's own actions is needed [4].
Impact
Successful exploitation allows an attacker to read, create, update, or delete records in DCA (Data Container Array) tables that belong to modules to which they have no legitimate access [1][2]. This can lead to unauthorized data exposure, manipulation of content, or disruption of administrative functions, compromising the confidentiality, integrity, and availability of the CMS instance [4].
Mitigation
The vendor has released patches in versions 5.3.38 and 5.6.1 that add the missing module permission check [1][2]. Users should update immediately. As a workaround, administrators can supplement the voter by explicitly checking the USER_CAN_ACCESS_MODULE permission in custom code [1]. The vulnerability is not listed on CISA's Known Exploited Vulnerabilities catalog as of publication.
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.0.0, < 5.3.38 | 5.3.38 |
contao/core-bundlePackagist | >= 5.4.0-RC1, < 5.6.1 | 5.6.1 |
contao/contaoPackagist | >= 5.0.0, < 5.3.38 | 5.3.38 |
contao/contaoPackagist | >= 5.4.0-RC1, < 5.6.1 | 5.6.1 |
Affected products
2- Range: >=5.0.0, <5.3.38 || >=5.0.0, <5.6.1
- contao/contaov5Range: >= 5.0.0, < 5.3.38
Patches
13f05c603e1c9Merge commit from fork
3 files changed · +135 −40
core-bundle/src/Security/Voter/DataContainer/AbstractDataContainerVoter.php+1 −2 modified@@ -19,9 +19,8 @@ use Contao\CoreBundle\Security\DataContainer\UpdateAction; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; -use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -abstract class AbstractDataContainerVoter implements VoterInterface, CacheableVoterInterface +abstract class AbstractDataContainerVoter implements CacheableVoterInterface { public function supportsAttribute(string $attribute): bool {
core-bundle/src/Security/Voter/DataContainer/TableAccessVoter.php+48 −20 modified@@ -14,6 +14,8 @@ use Contao\CoreBundle\Security\ContaoCorePermissions; use Contao\CoreBundle\Security\DataContainer\CreateAction; +use Contao\CoreBundle\Security\DataContainer\DeleteAction; +use Contao\CoreBundle\Security\DataContainer\ReadAction; use Contao\CoreBundle\Security\DataContainer\UpdateAction; use Contao\DataContainer; use Contao\DC_File; @@ -37,39 +39,65 @@ public function supportsAttribute(string $attribute): bool public function supportsType(string $subjectType): bool { - return match ($subjectType) { - CreateAction::class, - UpdateAction::class => true, - default => false, - }; + return \in_array($subjectType, [CreateAction::class, ReadAction::class, UpdateAction::class, DeleteAction::class], true); } /** * @param CreateAction|UpdateAction $subject */ public function vote(TokenInterface $token, $subject, array $attributes): int { - foreach ($attributes as $attribute) { - if (!$this->supportsAttribute($attribute) || DC_File::class === DataContainer::getDriverForTable($subject->getDataSource())) { - continue; - } + if (!array_filter($attributes, $this->supportsAttribute(...))) { + return self::ACCESS_ABSTAIN; + } - $hasNotExcluded = false; + // Check access to a module with this DCA + if (!$this->hasAccessToModule($token, $subject->getDataSource())) { + return self::ACCESS_DENIED; + } - // Intentionally do not load DCA, it should already be loaded. If DCA is not - // loaded, the voter just always abstains because it can't decide. - foreach ($GLOBALS['TL_DCA'][$subject->getDataSource()]['fields'] ?? [] as $config) { - if (!($config['exclude'] ?? true)) { - $hasNotExcluded = true; - break; - } - } + // If table access is granted, we don't check field permission for READ or DELETE + if ($subject instanceof ReadAction || $subject instanceof DeleteAction) { + return self::ACCESS_ABSTAIN; + } + + // DC_File does not have excluded fields + if (DC_File::class === DataContainer::getDriverForTable($subject->getDataSource())) { + return self::ACCESS_ABSTAIN; + } + + $hasNotExcluded = false; - if (!$hasNotExcluded && !$this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], $subject->getDataSource())) { - return self::ACCESS_DENIED; + // Intentionally do not load DCA, it should already be loaded. If DCA is not + // loaded, the voter just always abstains because it can't decide. + foreach ($GLOBALS['TL_DCA'][$subject->getDataSource()]['fields'] ?? [] as $config) { + if (!($config['exclude'] ?? true)) { + $hasNotExcluded = true; + break; } } + if (!$hasNotExcluded && !$this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], $subject->getDataSource())) { + return self::ACCESS_DENIED; + } + return self::ACCESS_ABSTAIN; } + + private function hasAccessToModule(TokenInterface $token, string $table): bool + { + foreach ($GLOBALS['BE_MOD'] as $modules) { + foreach ($modules as $name => $config) { + if ( + \is_array($config['tables'] ?? null) + && \in_array($table, $config['tables'], true) + && $this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], $name) + ) { + return true; + } + } + } + + return false; + } }
core-bundle/tests/Security/Voter/DataContainer/TableAccessVoterTest.php+86 −18 modified@@ -40,14 +40,14 @@ protected function setUp(): void $this->voter = new TableAccessVoter($this->accessDecisionManager); $this->token = $this->createMock(TokenInterface::class); - unset($GLOBALS['TL_DCA']); + unset($GLOBALS['TL_DCA'], $GLOBALS['TL_MOD']); } protected function tearDown(): void { parent::tearDown(); - unset($GLOBALS['TL_DCA']); + unset($GLOBALS['TL_DCA'], $GLOBALS['TL_MOD']); } public function testSupportsDCAttribute(): void @@ -56,12 +56,12 @@ public function testSupportsDCAttribute(): void $this->assertFalse($this->voter->supportsAttribute('foobar')); } - public function testSupportsCreateAndUpdateActionSubject(): void + public function testSupportsCRUDActionSubject(): void { $this->assertTrue($this->voter->supportsType(CreateAction::class)); $this->assertTrue($this->voter->supportsType(UpdateAction::class)); - $this->assertFalse($this->voter->supportsType(ReadAction::class)); - $this->assertFalse($this->voter->supportsType(DeleteAction::class)); + $this->assertTrue($this->voter->supportsType(ReadAction::class)); + $this->assertTrue($this->voter->supportsType(DeleteAction::class)); $this->assertFalse($this->voter->supportsType('foobar')); } @@ -78,8 +78,58 @@ public function testAbstainsOnUnsupportedAttribute(): void ); } + public function testDeniesIfTableIsNotInAllowedModule(): void + { + $GLOBALS['BE_MOD']['content']['article']['tables'] = ['tl_foobar']; + + $this->accessDecisionManager + ->expects($this->once()) + ->method('decide') + ->with($this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'article') + ->willReturn(false) + ; + + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $this->voter->vote($this->token, new CreateAction('tl_foobar'), [ContaoCorePermissions::DC_PREFIX.'tl_foobar']), + ); + } + + public function testAbstainsIfTableIsAllowedInSecondaryModule(): void + { + $GLOBALS['BE_MOD']['content']['article']['tables'] = ['tl_foobar']; + $GLOBALS['BE_MOD']['foo']['bar']['tables'] = ['tl_foobar']; + + $GLOBALS['TL_DCA']['tl_foobar']['fields'] = [ + 'foo' => [ + 'inputType' => 'text', + 'exclude' => true, + ], + 'bar' => [ + 'inputType' => 'text', + 'exclude' => false, + ], + ]; + + $this->accessDecisionManager + ->expects($this->exactly(2)) + ->method('decide') + ->willReturnMap([ + [$this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'article', false], + [$this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'bar', true], + ]) + ; + + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $this->voter->vote($this->token, new CreateAction('tl_foobar'), [ContaoCorePermissions::DC_PREFIX.'tl_foobar']), + ); + } + public function testAbstainsIfExcludedFieldAccessIsGranted(): void { + $GLOBALS['BE_MOD']['content']['article']['tables'] = ['tl_foobar']; + $GLOBALS['TL_DCA']['tl_foobar']['fields'] = [ 'foo' => [ 'inputType' => 'text', @@ -88,10 +138,12 @@ public function testAbstainsIfExcludedFieldAccessIsGranted(): void ]; $this->accessDecisionManager - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('decide') - ->with($this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar') - ->willReturn(true) + ->willReturnMap([ + [$this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'article', true], + [$this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar', true], + ]) ; $this->assertSame( @@ -102,17 +154,21 @@ public function testAbstainsIfExcludedFieldAccessIsGranted(): void public function testAbstainsIfDefaultExcludedFieldAccessIsGranted(): void { + $GLOBALS['BE_MOD']['content']['article']['tables'] = ['tl_foobar']; + $GLOBALS['TL_DCA']['tl_foobar']['fields'] = [ 'foo' => [ 'inputType' => 'text', ], ]; $this->accessDecisionManager - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('decide') - ->with($this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar') - ->willReturn(true) + ->willReturnMap([ + [$this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'article', true], + [$this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar', true], + ]) ; $this->assertSame( @@ -123,6 +179,8 @@ public function testAbstainsIfDefaultExcludedFieldAccessIsGranted(): void public function testAbstainsIfAtLeastOneFieldIsNotExcluded(): void { + $GLOBALS['BE_MOD']['content']['article']['tables'] = ['tl_foobar']; + $GLOBALS['TL_DCA']['tl_foobar']['fields'] = [ 'foo' => [ 'inputType' => 'text', @@ -135,8 +193,10 @@ public function testAbstainsIfAtLeastOneFieldIsNotExcluded(): void ]; $this->accessDecisionManager - ->expects($this->never()) + ->expects($this->once()) ->method('decide') + ->with($this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'article') + ->willReturn(true) ; $this->assertSame( @@ -147,6 +207,8 @@ public function testAbstainsIfAtLeastOneFieldIsNotExcluded(): void public function testDeniesAccessIfFieldIsExcluded(): void { + $GLOBALS['BE_MOD']['content']['article']['tables'] = ['tl_foobar']; + $GLOBALS['TL_DCA']['tl_foobar']['fields'] = [ 'foo' => [ 'inputType' => 'text', @@ -159,10 +221,12 @@ public function testDeniesAccessIfFieldIsExcluded(): void ]; $this->accessDecisionManager - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('decide') - ->with($this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar') - ->willReturn(false) + ->willReturnMap([ + [$this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'article', true], + [$this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar', false], + ]) ; $this->assertSame( @@ -173,6 +237,8 @@ public function testDeniesAccessIfFieldIsExcluded(): void public function testDeniesAccessIfFieldIsDefaultExcluded(): void { + $GLOBALS['BE_MOD']['content']['article']['tables'] = ['tl_foobar']; + $GLOBALS['TL_DCA']['tl_foobar']['fields'] = [ 'foo' => [ 'inputType' => 'text', @@ -184,10 +250,12 @@ public function testDeniesAccessIfFieldIsDefaultExcluded(): void ]; $this->accessDecisionManager - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('decide') - ->with($this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar') - ->willReturn(false) + ->willReturnMap([ + [$this->token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'article', true], + [$this->token, [ContaoCorePermissions::USER_CAN_EDIT_FIELDS_OF_TABLE], 'tl_foobar', false], + ]) ; $this->assertSame(
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-7m47-r75r-cx8vghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-57758ghsaADVISORY
- contao.org/en/security-advisories/improper-access-control-in-the-back-end-votersghsax_refsource_MISCWEB
- github.com/contao/contao/commit/3f05c603e1c94d34819f837f060df5d66447d0d7ghsax_refsource_MISCWEB
- github.com/contao/contao/security/advisories/GHSA-7m47-r75r-cx8vghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.