See how contao compares to other vendors in security performance
Contao is an Open Source CMS. From version 5.7.0 until 5.7.12, TableAccessVoter::hasAccessToModule() in core-bundle/src/Security/Voter/DataContainer/TableAccessVoter.php caches authorization decisions using only $tokenHash, a hash of the user's security token, and omits the table returned by getDataSource(). If one request first checks a table allowed to the user and then a different denied table, the voter can reuse the allowed result, while DefaultDataContainerVoter can convert an incorrect abstention into a grant. A low-privileged backend user can consequently read, create, update, or delete records in tables outside assigned module permissions, including tables containing member or newsletter-subscriber data. This issue is fixed in version 5.7.12.
core-bundle/config/services.yaml registers the preview access voter with the class Contao\CoreBundle\Security\Voter\DataContainer\PreviewAccessVoter, but the class shipped on disk is named PreviewVoter. Symfony resolves the class with getReflectionClass($class, false), gets null, silently skips the interface based autoconfiguration that would have tagged it as a voter, and the now untagged private service is removed when the container is compiled. The application boots without a warning, and the ownership rule in PreviewVoter::hasAccess() is never executed. tlpreviewlink has no compensating SQL filter on createdBy, so any non administrator back end user whose group holds the previewlink module reads every preview link on the installation and lifts the already signed share URLs straight out of the list. Following one grants front end preview of the target page, with showUnpublished and with no page permission check, to anyone at all.
Impact
A non administrator back end user reads every preview link on the installation, including links created by administrators, and lifts the already signed share URLs out of the list. Following one grants front end preview of the target page with showUnpublished and with no page permission check at all, so unpublished or embargoed content becomes readable by a user who has no rights on those pages, and by anyone that user forwards the URL to.
Honesty caveat, stated because it bounds the severity. UpdateAction on a record owned by someone else was denied in our lab by DcaPermissionVoter, so we do not claim that a foreign preview link can be edited or deleted.
RequestTokenListener is the only generic REQUESTTOKEN validator in Contao and it only runs on POST. On the GET side the guard is local and declarative, and it only fires when an act parameter is present. Every back end action dispatched through a different parameter therefore has no CSRF protection at all. This is a family rather than a single bug.
Impact
An attacker who gets a logged in back end user to load a URL performs back end actions as that user, with no token and no confirmation. Because the exposure follows from the dispatch mechanism rather than from one callback, every present and future key= action inherits it.
Honest bound. The victim must be authenticated in the back end and must load the URL, and the reachable actions are limited to the modules that user can access. We have not found a key= action that grants privileges; the ones we verified are destructive or state changing rather than escalating.
An unauthenticated front end visitor can post a comment containing a XSS injection. The victim is any back end user who opens the Comments module, and moderation makes exposure certain rather than preventing it. No Content-Security-Policy header is sent on the Contao back end, so nothing mitigates the inline handler.
Impact
Anyone on the internet can inject script that executes in the Contao back end origin, under the session of whichever user opens the Comments module, with no click and no hover. From there the payload acts as that user through the back end: reading any module they can reach, creating a new administrator, or editing a template, which on Contao is a documented path from back end write access to code execution. Moderation does not help and in fact guarantees exposure, since unpublished comments are still inserted and a moderator has to open the list to act on them.
ImagesController joins the user controlled {path} onto the configured image target directory with Path::join(), which canonicalises .. segments, and never calls Path::isBasePath() to confirm the result stayed inside that directory. An unauthenticated GET with percent encoded dot segments therefore escapes the directory and the file is returned by BinaryFileResponse. Reads are bounded to the extensions in contao.image.validextensions, and paths below the upload directory fail for an unrelated reason, so this is reported as a missing boundary check rather than as a general arbitrary file read. Both Apache with the shipped public/.htaccess and nginx with the documented configuration are affected.
Impact
Any unauthenticated visitor can read files that are deliberately kept outside the document root, anywhere under the project directory, as long as the name ends in one of contao.image.validextensions (jpg, jpeg, gif, png, tif, tiff, bmp, svg, svgz, webp). The route also acts as an existence oracle for arbitrary paths, and on a debug enabled instance the 404 message discloses the absolute filesystem path.
Honesty caveat, stated because it lowers the severity. The reporter could not use this to read protected member folders: every path below the upload directory returns 500 for the reason given under Details, so the practically reachable set is image typed files outside files/.
ModuleRegistration::compile() reaches its follow-up registration branch on any POST to a page carrying the module. That branch checks neither FORMSUBMIT nor the captcha result computed immediately above it, and resendActivationMail() leads to OptInToken::send(), which has no rate limit at all.
Impact
Anyone on the internet can make a Contao installation send unlimited mail to an address of their choosing, from the site's own sender and reputation, at one outbound message per HTTP request. That is both a nuisance for the recipient and a deliverability risk for the site operator. The same request is a reliable account oracle for "this address has a pending registration on this site", which is exactly the sort of membership fact a public site is usually expected not to disclose.
Honest bound. The target must have an unconfirmed registration, that is tlmember.disable = 1 together with an unconfirmed reg- opt-in token. An attacker can create that state for an arbitrary address, since registration requires no ownership proof, but on a site where regactivate is off the branch is unreachable.
ModuleSearch decides whether to filter protected pages out of search results based on the current value of contao.search.indexprotected, but the authorisation data lives per row in tlsearch. Turning the setting off removes the filter without removing the rows, so protected pages that were indexed while it was on are returned to unauthenticated visitors such as title, URL and context snippet, even though the pages themselves still answer 401.
Impact
Disclosure of member-only page titles, URLs and indexed text to unauthenticated visitors through the site search. The pages themselves remain access-controlled, so this is not a page-access bypass.
Credits
This security vulnerability was found by @iRevivalx .
End of life: 2/14/2027, End of support: 2/14/2027, Latest version: 6.0.4
Summary
An authenticated backend user who can access one job can request an attachment identifier containing ../ segments and make the job attachment download endpoint read a file from another job directory inside var/job-attachments.
The controller authorizes only the jobUuid route parameter. The later attachment lookup joins that authorized job UUID with the attacker-controlled identifier, then passes the combined path to the virtual filesystem. VirtualFilesystem::resolve() canonicalizes the whole path and only rejects paths that escape the filesystem mount, so authorized-job/../victim-job/debuglog.csv becomes victim-job/debuglog.csv.
This is a cross-job authorization bypass for known job attachment paths. It is not a practical brute-force against unknown jobs because job directories are UUID v4 values.
Root Cause
JobsController::downloadJobAttachment() checks access to the route jobUuid before loading the attachment:
php $job = $this->jobs->getByUuid($jobUuid);
if (!$job || !$this->jobs->hasAccess($job)) { throw $this->createNotFoundException(); }
$attachment = $this->jobs->getAttachment($jobUuid, $identifier);
Jobs::getAttachment() then resolves a path built from the authorized job UUID and the attacker-controlled identifier:
php $fileItem = $this->jobAttachmentsStorage->get($this->getAttachmentIdentifier($job, $identifier));
php return $job->getUuid().'/'.$identifier;
VirtualFilesystem::resolve() canonicalizes the combined path. It rejects absolute paths and paths that start with .., but it does not preserve the authorized job directory as a boundary:
php $path = Path::canonicalize($location);
if (strstartswith($path, '..')) { throw new \OutOfBoundsException(...); }
return Path::join($this->prefix, $path);
Therefore:
text <authorized-job>/../<victim-job>/debuglog.csv
canonicalizes to:
text <victim-job>/debuglog.csv
which remains inside the job-attachments filesystem mount and is accepted.
Recommended Fix
Treat the attachment identifier as a filename, not a path:
- Reject /, \, NUL, and dot-segment components in identifier. - Add a route requirement that prevents slashes in {identifier} if nested attachment paths are not intended. - After resolving, assert the canonical relative path starts with <authorized-job-uuid>/ before returning a FilesystemItem. - Apply the same identifier validation in Jobs::addAttachment() so future producers/extensions cannot write outside the owning job directory.
Impact A low-privileged backend user can read another job's attachment if they know or obtain the target job UUID and attachment filename. Built-in crawler jobs attach CSV logs such as debuglog.csv, broken-link-checkerlog.csv, and search-indexlog.csv, which can contain crawled URLs, referring URLs, tags, and error messages.
Summary Contao's crawler tries to prevent confidential HTTP client options from being sent to external domains by creating a scoped client: full options for root page origins, cleaned options for everything else. The cleaner removes Cookie and Authorization headers, but it removes the non-Symfony option names basicauth and bearerauth instead of Symfony HttpClient's real authbasic and authbearer options.
When contao.crawl.defaulthttpclientoptions contains Basic or Bearer authentication for a protected staging/production site, those credentials remain in the "clean" client used for external links or configured additional URIs. An attacker who can get an external URL crawled, for example through a link on a crawled page while the broken-link checker is enabled, can receive the crawler credentials.
Technical Detail
Root Cause
php // core-bundle/src/Crawl/Escargot/Factory.php:175-209 @ e550b92a01ef625bd546e6c3956dd200af05ebf0 private function createHttpClient(array $options = []): HttpClientInterface { $options = arraymergerecursive( [ 'headers' => [ 'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8', 'user-agent' => self::USERAGENT, ], 'maxduration' => 10, ], arraymergerecursive($this->getDefaultHttpClientOptions(), $options), );
$cleanOptions = $this->cleanOptionsFromConfidentialData($options);
if ($options === $cleanOptions) { return ($this->httpClientFactory)($options); }
$scopedOptionsByRegex = [];
foreach ($this->getRootPageUriCollection()->all() as $rootPageUri) { $scopedOptionsByRegex[pregquote($this->getOriginFromUri($rootPageUri))] = $options; }
return new ScopingHttpClient(($this->httpClientFactory)($cleanOptions), $scopedOptionsByRegex); }
php // core-bundle/src/Crawl/Escargot/Factory.php:226-247 @ e550b92a01ef625bd546e6c3956dd200af05ebf0 foreach ($options as $k => $v) { if ('headers' === $k) { foreach ($v as $header => $value) { if (\inarray(strtolower($header), ['authorization', 'cookie'], true)) { continue; }
$cleanOptions['headers'][$header] = $value; }
continue; }
if ('basicauth' === $k || 'bearerauth' === $k) { continue; }
$cleanOptions[$k] = $v; }
Symfony HttpClient authentication options are authbasic and authbearer; Contao's own manual documents authbasic for crawler Basic Authentication. Because the cleaner only strips basicauth and bearerauth, the "clean" default client for non-root-page hosts still carries the real auth options. The existing factory test intends to assert that Authorization is not sent to www.foreign-domain.com, but its mock client factory ignores the $defaultOptions argument, so it does not catch auth options that survive into HttpClient::create($cleanOptions).
Suggested Mitigation
Strip the actual Symfony HttpClient authentication option keys from the clean client. Include NTLM as a defensive extension because Symfony documents it as another auth option.
diff - if ('basicauth' === $k || 'bearerauth' === $k) { + if (\inarray($k, ['authbasic', 'authbearer', 'authntlm', 'basicauth', 'bearerauth'], true)) { continue; }
Also update the factory test so the mock factory records or preserves $defaultOptions; otherwise the test does not verify what HttpClient::create($cleanOptions) receives in production.
Impact
- Direct primitive: disclosure of crawler Basic/Bearer credentials to an external host reached by the crawler. - Chain potential: if those credentials protect a staging or pre-publication environment, an attacker can use them to access that environment. The impact depends on what the leaked credential unlocks. - Realistic exploitation: a content editor adds a link to https://attacker.example/probe on a page that the crawler visits. When an administrator or scheduled maintenance run starts the broken-link checker with crawler Basic/Bearer authentication configured, the request to the attacker URL includes the generated Authorization header.
Summary
The Feed Reader front-end module passes RSS feed URLs from its configuration directly to $this->feedIo->read($url) without any scheme validation or private-IP blocklist. A backend user with module-edit permissions can configure an arbitrary URL pointing to internal network services, cloud-provider metadata endpoints, or loopback addresses, causing the server to fetch those resources unconditionally. Confirmed live: the server successfully reaches the internal MySQL database container and its own loopback Apache instance.
---
Details
In core-bundle/src/Controller/FrontendModule/FeedReaderController.php, the getResponse() function iterates over the configured feed URLs and passes each one directly to the HTTP client with no validation:
php // Line 50-55 foreach (StringUtil::trimsplit('[\n\t ]', trim($model->rssfeed)) as $url) { try { $feed = $this->cache->get( 'feedreader'.$model->id.''.md5($url), function (ItemInterface $item) use ($url, $model) { $readerResult = $this->feedIo->read($url, new Feed()); // <-- no validation
The DCA field definition for rssfeed in tlmodule.php carries no URL scheme or host validation: php 'eval' => array('mandatory'=>true, 'decodeEntities'=>true, 'style'=>'height:60px')
The HTTP client is wired as @psr18.httpclient (Symfony HttpClient) with no SSRF protection configured (NoPrivateNetworkHttpClient is not used).
---
Impact
This is a CWE-918 (SSRF) vulnerability. A backend user with module-edit access can:
1. Enumerate internal network services -- probe any IP/port on the internal network by observing response times and error messages 2. Reach internal APIs -- access unauthenticated services inside the Docker/Kubernetes network (databases, caches, admin panels) 3. Steal cloud metadata credentials -- on AWS, fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/ to obtain IAM role credentials (IMDSv1 has no authentication) 4. Pivot to internal infrastructure -- use the server as a proxy to interact with services not exposed to the public internet
Confirmed in live testing: the server successfully connected to the internal MySQL container (172.19.0.3:3306) and retrieved a full HTTP response from its own loopback interface (127.0.0.1:80).
---
Remediation
1. Use NoPrivateNetworkHttpClient -- wrap the injected HTTP client with Symfony's built-in SSRF protection before passing it to feedIo: php use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
$safeClient = new NoPrivateNetworkHttpClient($this->httpClient); This blocks all RFC-1918, loopback, and link-local addresses at the HTTP client level.
2. Validate URL scheme and host -- before calling feedIo->read(), parse the URL and reject anything that is not http:// or https:// with a public routable IP or hostname.
3. Configure the DCA field -- add 'rgxp' => 'url' and a custom validation callback to tlmodule.rssfeed to reject non-public URLs at save time.
End of life: 2/14/2030, End of support: 2/14/2029, Latest version: 5.7.15
Impact
It is possible to inject code into the template output that will be executed in the browser in the front end and back end.
Patches
Update to Contao 4.13.57, 5.3.42 or 5.6.5.
Workarounds
Do not use the affected templates or patch them manually.
Refsources
https://contao.org/en/security-advisories/cross-site-scripting-in-templates
Impact
Backend users with precise control over the contents of template closures can execute arbitrary PHP functions that do not have required parameters.
Patches
Update to Contao 4.13.57, 5.3.42 or 5.6.5
Workarounds
Manually patch the Contao\Template::once() method.
Resources
https://contao.org/en/security-advisories/remote-code-execution-in-template-closures
Impact
Under certain conditions, back end users may be able to edit fields of pages and articles without having the necessary permissions.
Patches
Update to Contao 5.3.38 or 5.6.1.
Workarounds
None.
For more information
If you have any questions or comments about this advisory, open an issue in contao/contao.
Impact
If a news feed contains protected news archives, their news items are not filtered and become publicly available in the RSS feed.
Patches
Update to Contao 5.3.38 or 5.6.1.
Workarounds
Do not add protected news archives to the news feed page.
For more information
If you have any questions or comments about this advisory, open an issue in contao/contao.
Impact
Protected content elements that are rendered as fragments are indexed and become publicly available in the front end search.
Patches
Update to Contao 4.13.56, 5.3.38 or 5.6.1.
Workarounds
Disable the front end search.
For more information
If you have any questions or comments about this advisory, open an issue in contao/contao.
Impact
The table access voter in the back end doesn't check if a user is allowed to access the corresponding module.
Patches
Update to Contao 5.3.38 or 5.6.1.
Workarounds
Do not rely solely on the voter and additionally check USERCANACCESSMODULE.
For more information
If you have any questions or comments about this advisory, open an issue in contao/contao.
End of life: 2/14/2026, End of support: 2/14/2026, Latest version: 5.6.11
Impact
Users can upload SVG files with malicious code, which is then executed in the back end and/or front end.
Patches
Update to Contao 4.13.54, 5.3.30 or 5.5.6.
Workarounds
Remove svg,svgz from the allowed upload file types in the system settings and from contao.editablefiles in the config.yaml.
References
https://contao.org/en/security-advisories/cross-site-scripting-through-svg-uploads
For more information
If you have any questions or comments about this advisory, open an issue in contao/contao.
End of life: 8/14/2025, End of support: 8/14/2025, Latest version: 5.5.16
End of life: 2/14/2028, End of support: 2/14/2027, Latest version: 5.3.52
End of life: 2/14/2028, End of support: 2/14/2027, Latest version: 5.3.52
End of life: 2/14/2025, End of support: 2/14/2025, Latest version: 5.4.14
End of life: 2/14/2025, End of support: 2/14/2025, Latest version: 5.4.14
End of life: 2/14/2026, End of support: 2/14/2025, Latest version: 4.13.58
End of life: 2/14/2026, End of support: 2/14/2025, Latest version: 4.13.58
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-vqqr-fgmh-f626. This link is maintained to preserve external references.
Original Description
Contao 5.4.1 allows an authenticated admin account to upload a SVG file containing malicious javascript code into the target system. If the file is accessed through the website, it could lead to a Cross-Site Scripting (XSS) attack or execute arbitrary code via a crafted javascript to the target.
Impact
It is possible to inject insert tags in canonical URLs which will be replaced when the page is rendered.
Patches
Update to Contao 4.13.49, 5.3.15 or 5.4.3.
Workarounds
Disable canonical tags in the settings of the website root page.
References
https://contao.org/en/security-advisories/insert-tag-injection-via-canonical-urls
For more information
If you have any questions or comments about this advisory, open an issue in contao/contao.
Impact
Back end users can list files outside their file mounts or the document root in the FileSelector widget.
Patches
Update to Contao 4.13.49.
Workarounds
None.
References
https://contao.org/en/security-advisories/directory-traversal-in-the-fileselector-widget
For more information
If you have any questions or comments about this advisory, open an issue in contao/contao.
Credits
Thanks to Jakob Steeg from usd AG for reporting this vulnerability.