diff --git a/core/modules/big_pipe/big_pipe.services.yml b/core/modules/big_pipe/big_pipe.services.yml index e1080f084dce0053d127c9fed5dbfe33b0bce024..22d937a3d8ff06e2282213ff8d44035122623ca9 100644 --- a/core/modules/big_pipe/big_pipe.services.yml +++ b/core/modules/big_pipe/big_pipe.services.yml @@ -11,7 +11,7 @@ services: - { name: placeholder_strategy, priority: 0 } big_pipe: class: Drupal\big_pipe\Render\BigPipe - arguments: ['@renderer', '@session', '@request_stack', '@http_kernel', '@event_dispatcher', '@config.factory', '@messenger'] + arguments: ['@renderer', '@session', '@request_stack', '@http_kernel', '@event_dispatcher', '@config.factory', '@messenger', '@router.request_context', '@logger.channel.php'] Drupal\big_pipe\Render\BigPipe: '@big_pipe' html_response.attachments_processor.big_pipe: public: false diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php index f703ebb3645d4787ebd48e0488069c2ded94ab87..ac85ec4b620beac03613304962ed925c95963e42 100644 --- a/core/modules/big_pipe/src/Render/BigPipe.php +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -2,17 +2,24 @@ namespace Drupal\big_pipe\Render; +use Drupal\Component\HttpFoundation\SecuredRedirectResponse; use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\Html; use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\MessageCommand; +use Drupal\Core\Ajax\RedirectCommand; use Drupal\Core\Ajax\ReplaceCommand; use Drupal\Core\Asset\AttachedAssets; use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Form\EnforcedResponseException; use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Render\HtmlResponse; use Drupal\Core\Render\RendererInterface; +use Drupal\Core\Routing\LocalRedirectResponse; +use Drupal\Core\Routing\RequestContext; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -173,6 +180,8 @@ public function __construct( protected EventDispatcherInterface $eventDispatcher, protected ConfigFactoryInterface $configFactory, protected MessengerInterface $messenger, + protected RequestContext $requestContext, + protected LoggerInterface $logger, ) { } @@ -553,6 +562,53 @@ protected function sendPlaceholders(array $placeholders, array $placeholder_orde $cumulative_assets->setAlreadyLoadedLibraries(explode(',', $ajax_response->getAttachments()['drupalSettings']['ajaxPageState']['libraries'])); } } + // Handle enforced redirect responses. + // A typical use case where this might happen are forms using GET as + // #method that are build inside a lazy builder. + catch (EnforcedResponseException $e) { + $response = $e->getResponse(); + if (!$response instanceof RedirectResponse) { + throw $e; + } + $ajax_response = new AjaxResponse(); + if ($response instanceof SecuredRedirectResponse) { + // Only redirect to safe locations. + $ajax_response->addCommand(new RedirectCommand($response->getTargetUrl())); + } + else { + try { + // SecuredRedirectResponse is an abstract class that requires a + // concrete implementation. Default to LocalRedirectResponse, which + // considers only redirects to within the same site as safe. + $safe_response = LocalRedirectResponse::createFromRedirectResponse($response); + $safe_response->setRequestContext($this->requestContext); + $ajax_response->addCommand(new RedirectCommand($safe_response->getTargetUrl())); + } + catch (\InvalidArgumentException) { + // If the above failed, it's because the redirect target wasn't + // local. Do not follow that redirect. Log an error message + // instead, then return a 400 response to the client with the + // error message. We don't throw an exception, because this is a + // client error rather than a server error. + $message = 'Redirects to external URLs are not allowed by default, use \Drupal\Core\Routing\TrustedRedirectResponse for it.'; + $this->logger->error($message); + $ajax_response->addCommand(new MessageCommand($message)); + } + } + $ajax_response = $this->filterEmbeddedResponse($fake_request, $ajax_response); + + $json = $ajax_response->getContent(); + $output = <<<EOF +<script type="application/vnd.drupal-ajax" data-big-pipe-replacement-for-placeholder-with-id="$placeholder_id"> +$json +</script> +EOF; + $this->sendChunk($output); + + // Send the stop signal. + $this->sendChunk("\n" . static::STOP_SIGNAL . "\n"); + break; + } catch (\Exception $e) { unset($fibers[$placeholder_id]); if ($this->configFactory->get('system.logging')->get('error_level') === ERROR_REPORTING_DISPLAY_VERBOSE) { diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml index 8edbcec487b456f8cfffaf8f1e9c884eaea9a160..a1ecd4612f8f8dd7a93fe8f45a474f7593b62278 100644 --- a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml @@ -31,3 +31,19 @@ big_pipe_test_preview: _title: 'Test placeholder previews' requirements: _access: 'TRUE' + +big_pipe_test_trusted_redirect: + path: '/big_pipe_test_trusted_redirect' + defaults: + _controller: '\Drupal\big_pipe_test\BigPipeTestController::trustedRedirectLazyBuilder' + _title: 'BigPipe test trusted redirect' + requirements: + _access: 'TRUE' + +big_pipe_test_untrusted_redirect: + path: '/big_pipe_test_untrusted_redirect' + defaults: + _controller: '\Drupal\big_pipe_test\BigPipeTestController::untrustedRedirectLazyBuilder' + _title: 'BigPipe test untrusted redirect' + requirements: + _access: 'TRUE' diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php index db4d7c0e9db29b93aa7328ac6404d09f4f2d2639..96592071ee60e676e876c0df1d873ea68d3b3d1f 100644 --- a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php @@ -4,7 +4,9 @@ use Drupal\big_pipe\Render\BigPipeMarkup; use Drupal\big_pipe_test\EventSubscriber\BigPipeTestSubscriber; +use Drupal\Core\Form\EnforcedResponseException; use Drupal\Core\Security\TrustedCallbackInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; /** * Returns responses for Big Pipe routes. @@ -213,11 +215,63 @@ public static function counter() { ]; } + /** + * Route callback to test a trusted lazy builder redirect response. + * + * @return array + * The lazy builder callback. + */ + public function trustedRedirectLazyBuilder(): array { + return [ + 'redirect' => [ + '#lazy_builder' => [static::class . '::redirectTrusted', []], + '#create_placeholder' => TRUE, + ], + ]; + } + + /** + * Supports Big Pipe testing of the enforced redirect response. + * + * @throws \Drupal\Core\Form\EnforcedResponseException + * Trigger catch of Big Pipe enforced redirect response exception. + */ + public static function redirectTrusted(): void { + $response = new RedirectResponse('/big_pipe_test'); + throw new EnforcedResponseException($response); + } + + /** + * Route callback to test an untrusted lazy builder redirect response. + * + * @return array + * The lazy builder callback. + */ + public function untrustedRedirectLazyBuilder(): array { + return [ + 'redirect' => [ + '#lazy_builder' => [static::class . '::redirectUntrusted', []], + '#create_placeholder' => TRUE, + ], + ]; + } + + /** + * Supports Big Pipe testing of an untrusted external URL. + * + * @throws \Drupal\Core\Form\EnforcedResponseException + * Trigger catch of Big Pipe enforced redirect response exception. + */ + public static function redirectUntrusted(): void { + $response = new RedirectResponse('https://example.com'); + throw new EnforcedResponseException($response); + } + /** * {@inheritdoc} */ public static function trustedCallbacks() { - return ['currentTime', 'piggy', 'helloOrHi', 'exception', 'responseException', 'counter']; + return ['currentTime', 'piggy', 'helloOrHi', 'exception', 'responseException', 'counter', 'redirectTrusted', 'redirectUntrusted']; } } diff --git a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php index 08cabf578218faf02f29cfb39f9ff4fb002f2871..b7b197b968f7a2c2d26b73dd146b6ae32c07ba46 100644 --- a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php +++ b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php @@ -219,6 +219,16 @@ public function testBigPipe(): void { $this->assertSession()->responseNotContains('</body>'); // The exception is expected. Do not interpret it as a test failure. unlink($this->root . '/' . $this->siteDirectory . '/error.log'); + + // Tests the enforced redirect response exception handles redirecting to + // a trusted redirect. + $this->drupalGet(Url::fromRoute('big_pipe_test_trusted_redirect')); + $this->assertSession()->responseContains('application/vnd.drupal-ajax'); + $this->assertSession()->responseContains('[{"command":"redirect","url":"\/big_pipe_test"}]'); + + // Test that it rejects an untrusted redirect. + $this->drupalGet(Url::fromRoute('big_pipe_test_untrusted_redirect')); + $this->assertSession()->responseContains('Redirects to external URLs are not allowed by default'); } /** diff --git a/core/modules/big_pipe/tests/src/Unit/Render/FiberPlaceholderTest.php b/core/modules/big_pipe/tests/src/Unit/Render/FiberPlaceholderTest.php index 6e1ce3943ac081342f7bb598de3d5688a8f07f8c..644ed8eb051800b8ae3431301f4144ad8f661070 100644 --- a/core/modules/big_pipe/tests/src/Unit/Render/FiberPlaceholderTest.php +++ b/core/modules/big_pipe/tests/src/Unit/Render/FiberPlaceholderTest.php @@ -13,11 +13,13 @@ use Drupal\Core\Render\PlaceholderGeneratorInterface; use Drupal\Core\Render\RenderCacheInterface; use Drupal\Core\Render\Renderer; +use Drupal\Core\Routing\RequestContext; use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Theme\ThemeManagerInterface; use Drupal\Core\Utility\CallableResolver; use Drupal\Tests\UnitTestCase; use Prophecy\Argument; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -69,6 +71,8 @@ public function testLongPlaceholderFiberSuspendingLoop(): void { $this->createMock(EventDispatcherInterface::class), $this->prophesize(ConfigFactoryInterface::class)->reveal(), $this->prophesize(MessengerInterface::class)->reveal(), + $this->prophesize(RequestContext::class)->reveal(), + $this->prophesize(LoggerInterface::class)->reveal(), ); $response = new BigPipeResponse(new HtmlResponse()); diff --git a/core/modules/big_pipe/tests/src/Unit/Render/ManyPlaceholderTest.php b/core/modules/big_pipe/tests/src/Unit/Render/ManyPlaceholderTest.php index 7446def8ce056797b8f583f4ace2ee68da9d85b4..63fed38f5d7869dd127e6427cff54a24d0b07233 100644 --- a/core/modules/big_pipe/tests/src/Unit/Render/ManyPlaceholderTest.php +++ b/core/modules/big_pipe/tests/src/Unit/Render/ManyPlaceholderTest.php @@ -10,7 +10,9 @@ use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Render\HtmlResponse; use Drupal\Core\Render\RendererInterface; +use Drupal\Core\Routing\RequestContext; use Drupal\Tests\UnitTestCase; +use Psr\Log\LoggerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -36,7 +38,9 @@ public function testManyNoJsPlaceHolders(): void { $this->prophesize(HttpKernelInterface::class)->reveal(), $this->prophesize(EventDispatcherInterface::class)->reveal(), $this->prophesize(ConfigFactoryInterface::class)->reveal(), - $this->prophesize(MessengerInterface::class)->reveal() + $this->prophesize(MessengerInterface::class)->reveal(), + $this->prophesize(RequestContext::class)->reveal(), + $this->prophesize(LoggerInterface::class)->reveal(), ); $response = new BigPipeResponse(new HtmlResponse());