Issue #3485174 by fago, arthur_lorenz, alexpott, smustgrave: Menu APIs provide invalid CSRF tokens

merge-requests/10999/merge
Alex Pott 2025-01-28 10:36:50 +00:00
parent 2f09ecf6fa
commit ec91b0d0cd
No known key found for this signature in database
GPG Key ID: BDA67E7EE836E5CE
4 changed files with 71 additions and 20 deletions

View File

@ -1457,7 +1457,7 @@ services:
class: Drupal\Core\Access\RouteProcessorCsrf class: Drupal\Core\Access\RouteProcessorCsrf
tags: tags:
- { name: route_processor_outbound } - { name: route_processor_outbound }
arguments: ['@csrf_token'] arguments: ['@csrf_token', '@request_stack']
transliteration: transliteration:
class: Drupal\Core\Transliteration\PhpTransliteration class: Drupal\Core\Transliteration\PhpTransliteration
arguments: [null, '@module_handler'] arguments: [null, '@module_handler']

View File

@ -6,6 +6,7 @@ use Drupal\Component\Utility\Crypt;
use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface; use Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Route;
/** /**
@ -13,21 +14,21 @@ use Symfony\Component\Routing\Route;
*/ */
class RouteProcessorCsrf implements OutboundRouteProcessorInterface, TrustedCallbackInterface { class RouteProcessorCsrf implements OutboundRouteProcessorInterface, TrustedCallbackInterface {
/**
* The CSRF token generator.
*
* @var \Drupal\Core\Access\CsrfTokenGenerator
*/
protected $csrfToken;
/** /**
* Constructs a RouteProcessorCsrf object. * Constructs a RouteProcessorCsrf object.
* *
* @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token * @param \Drupal\Core\Access\CsrfTokenGenerator $csrfToken
* The CSRF token generator. * The CSRF token generator.
* @param \Symfony\Component\HttpFoundation\RequestStack|null $requestStack
* The request stack.
*/ */
public function __construct(CsrfTokenGenerator $csrf_token) { public function __construct(
$this->csrfToken = $csrf_token; protected CsrfTokenGenerator $csrfToken,
protected ?RequestStack $requestStack = NULL,
) {
if ($requestStack === NULL) {
$this->requestStack = \Drupal::service('request_stack');
}
} }
/** /**
@ -42,7 +43,7 @@ class RouteProcessorCsrf implements OutboundRouteProcessorInterface, TrustedCall
} }
// Adding this to the parameters means it will get merged into the query // Adding this to the parameters means it will get merged into the query
// string when the route is compiled. // string when the route is compiled.
if (!$bubbleable_metadata) { if (!$bubbleable_metadata || $this->requestStack->getCurrentRequest()->getRequestFormat() !== 'html') {
$parameters['token'] = $this->csrfToken->get($path); $parameters['token'] = $this->csrfToken->get($path);
} }
else { else {
@ -51,7 +52,6 @@ class RouteProcessorCsrf implements OutboundRouteProcessorInterface, TrustedCall
$placeholder_render_array = [ $placeholder_render_array = [
'#lazy_builder' => ['route_processor_csrf:renderPlaceholderCsrfToken', [$path]], '#lazy_builder' => ['route_processor_csrf:renderPlaceholderCsrfToken', [$path]],
]; ];
// Instead of setting an actual CSRF token as the query string, we set // 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 // the placeholder, which will be replaced at the very last moment. This
// ensures links with CSRF tokens don't break cacheability. // ensures links with CSRF tokens don't break cacheability.

View File

@ -103,6 +103,7 @@ class MenuLinksetRoutes extends RouteSubscriberBase implements ContainerInjectio
], ],
[ [
'_access' => 'TRUE', '_access' => 'TRUE',
'_format' => 'json',
], ],
[ [
'parameters' => [ 'parameters' => [

View File

@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Drupal\Tests\Core\Access; namespace Drupal\Tests\Core\Access;
use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\Crypt;
use Drupal\Core\Access\CsrfTokenGenerator;
use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Tests\UnitTestCase; use Drupal\Tests\UnitTestCase;
use Drupal\Core\Access\RouteProcessorCsrf; use Drupal\Core\Access\RouteProcessorCsrf;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Route;
/** /**
@ -18,17 +21,18 @@ class RouteProcessorCsrfTest extends UnitTestCase {
/** /**
* The mock CSRF token generator. * 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. * The route processor.
*
* @var \Drupal\Core\Access\RouteProcessorCsrf
*/ */
protected $processor; protected RouteProcessorCsrf $processor;
/** /**
* {@inheritdoc} * {@inheritdoc}
@ -40,7 +44,20 @@ class RouteProcessorCsrfTest extends UnitTestCase {
->disableOriginalConstructor() ->disableOriginalConstructor()
->getMock(); ->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 @@ class RouteProcessorCsrfTest extends UnitTestCase {
$this->assertEquals((new BubbleableMetadata())->setAttachments(['placeholders' => [$placeholder => $placeholder_render_array]]), $bubbleable_metadata); $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']);
}
} }