From ae6992293d0e33f8a90077918c6d3c71f2835b5f Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Tue, 28 Jan 2025 10:35:07 +0000
Subject: [PATCH] Issue #3485174 by fago, arthur_lorenz, alexpott, smustgrave:
 Menu APIs provide invalid CSRF tokens

---
 core/core.services.yml                        |  2 +-
 .../Drupal/Core/Access/RouteProcessorCsrf.php | 25 ++++----
 .../system/src/Routing/MenuLinksetRoutes.php  |  1 +
 .../Core/Access/RouteProcessorCsrfTest.php    | 64 +++++++++++++++++--
 4 files changed, 72 insertions(+), 20 deletions(-)

diff --git a/core/core.services.yml b/core/core.services.yml
index 2dff154d821b..478329f97e07 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1457,7 +1457,7 @@ services:
     class: Drupal\Core\Access\RouteProcessorCsrf
     tags:
       - { name: route_processor_outbound }
-    arguments: ['@csrf_token']
+    arguments: ['@csrf_token', '@request_stack']
   transliteration:
     class: Drupal\Core\Transliteration\PhpTransliteration
     arguments: [null, '@module_handler']
diff --git a/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
index 6b70d7c63c30..15baa36f4911 100644
--- a/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
+++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Security\TrustedCallbackInterface;
 use Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
 use Symfony\Component\Routing\Route;
 
 /**
@@ -13,21 +14,22 @@
  */
 class RouteProcessorCsrf implements OutboundRouteProcessorInterface, TrustedCallbackInterface {
 
-  /**
-   * The CSRF token generator.
-   *
-   * @var \Drupal\Core\Access\CsrfTokenGenerator
-   */
-  protected $csrfToken;
-
   /**
    * Constructs a RouteProcessorCsrf object.
    *
-   * @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
+   * @param \Drupal\Core\Access\CsrfTokenGenerator $csrfToken
    *   The CSRF token generator.
+   * @param \Symfony\Component\HttpFoundation\RequestStack|null $requestStack
+   *   The request stack.
    */
-  public function __construct(CsrfTokenGenerator $csrf_token) {
-    $this->csrfToken = $csrf_token;
+  public function __construct(
+    protected CsrfTokenGenerator $csrfToken,
+    protected ?RequestStack $requestStack = NULL,
+  ) {
+    if ($requestStack === NULL) {
+      @trigger_error('Calling ' . __CLASS__ . ' constructor without the $requestStack argument is deprecated in drupal:11.2.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/project/drupal/issues/3485174', E_USER_DEPRECATED);
+      $this->requestStack = \Drupal::service('request_stack');
+    }
   }
 
   /**
@@ -42,7 +44,7 @@ public function processOutbound($route_name, Route $route, array &$parameters, ?
       }
       // Adding this to the parameters means it will get merged into the query
       // string when the route is compiled.
-      if (!$bubbleable_metadata) {
+      if (!$bubbleable_metadata || $this->requestStack->getCurrentRequest()->getRequestFormat() !== 'html') {
         $parameters['token'] = $this->csrfToken->get($path);
       }
       else {
@@ -51,7 +53,6 @@ public function processOutbound($route_name, Route $route, array &$parameters, ?
         $placeholder_render_array = [
           '#lazy_builder' => ['route_processor_csrf:renderPlaceholderCsrfToken', [$path]],
         ];
-
         // Instead of setting an actual CSRF token as the query string, we set
         // the placeholder, which will be replaced at the very last moment. This
         // ensures links with CSRF tokens don't break cacheability.
diff --git a/core/modules/system/src/Routing/MenuLinksetRoutes.php b/core/modules/system/src/Routing/MenuLinksetRoutes.php
index bbafef4971ad..72d0c2910193 100644
--- a/core/modules/system/src/Routing/MenuLinksetRoutes.php
+++ b/core/modules/system/src/Routing/MenuLinksetRoutes.php
@@ -103,6 +103,7 @@ public function routes() {
         ],
         [
           '_access' => 'TRUE',
+          '_format' => 'json',
         ],
         [
           'parameters' => [
diff --git a/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php b/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php
index 621d79557c0a..c4d6d6d221c6 100644
--- a/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php
+++ b/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php
@@ -5,9 +5,12 @@
 namespace Drupal\Tests\Core\Access;
 
 use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Access\CsrfTokenGenerator;
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Tests\UnitTestCase;
 use Drupal\Core\Access\RouteProcessorCsrf;
+use PHPUnit\Framework\MockObject\MockObject;
+use Symfony\Component\HttpFoundation\RequestStack;
 use Symfony\Component\Routing\Route;
 
 /**
@@ -18,17 +21,18 @@ class RouteProcessorCsrfTest extends UnitTestCase {
 
   /**
    * The mock CSRF token generator.
-   *
-   * @var \Drupal\Core\Access\CsrfTokenGenerator|\PHPUnit\Framework\MockObject\MockObject
    */
-  protected $csrfToken;
+  protected CsrfTokenGenerator&MockObject $csrfToken;
+
+  /**
+   * The mock request stack.
+   */
+  protected RequestStack&MockObject $requestStack;
 
   /**
    * The route processor.
-   *
-   * @var \Drupal\Core\Access\RouteProcessorCsrf
    */
-  protected $processor;
+  protected RouteProcessorCsrf $processor;
 
   /**
    * {@inheritdoc}
@@ -40,7 +44,20 @@ protected function setUp(): void {
       ->disableOriginalConstructor()
       ->getMock();
 
-    $this->processor = new RouteProcessorCsrf($this->csrfToken);
+    $this->requestStack = $this->getMockBuilder('Symfony\Component\HttpFoundation\RequestStack')
+      ->disableOriginalConstructor()
+      ->getMock();
+
+    $request = $this->createMock('Symfony\Component\HttpFoundation\Request');
+    $request->expects($this->any())
+      ->method('getRequestFormat')
+      ->willReturn('html');
+
+    $this->requestStack->expects($this->any())
+      ->method('getCurrentRequest')
+      ->willReturn($request);
+
+    $this->processor = new RouteProcessorCsrf($this->csrfToken, $this->requestStack);
   }
 
   /**
@@ -122,4 +139,37 @@ public function testProcessOutboundDynamicTwo(): void {
     $this->assertEquals((new BubbleableMetadata())->setAttachments(['placeholders' => [$placeholder => $placeholder_render_array]]), $bubbleable_metadata);
   }
 
+  /**
+   * Tests JSON requests to get no placeholders, but real tokens.
+   */
+  public function testProcessOutboundJsonFormat(): void {
+    // Create a new request mock that returns 'json' format.
+    $request = $this->createMock('Symfony\Component\HttpFoundation\Request');
+    $request->expects($this->once())
+      ->method('getRequestFormat')
+      ->willReturn('json');
+    $this->requestStack = $this->createMock('Symfony\Component\HttpFoundation\RequestStack');
+    $this->requestStack->expects($this->once())
+      ->method('getCurrentRequest')
+      ->willReturn($request);
+
+    // Mock that the CSRF token service should be called once with 'test-path'
+    // and return a test token.
+    $this->csrfToken->expects($this->any())
+      ->method('get')
+      ->with('test-path')
+      ->willReturn('real_token_value');
+
+    $this->processor = new RouteProcessorCsrf($this->csrfToken, $this->requestStack);
+
+    $route = new Route('/test-path', [], ['_csrf_token' => 'TRUE']);
+    $parameters = [];
+    // For JSON requests, the actual CSRF token should be in parameters,
+    // regardless of whether cache metadata is present.
+    $this->processor->processOutbound('test', $route, $parameters);
+    $this->assertEquals('real_token_value', $parameters['token']);
+    $this->processor->processOutbound('test', $route, $parameters, new BubbleableMetadata());
+    $this->assertEquals('real_token_value', $parameters['token']);
+  }
+
 }
-- 
GitLab