diff -u b/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php --- b/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php @@ -7,18 +7,15 @@ namespace Drupal\Core\EventSubscriber; +use Drupal\Component\HttpFoundation\SafeRedirectResponse; use Drupal\Component\Utility\UrlHelper; -use Drupal\Core\Routing\RedirectResponseAllowExternalUrl; +use Drupal\Core\Routing\LocalRedirectResponse; use Drupal\Core\Routing\RequestContext; -use Drupal\Core\Routing\TrustedRedirectResponse; use Drupal\Core\Routing\UrlGeneratorInterface; -use InvalidArgumentException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseEvent; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; -use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -56,78 +53,74 @@ public function checkRedirectUrl(FilterResponseEvent $event) { $response = $event->getResponse(); if ($response instanceOf RedirectResponse) { - $options = array(); - $request = $event->getRequest(); + + // Let the 'destination' query parameter override the redirect target. + // If $response is already a SafeRedirectResponse, it might reject the + // new target as invalid, in which case proceed with the old target. $destination = $request->query->get('destination'); - // A destination from \Drupal::request()->query always overrides the - // current RedirectResponse. We do not allow absolute URLs to be passed - // via \Drupal::request()->query, as this can be an attack vector, with - // the following exception: - // - Absolute URLs that point to this site (i.e. same base URL and - // base path) are allowed. - try { - if ($destination) { - if (!UrlHelper::isExternal($destination)) { - // The destination query parameter can be a relative URL in the sense - // of not including the scheme and host, but its path is expected to - // be absolute (start with a '/'). For such a case, prepend the - // scheme and host, because the 'Location' header must be absolute. - if (strpos($destination, '/') === 0) { - $destination = $request->getSchemeAndHttpHost() . $destination; - } - else { - // Legacy destination query parameters can be relative paths that - // have not yet been converted to URLs (outbound path processors - // and other URL handling still needs to be performed). - // @todo As generateFromPath() is deprecated, remove this in - // https://www.drupal.org/node/2418219. - $destination = UrlHelper::parse($destination); - $path = $destination['path']; - $options['query'] = $destination['query']; - $options['fragment'] = $destination['fragment']; - // The 'Location' HTTP header must always be absolute. - $options['absolute'] = TRUE; - $destination = $this->urlGenerator->generateFromPath($path, $options); - } - $response->setTargetUrl($destination); - } - elseif (UrlHelper::externalIsLocal($destination, $this->requestContext->getCompleteBaseUrl())) { - $response->setTargetUrl($destination); - } + if ($destination) { + // The 'Location' HTTP header must always be absolute. + $destination = $this->getDestinationAsAbsoluteUrl($destination, $request->getSchemeAndHttpHost()); + try { + $response->setTargetUrl($destination); } - - // In case someone setup a custom RedirectResponse object ensure that the - // URL is not external. In case this is wanted, check for a custom flag - // on RedirectResponse. - $url = $response->getTargetUrl(); - if (!$response instanceof TrustedRedirectResponse && UrlHelper::isExternal($url) && !UrlHelper::externalIsLocal($url, $this->requestContext->getCompleteBaseUrl())) { - $this->setBadRequestException($event, 'Redirects to external URLs are not allowed by default, use \Drupal\Core\Routing\RedirectResponseAllowExternalUrl for it'); + catch (\InvalidArgumentException $e) { } } - // Potentially thrown by UrlHelper::externalIsLocal(). - catch (\InvalidArgumentException $e) { - $this->setBadRequestException($event, $e->getMessage()); + + // Regardless of whether the target is the original one or the overridden + // destination, ensure that all redirects are safe. + if (!($response instanceOf SafeRedirectResponse)) { + try { + // SafeRedirectResponse 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); + } + catch (\InvalidArgumentException $e) { + // If the above failed, it's because the redirect target wasn't + // local. Do not follow that redirect. Display an error message + // instead. We're already catching one exception, so trigger_error() + // rather than throw another one. + $message = 'Redirects to external URLs are not allowed by default, use \Drupal\Core\Routing\TrustedRedirectResponse for it.'; + trigger_error($message, E_USER_ERROR); + $safe_response = new Response($message, 400); + } + $event->setResponse($safe_response); } } } /** - * Sets the bad request response. - * - * We are not throwing a BadRequestHttpException because filterResponse might - * be executed in the context of an exception handling already. - * - * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event - * The event to update its response - * @param string $message - * The message for response. + * @todo Document. */ - protected function setBadRequestException(FilterResponseEvent $event, $message) { - trigger_error($message, E_USER_ERROR); - - $response = new Response($message, 400); - $event->setResponse($response); + protected function getDestinationAsAbsoluteUrl($destination, $scheme_and_host) { + if (!UrlHelper::isExternal($destination)) { + // The destination query parameter can be a relative URL in the sense + // of not including the scheme and host, but its path is expected to + // be absolute (start with a '/'). For such a case, prepend the + // scheme and host, because the 'Location' header must be absolute. + if (strpos($destination, '/') === 0) { + $destination = $scheme_and_host . $destination; + } + else { + // Legacy destination query parameters can be relative paths that + // have not yet been converted to URLs (outbound path processors + // and other URL handling still needs to be performed). + // @todo As generateFromPath() is deprecated, remove this in + // https://www.drupal.org/node/2418219. + $destination = UrlHelper::parse($destination); + $path = $destination['path']; + $options = [ + 'query' => $destination['query'], + 'fragment' => $destination['fragment'], + 'absolute' => TRUE, + ]; + $destination = $this->urlGenerator->generateFromPath($path, $options); + } + } + return $destination; } /** diff -u b/core/lib/Drupal/Core/Routing/TrustedRedirectResponse.php b/core/lib/Drupal/Core/Routing/TrustedRedirectResponse.php --- b/core/lib/Drupal/Core/Routing/TrustedRedirectResponse.php +++ b/core/lib/Drupal/Core/Routing/TrustedRedirectResponse.php @@ -2,72 +2,31 @@ /** * @file - * Contains \Drupal\Core\Routing\RedirectResponse. + * Contains \Drupal\Core\Routing\TrustedRedirectResponse. */ namespace Drupal\Core\Routing; -use Drupal\Component\Utility\UrlHelper; -use \Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; - /** - * Provides a redirect response which allows to redirect to an external URl. - * - * By default Drupal disables redirects to external URLs in order to provide - * better security. - * - * @see \Drupal\Core\EventSubscriber\RedirectResponseSubscriber::checkRedirectUrl + * @todo Document */ -class TrustedRedirectResponse extends RedirectResponse { +class TrustedRedirectResponse extends LocalRedirectResponse { /** - * {@inheritdoc} + * @todo Document. */ - public function __construct($url, $status = 302, $headers = array()) { - if (empty($url)) { - throw new \InvalidArgumentException('Cannot redirect to an empty URL.'); - } - - $this->headers = new ResponseHeaderBag($headers); - $this->setContent(''); - $this->setStatusCode($status); - $this->setProtocolVersion('1.0'); - if (!$this->headers->has('Date')) { - $this->setDate(new \DateTime(null, new \DateTimeZone('UTC'))); - } - - $this->setTargetUrl($url, TRUE); - - if (!$this->isRedirect()) { - throw new \InvalidArgumentException(sprintf('The HTTP status code is not a redirect ("%s" given).', $status)); - } - } - - - /** - * The request context. - * - * @var \Drupal\Core\Routing\RequestContext - */ - protected $requestContext; + protected $trustedUrls = array(); /** * {@inheritdoc} */ - public function setTargetUrl($url, $trusted = FALSE) { - // Just allow to set a different internal URL, for setting a different - // external URL we provide setTrustedUrl(). - - if (!$trusted && UrlHelper::isExternal($url) && !UrlHelper::externalIsLocal($url, $this->getRequestContext()->getCompleteBaseUrl())) { - throw new \InvalidArgumentException('It is not possible to set an external URL using ::setTargetUrl, use setTrustedTargetUrl instead.'); - } - - return parent::setTargetUrl($url); + public function __construct($url, $status = 302, $headers = array()) { + $this->trustedUrls[$url] = TRUE; + parent::__construct($url, $status, $headers); } /** - * Allows to set the target URl of a response to an external URL. + * Sets the target URL to a trusted URL. * * @param string $url * A trusted URL. @@ -75,19 +34,15 @@ * @return $this */ public function setTrustedTargetUrl($url) { - return $this->setTargetUrl($url, TRUE); + $this->trustedUrls[$url] = TRUE; + return $this->setTargetUrl($url); } /** - * Returns the request context. - * - * @return \Drupal\Core\Routing\RequestContext + * {@inheritdoc} */ - protected function getRequestContext() { - if (!isset($this->requestContext)) { - $this->requestContext = \Drupal::service('router.request_context'); - } - return $this->requestContext; + protected function isSafe($url) { + return !empty($this->trustedUrls[$url]) || parent::isSafe($url); } } only in patch2: unchanged: --- /dev/null +++ b/core/lib/Drupal/Component/HttpFoundation/SafeRedirectResponse.php @@ -0,0 +1,46 @@ +getTargetUrl(), $response->getStatusCode(), $response->headers->allPreserveCase()); + $safe_response->setProtocolVersion($response->getProtocolVersion()); + $safe_response->setCharset($response->getCharset()); + return $safe_response; + } + + /** + * {@inheritdoc} + */ + public function setTargetUrl($url) { + if (!$this->isSafe($url)) { + throw new \InvalidArgumentException(sprintf('It is not safe to redirect to %s', $url)); + } + return parent::setTargetUrl($url); + } + + /** + * Returns TRUE if the URL is safe to redirect to. + * + * @param string $url + * + * @return bool + */ + abstract protected function isSafe($url); + +} only in patch2: unchanged: --- /dev/null +++ b/core/lib/Drupal/Core/Routing/LocalRedirectResponse.php @@ -0,0 +1,44 @@ +getRequestContext()->getCompleteBaseUrl()); + } + + /** + * Returns the request context. + * + * @return \Drupal\Core\Routing\RequestContext + */ + protected function getRequestContext() { + if (!isset($this->requestContext)) { + $this->requestContext = \Drupal::service('router.request_context'); + } + return $this->requestContext; + } + +}