Repository: contao/contao
Pull request #8224: Do not require CSRF token checks on preflight requests
Author: login: Toflar
Labels: name: feature
Created: 2025-03-21T13:43:36Z | Merged: 2025-03-25T09:42:09Z

CSRF is only required for state modifying requests that are "simple requests" as per CORS definition. These are requests using one of the following `Content-Type` headers:

* `application/x-www-form-urlencoded`
* `multipart/form-data`
* `text/plain`

All the other requests require a preflight request and are thus not subject to CORS attacks.

We already ignore the check, if you add the `X-Requested-With: XMLHttpRequest` header but for some frameworks, this requires extra work. This PR should simplify working with those as the custom header is now not needed anymore. 


Diff:
diff --git a/core-bundle/src/EventListener/RequestTokenListener.php b/core-bundle/src/EventListener/RequestTokenListener.php
index 5e372dd2e05..5a92311373b 100644
--- a/core-bundle/src/EventListener/RequestTokenListener.php
+++ b/core-bundle/src/EventListener/RequestTokenListener.php
@@ -16,6 +16,7 @@
 use Contao\CoreBundle\Exception\InvalidRequestTokenException;
 use Contao\CoreBundle\Routing\ScopeMatcher;
 use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+use Symfony\Component\HttpFoundation\HeaderUtils;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Event\RequestEvent;
 use Symfony\Component\Security\Csrf\CsrfToken;
@@ -51,12 +52,14 @@ public function __invoke(RequestEvent $event): void
         // Only check the request token if
         // - the request is a POST request,
         // - the request is not an Ajax request,
+        // - the request is a CORS Content-Type that requires a preflight request,
         // - the _token_check attribute is not false,
         // - the _token_check attribute is true or the request is a Contao request and
         // - the request has cookies, an authenticated user or the session has been started.
         if (
             'POST' !== $request->getRealMethod()
             || $request->isXmlHttpRequest()
+            || !self::isSimpleCorsRequest($request)
             || false === $request->attributes->get('_token_check')
             || $this->csrfTokenManager->canSkipTokenValidation($request, $this->csrfCookiePrefix.$this->csrfTokenName)
             || (true !== $request->attributes->get('_token_check') && !$this->scopeMatcher->isContaoRequest($request))
@@ -73,6 +76,21 @@ public function __invoke(RequestEvent $event): void
         throw new InvalidRequestTokenException('Invalid CSRF token. Please reload the page and try again.');
     }
 
+    public static function isSimpleCorsRequest(Request $request): bool
+    {
+        $contentType = HeaderUtils::split($request->headers->get('content-type'), ';')[0] ?? '';
+
+        return \in_array(
+            strtolower($contentType),
+            [
+                'application/x-www-form-urlencoded',
+                'multipart/form-data',
+                'text/plain',
+            ],
+            true,
+        );
+    }
+
     private function getTokenFromRequest(Request $request): string|null
     {
         if ($request->request->has('REQUEST_TOKEN')) {
diff --git a/core-bundle/tests/EventListener/RequestTokenListenerTest.php b/core-bundle/tests/EventListener/RequestTokenListenerTest.php
index 922067e2fa1..194ac049444 100644
--- a/core-bundle/tests/EventListener/RequestTokenListenerTest.php
+++ b/core-bundle/tests/EventListener/RequestTokenListenerTest.php
@@ -31,8 +31,7 @@ class RequestTokenListenerTest extends TestCase
 {
     public function testValidatesTheRequestToken(): void
     {
-        $request = Request::create('/account.html', 'POST');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
 
         $request->attributes->set('_token_check', true);
         $request->request->set('REQUEST_TOKEN', 'foo');
@@ -44,8 +43,7 @@ public function testValidatesTheRequestToken(): void
 
     public function testValidatesTheRequestTokenUponAuthenticatedRequest(): void
     {
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
 
         $request->headers->set('PHP_AUTH_USER', 'user');
         $request->request->set('REQUEST_TOKEN', 'foo');
@@ -58,8 +56,7 @@ public function testValidatesTheRequestTokenUponRunningSession(): void
         $session = new Session(new MockArraySessionStorage());
         $session->set('foobar', 'foobaz');
 
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
         $request->setSession($session);
 
         $request->attributes->set('_token_check', true);
@@ -70,17 +67,14 @@ public function testValidatesTheRequestTokenUponRunningSession(): void
 
     public function testDoesNotValidateTheRequestTokenWithoutCookies(): void
     {
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
 
         $this->validateRequestTokenForRequest($request, false);
     }
 
     public function testDoesNotValidateTheRequestTokenWithCsrfCookiesOnly(): void
     {
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
-
+        $request = $this->createPostRequest();
         $request->cookies = new InputBag(['csrf_contao_csrf_token' => 'value']);
 
         $this->validateRequestTokenForRequest($request, false);
@@ -102,9 +96,7 @@ public function testValidatesTheRequestTokenDependingOnTheRequest(bool $setAttri
             ->willReturn(true)
         ;
 
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
-
+        $request = $this->createPostRequest();
         $request->request->set('REQUEST_TOKEN', 'foo');
 
         $request->cookies = new InputBag(['unrelated-cookie' => 'to-activate-csrf']);
@@ -154,8 +146,7 @@ public function testFailsIfTheRequestTokenIsInvalid(): void
             ->willReturn(false)
         ;
 
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
 
         $request->request->set('REQUEST_TOKEN', 'foo');
 
@@ -214,12 +205,35 @@ public function testDoesNotValidateTheRequestTokenUponAjaxRequests(): void
         $scopeMatcher = $this->createMock(ScopeMatcher::class);
         $csrfTokenManager = $this->createMock(ContaoCsrfTokenManager::class);
 
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
 
         $request->cookies = new InputBag(['unrelated-cookie' => 'to-activate-csrf']);
         $request->headers->set('X-Requested-With', 'XMLHttpRequest');
 
+        $event = $this->createMock(RequestEvent::class);
+        $event
+            ->expects($this->once())
+            ->method('getRequest')
+            ->willReturn($request)
+        ;
+
+        $event
+            ->expects($this->once())
+            ->method('isMainRequest')
+            ->willReturn(true)
+        ;
+
+        $listener = new RequestTokenListener($scopeMatcher, $csrfTokenManager, 'contao_csrf_token');
+        $listener($event);
+    }
+
+    public function testDoesNotValidateTheRequestTokenUponPreflightedRequests(): void
+    {
+        $scopeMatcher = $this->createMock(ScopeMatcher::class);
+        $csrfTokenManager = $this->createMock(ContaoCsrfTokenManager::class);
+
+        $request = $this->createPostRequest('text/xml'); // Content-Type requiring preflighted request
+
         $request->cookies = new InputBag(['unrelated-cookie' => 'to-activate-csrf']);
 
         $event = $this->createMock(RequestEvent::class);
@@ -244,8 +258,7 @@ public function testDoesNotValidateTheRequestTokenIfTheRequestAttributeIsFalse()
         $scopeMatcher = $this->createMock(ScopeMatcher::class);
         $csrfTokenManager = $this->createMock(ContaoCsrfTokenManager::class);
 
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
 
         $request->attributes->set('_token_check', false);
 
@@ -279,8 +292,7 @@ public function testDoesNotValidateTheRequestTokenIfNoRequestAttributeAndNotACon
 
         $csrfTokenManager = $this->createMock(ContaoCsrfTokenManager::class);
 
-        $request = Request::create('/account.html');
-        $request->setMethod('POST');
+        $request = $this->createPostRequest();
 
         $request->cookies = new InputBag(['unrelated-cookie' => 'to-activate-csrf']);
 
@@ -322,6 +334,32 @@ public function testDoesNotValidateTheRequestTokenIfNotAMainRequest(): void
         $listener($event);
     }
 
+    #[DataProvider('simpleCorsRequestContentTypeProvider')]
+    public function testSimpleCorsRequest(string $contentType): void
+    {
+        $request = $this->createPostRequest($contentType);
+
+        $this->assertTrue(RequestTokenListener::isSimpleCorsRequest($request));
+    }
+
+    public static function simpleCorsRequestContentTypeProvider(): array
+    {
+        return [
+            'urlencoded basic' => ['application/x-www-form-urlencoded'],
+            'urlencoded with UTF-8' => ['application/x-www-form-urlencoded; charset=UTF-8'],
+            'urlencoded with ISO' => ['application/x-www-form-urlencoded; charset=ISO-8859-1'],
+            'multipart basic' => ['multipart/form-data'],
+            'multipart with WebKit boundary' => ['multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW'],
+            'multipart with charset and custom boundary' => ['multipart/form-data; charset=UTF-8; boundary=----MyCustomBoundary12345'],
+            'text plain basic' => ['text/plain'],
+            'text plain with UTF-8' => ['text/plain; charset=UTF-8'],
+            'text plain with ISO' => ['text/plain; charset=ISO-8859-1'],
+            'text plain flowed' => ['text/plain; format=flowed'],
+            'text plain UTF-8 flowed' => ['text/plain; charset=UTF-8; format=flowed'],
+            'text plain uppercase' => ['TEXT/PLAIN'],
+        ];
+    }
+
     private function validateRequestTokenForRequest(Request $request, bool $shouldValidate = true): void
     {
         $scopeMatcher = $this->createMock(ScopeMatcher::class);
@@ -364,4 +402,9 @@ function () {
         $listener = new RequestTokenListener($scopeMatcher, $csrfTokenManager, 'contao_csrf_token');
         $listener($event);
     }
+
+    private function createPostRequest(string $contentType = 'application/x-www-form-urlencoded'): Request
+    {
+        return Request::create('/account.html', 'POST', [], [], [], ['CONTENT_TYPE' => $contentType]);
+    }
 }


Reviews:
user: login: ausi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T13:50:29Z
user: login: Toflar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T14:24:28Z
user: login: leofeyer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T15:24:48Z
user: login: aschempp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T10:11:13Z
user: login: Toflar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T10:34:10Z
user: login: leofeyer
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-24T11:16:15Z
user: login: ausi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T08:59:48Z
user: login: ausi
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T09:05:20Z

Review comments:
diff_hunk: @@ -89,4 +91,17 @@ private function getTokenFromRequest(Request $request): string|null
 
         return null;
     }
+
+    private function isSimpleCorsRequest(Request $request): bool
+    {
+        return \in_array(
+            $request->headers->get('content-type'),
+            [
+                'application/x-www-form-urlencoded',
+                'multipart/form-data',
path: core-bundle/src/EventListener/RequestTokenListener.php
user: login: ausi
body: Do we need to use `str_starts_with()` here or parse the header value as the browser sends something like `Content-Type: multipart/form-data; boundary=…` as the header value. Or does Symfony already parse this and always return `multipart/form-data` here?
created_at: 2025-03-21T13:50:29Z
author_association: MEMBER
diff_hunk: @@ -89,4 +91,17 @@ private function getTokenFromRequest(Request $request): string|null
 
         return null;
     }
+
+    private function isSimpleCorsRequest(Request $request): bool
+    {
+        return \in_array(
+            $request->headers->get('content-type'),
+            [
+                'application/x-www-form-urlencoded',
+                'multipart/form-data',
path: core-bundle/src/EventListener/RequestTokenListener.php
user: login: Toflar
body: Good input, I didn't know so I just added tests 😊 https://github.com/contao/contao/pull/8224/commits/a5df38b4cb558f07feca5c53a181892b00b8949c
created_at: 2025-03-21T14:24:28Z
author_association: MEMBER
diff_hunk: @@ -51,12 +52,14 @@ public function __invoke(RequestEvent $event): void
         // Only check the request token if
         // - the request is a POST request,
         // - the request is not an Ajax request,
+        // - the request is not a simple CORS Content-Type that does not require a preflight request,
path: core-bundle/src/EventListener/RequestTokenListener.php
user: login: leofeyer
body: ```suggestion
        // - the request is a CORS Content-Type that requires a preflight request,
```

I find it easier to understand this way. But feel free to ignore ☺️ 
created_at: 2025-03-21T15:24:48Z
author_association: MEMBER
diff_hunk: @@ -73,6 +76,21 @@ public function __invoke(RequestEvent $event): void
         throw new InvalidRequestTokenException('Invalid CSRF token. Please reload the page and try again.');
     }
 
+    public static function isSimpleCorsRequest(Request $request): bool
path: core-bundle/src/EventListener/RequestTokenListener.php
user: login: aschempp
body: sounds like something that could also be part of Symfony Request?
created_at: 2025-03-24T10:11:13Z
author_association: MEMBER
diff_hunk: @@ -73,6 +76,21 @@ public function __invoke(RequestEvent $event): void
         throw new InvalidRequestTokenException('Invalid CSRF token. Please reload the page and try again.');
     }
 
+    public static function isSimpleCorsRequest(Request $request): bool
path: core-bundle/src/EventListener/RequestTokenListener.php
user: login: Toflar
body: Yes but it's not at the moment and so that won't help us anything.
created_at: 2025-03-24T10:34:10Z
author_association: MEMBER
diff_hunk: @@ -322,6 +334,31 @@ public function testDoesNotValidateTheRequestTokenIfNotAMainRequest(): void
         $listener($event);
     }
 
+    #[DataProvider('simpleCorsRequestContentTypeProvider')]
+    public function testSimpleCorsRequest(string $contentType): void
+    {
+        $request = $this->createPostRequest($contentType);
+
+        $this->assertTrue(RequestTokenListener::isSimpleCorsRequest($request));
+    }
+
+    public static function simpleCorsRequestContentTypeProvider(): array
+    {
+        return [
+            'urlencoded basic' => ['application/x-www-form-urlencoded'],
+            'urlencoded with UTF-8' => ['application/x-www-form-urlencoded; charset=UTF-8'],
+            'urlencoded with ISO' => ['application/x-www-form-urlencoded; charset=ISO-8859-1'],
+            'multipart basic' => ['multipart/form-data'],
+            'multipart with WebKit boundary' => ['multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW'],
+            'multipart with charset and custom boundary' => ['multipart/form-data; charset=UTF-8; boundary=----MyCustomBoundary12345'],
+            'text plain basic' => ['text/plain'],
+            'text plain with UTF-8' => ['text/plain; charset=UTF-8'],
+            'text plain with ISO' => ['text/plain; charset=ISO-8859-1'],
+            'text plain flowed' => ['text/plain; format=flowed'],
+            'text plain UTF-8 flowed' => ['text/plain; charset=UTF-8; format=flowed'],
path: core-bundle/tests/EventListener/RequestTokenListenerTest.php
user: login: ausi
body: ```suggestion
            'text plain UTF-8 flowed' => ['text/plain; charset=UTF-8; format=flowed'],
            'text plain uppercase' => ['TEXT/PLAIN'],
```
created_at: 2025-03-25T08:58:39Z
author_association: MEMBER
diff_hunk: @@ -73,6 +76,21 @@ public function __invoke(RequestEvent $event): void
         throw new InvalidRequestTokenException('Invalid CSRF token. Please reload the page and try again.');
     }
 
+    public static function isSimpleCorsRequest(Request $request): bool
+    {
+        $contentType = HeaderUtils::split($request->headers->get('content-type'), ';')[0] ?? '';
+
+        return \in_array(
+            $contentType,
path: core-bundle/src/EventListener/RequestTokenListener.php
user: login: ausi
body: ```suggestion
            strtolower($contentType),
```
According to https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.1 the content type is case-insensitive
created_at: 2025-03-25T08:59:37Z
author_association: MEMBER

PR comments:
user: login: leofeyer
created_at: 2025-03-24T11:14:15Z
body: @Toflar Is this really a new feature or rather a bugfix?
author_association: MEMBER
user: login: Toflar
created_at: 2025-03-24T11:15:12Z
body: Yes, there is no bug at the moment, it's just something that eases development but it's not a requirement.
author_association: MEMBER
user: login: leofeyer
created_at: 2025-03-25T09:42:11Z
body: Thank you @Toflar.
author_association: MEMBER