Issue #3085360 by bradjones1, josephdpurcell, Giuseppe87, ravi.shankar, rajandro, ridhimaabrol24, bbrala, andregp, jhedstrom: RouteProvider::getRouteCollectionForRequest() can poison query string of next request

merge-requests/7800/merge
catch 2024-05-30 10:41:23 +01:00
parent d7f114cd9a
commit 8b19e42873
8 changed files with 229 additions and 7 deletions

View File

@ -453,6 +453,8 @@ class RouteProvider implements CacheableRouteProviderInterface, PreloadableRoute
// based on the domain.
$this->addExtraCacheKeyPart('language', $this->getCurrentLanguageCacheIdPart());
$this->addExtraCacheKeyPart('query_parameters', $this->getQueryParametersCacheIdPart($request));
// Sort the cache key parts by their provider in order to have predictable
// cache keys.
ksort($this->extraCacheKeyParts);
@ -461,7 +463,52 @@ class RouteProvider implements CacheableRouteProviderInterface, PreloadableRoute
$key_parts[] = '[' . $provider . ']=' . $key_part;
}
return 'route:' . implode(':', $key_parts) . ':' . $request->getPathInfo() . ':' . $request->getQueryString();
return 'route:' . implode(':', $key_parts) . ':' . $request->getPathInfo();
}
/**
* Returns the query parameters identifier for the route collection cache.
*
* The query parameters on the request may be altered programmatically, e.g.
* while serving private files or in subrequests. As such, we must vary on
* both the query string from the client and the parameter bag after incoming
* route processors have modified the request object.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* Request.
*
* @return string
*/
protected function getQueryParametersCacheIdPart(Request $request) {
// @todo Use \Symfony\Component\HttpFoundation\Request::normalizeQueryString
// for recursive key ordering if support is added in the future.
$recursive_sort = function (&$array) use (&$recursive_sort) {
foreach ($array as &$v) {
if (is_array($v)) {
$recursive_sort($v);
}
}
ksort($array);
};
// Recursively normalize the query parameters to ensure maximal cache hits.
// If we did not normalize the order, functionally identical query string
// sets could be sent in differing order creating a potential DoS vector
// and decreasing cache hit rates.
$sorted_resolved_parameters = $request->query->all();
$recursive_sort($sorted_resolved_parameters);
$sorted_original_parameters = Request::create('/?' . $request->getQueryString())->query->all();
$recursive_sort($sorted_original_parameters);
// Hash this portion to help shorten the total key length.
$resolved_hash = $sorted_resolved_parameters
? sha1(http_build_query($sorted_resolved_parameters))
: NULL;
return implode(
',',
array_filter([
http_build_query($sorted_original_parameters),
$resolved_hash,
])
);
}
/**

View File

@ -0,0 +1,25 @@
<?php
/**
* @file
* Test module.
*/
use Drupal\Core\Url;
/**
* Implements hook_preprocess_HOOK().
*
* Performs an operation that calls the RouteProvider's collection method
* during an exception page view. (which is rendered during a subrequest.)
*
* @see \Drupal\FunctionalTests\Routing\RouteCachingQueryAlteredTest
*/
function router_test_preprocess_page(&$variables) {
$request = \Drupal::request();
if ($request->getPathInfo() === '/router-test/rejects-query-strings') {
// Create a URL from the request, e.g. for a breadcrumb or other contextual
// information.
Url::createFromRequest($request);
}
}

View File

@ -247,3 +247,10 @@ router_test.case_sensitive_duplicate3:
_controller: '\Drupal\router_test\TestControllers::testRouteName'
requirements:
_access: 'TRUE'
router_test.rejects_query_strings:
path: '/router-test/rejects-query-strings'
defaults:
_controller: '\Drupal\router_test\TestControllers::rejectsQueryStrings'
requirements:
_access: 'TRUE'

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Drupal\router_test;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Event subscribers for exceptions thrown in early kernel middleware.
*/
class RouterTestEarlyExceptionSubscriber implements EventSubscriberInterface {
/**
* Throw an exception, which will trigger exception-handling subscribers.
*
* See DefaultExceptionHtmlSubscriber.
*/
public function onKernelRequest(RequestEvent $event): void {
if ($event->isMainRequest() && $event->getRequest()->headers->get('Authorization') === 'Bearer invalid') {
throw new HttpException(
Response::HTTP_UNAUTHORIZED,
'This is a common exception during authentication.'
);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// This is the same priority as AuthenticationSubscriber, however
// exceptions are not restricted to authentication; this is a common,
// early point to emulate an exception, e.g. when an OAuth token is
// rejected.
$events[KernelEvents::REQUEST][] = ['onKernelRequest', 300];
return $events;
}
}

View File

@ -14,9 +14,12 @@ class RouterTestServiceProvider implements ServiceProviderInterface {
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
$container->register('router_test.subscriber', 'Drupal\router_test\RouteTestSubscriber')->addTag('event_subscriber');
$container->register('router_test.subscriber', 'Drupal\router_test\RouteTestSubscriber')
->addTag('event_subscriber');
$container->register('access_check.router_test', 'Drupal\router_test\Access\TestAccessCheck')
->addTag('access_check', ['applies_to' => '_access_router_test']);
$container->register('router_test.early_exception.subscriber', 'Drupal\router_test\RouterTestEarlyExceptionSubscriber')
->addTag('event_subscriber');
}
}

View File

@ -118,6 +118,19 @@ class TestControllers {
];
}
/**
* Rejects requests with query keys.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The given request.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response.
*/
public function rejectsQueryStrings(Request $request) {
return new Response('', $request->query->keys() ? Response::HTTP_BAD_REQUEST : Response::HTTP_OK);
}
/**
* Throws an exception.
*

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Drupal\FunctionalTests\Routing;
use Drupal\Tests\BrowserTestBase;
use Symfony\Component\HttpFoundation\Response;
/**
* Tests the route cache when the request's query parameters are altered.
*
* This happens either in the normal course of operations or due to an
* exception.
*
* @group routing
*/
class RouteCachingQueryAlteredTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['router_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// page_cache module is enabled in the testing profile, however by default
// exceptions which create 4xx responses are cached for 1 hour. This is
// undesirable for certain response types (e.g., 401) which vary on other
// elements of the request than the URL. For this reason, do not cache 4xx
// responses for the purposes of this test.
$settings['settings']['cache_ttl_4xx'] = (object) [
'value' => 0,
'required' => TRUE,
];
$this->writeSettings($settings);
}
/**
* Tests route collection cache after an exception.
*/
public function testRouteCollectionCacheAfterException() {
// Force an exception early in the Kernel middleware on a cold cache by
// simulating bad Bearer authentication.
$this->drupalGet('/router-test/rejects-query-strings', [], [
'Authorization' => 'Bearer invalid',
]);
$this->assertSession()->statusCodeEquals(Response::HTTP_UNAUTHORIZED);
// Check that the route collection cache does not recover any unexpected
// query strings from the earlier request that involved an exception.
// The requested controller returns 400 if there are any query parameters
// present, similar to JSON:API paths that strictly filter requests.
$this->drupalGet('/router-test/rejects-query-strings', [], [
'Authorization' => 'Bearer valid',
]);
$this->assertSession()->statusCodeEquals(Response::HTTP_OK);
}
}

View File

@ -584,7 +584,7 @@ class RouteProviderTest extends KernelTestBase {
$request = Request::create($path, 'GET');
$provider->getRouteCollectionForRequest($request);
$cache = $this->cache->get('route:[language]=en:/path/add/one:');
$cache = $this->cache->get('route:[language]=en:[query_parameters]=:/path/add/one');
$this->assertEquals('/path/add/one', $cache->data['path']);
$this->assertEquals([], $cache->data['query']);
$this->assertCount(3, $cache->data['routes']);
@ -594,17 +594,33 @@ class RouteProviderTest extends KernelTestBase {
$request = Request::create($path, 'GET');
$provider->getRouteCollectionForRequest($request);
$cache = $this->cache->get('route:[language]=en:/path/add/one:foo=bar');
$cache = $this->cache->get('route:[language]=en:[query_parameters]=foo=bar,2fb8f40115dd1e695cbe23d4f97ce5b1fb697eee:/path/add/one');
$this->assertEquals('/path/add/one', $cache->data['path']);
$this->assertEquals(['foo' => 'bar'], $cache->data['query']);
$this->assertCount(3, $cache->data['routes']);
// A path with multivalued query parameters.
$path = '/path/add/one?foo=bar&foo2[]=bar2&foo2[]=bar3';
$request = Request::create($path, 'GET');
$provider->getRouteCollectionForRequest($request);
$cache = $this->cache->get('route:[language]=en:[query_parameters]=foo=bar&foo2%5B0%5D=bar2&foo2%5B1%5D=bar3,2d3a0851c4970a16be1c851a0e9946e6d10a3fe2:/path/add/one');
$this->assertEquals('/path/add/one', $cache->data['path']);
$this->assertEquals(
[
'foo' => 'bar',
'foo2' => ['bar2', 'bar3'],
],
$cache->data['query']
);
$this->assertCount(3, $cache->data['routes']);
// A path with placeholders.
$path = '/path/1/one';
$request = Request::create($path, 'GET');
$provider->getRouteCollectionForRequest($request);
$cache = $this->cache->get('route:[language]=en:/path/1/one:');
$cache = $this->cache->get('route:[language]=en:[query_parameters]=:/path/1/one');
$this->assertEquals('/path/1/one', $cache->data['path']);
$this->assertEquals([], $cache->data['query']);
$this->assertCount(2, $cache->data['routes']);
@ -619,7 +635,7 @@ class RouteProviderTest extends KernelTestBase {
$request = Request::create($path, 'GET');
$provider->getRouteCollectionForRequest($request);
$cache = $this->cache->get('route:[language]=en:/path/add-one:');
$cache = $this->cache->get('route:[language]=en:[query_parameters]=:/path/add-one');
$this->assertEquals('/path/add/one', $cache->data['path']);
$this->assertEquals([], $cache->data['query']);
$this->assertCount(3, $cache->data['routes']);
@ -634,7 +650,7 @@ class RouteProviderTest extends KernelTestBase {
$request = Request::create($path, 'GET');
$provider->getRouteCollectionForRequest($request);
$cache = $this->cache->get('route:[language]=gsw-berne:/path/add-one:');
$cache = $this->cache->get('route:[language]=gsw-berne:[query_parameters]=:/path/add-one');
$this->assertEquals('/path/add/one', $cache->data['path']);
$this->assertEquals([], $cache->data['query']);
$this->assertCount(3, $cache->data['routes']);