RedirectResponseSubscriber.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. <?php
  2. namespace Drupal\Core\EventSubscriber;
  3. use Drupal\Component\HttpFoundation\SecuredRedirectResponse;
  4. use Drupal\Component\Utility\UrlHelper;
  5. use Drupal\Core\Routing\LocalRedirectResponse;
  6. use Drupal\Core\Routing\RequestContext;
  7. use Drupal\Core\Utility\UnroutedUrlAssemblerInterface;
  8. use Symfony\Component\HttpFoundation\Response;
  9. use Symfony\Component\HttpKernel\Event\GetResponseEvent;
  10. use Symfony\Component\HttpKernel\KernelEvents;
  11. use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
  12. use Symfony\Component\HttpFoundation\RedirectResponse;
  13. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  14. /**
  15. * Allows manipulation of the response object when performing a redirect.
  16. */
  17. class RedirectResponseSubscriber implements EventSubscriberInterface {
  18. /**
  19. * The unrouted URL assembler service.
  20. *
  21. * @var \Drupal\Core\Utility\UnroutedUrlAssemblerInterface
  22. */
  23. protected $unroutedUrlAssembler;
  24. /**
  25. * Constructs a RedirectResponseSubscriber object.
  26. *
  27. * @param \Drupal\Core\Utility\UnroutedUrlAssemblerInterface $url_assembler
  28. * The unrouted URL assembler service.
  29. * @param \Drupal\Core\Routing\RequestContext $request_context
  30. * The request context.
  31. */
  32. public function __construct(UnroutedUrlAssemblerInterface $url_assembler, RequestContext $request_context) {
  33. $this->unroutedUrlAssembler = $url_assembler;
  34. $this->requestContext = $request_context;
  35. }
  36. /**
  37. * Allows manipulation of the response object when performing a redirect.
  38. *
  39. * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
  40. * The Event to process.
  41. */
  42. public function checkRedirectUrl(FilterResponseEvent $event) {
  43. $response = $event->getResponse();
  44. if ($response instanceof RedirectResponse) {
  45. $request = $event->getRequest();
  46. // Let the 'destination' query parameter override the redirect target.
  47. // If $response is already a SecuredRedirectResponse, it might reject the
  48. // new target as invalid, in which case proceed with the old target.
  49. $destination = $request->query->get('destination');
  50. if ($destination) {
  51. // The 'Location' HTTP header must always be absolute.
  52. $destination = $this->getDestinationAsAbsoluteUrl($destination, $request->getSchemeAndHttpHost());
  53. try {
  54. $response->setTargetUrl($destination);
  55. }
  56. catch (\InvalidArgumentException $e) {
  57. }
  58. }
  59. // Regardless of whether the target is the original one or the overridden
  60. // destination, ensure that all redirects are safe.
  61. if (!($response instanceof SecuredRedirectResponse)) {
  62. try {
  63. // SecuredRedirectResponse is an abstract class that requires a
  64. // concrete implementation. Default to LocalRedirectResponse, which
  65. // considers only redirects to within the same site as safe.
  66. $safe_response = LocalRedirectResponse::createFromRedirectResponse($response);
  67. $safe_response->setRequestContext($this->requestContext);
  68. }
  69. catch (\InvalidArgumentException $e) {
  70. // If the above failed, it's because the redirect target wasn't
  71. // local. Do not follow that redirect. Display an error message
  72. // instead. We're already catching one exception, so trigger_error()
  73. // rather than throw another one.
  74. // We don't throw an exception, because this is a client error rather than a
  75. // server error.
  76. $message = 'Redirects to external URLs are not allowed by default, use \Drupal\Core\Routing\TrustedRedirectResponse for it.';
  77. trigger_error($message, E_USER_ERROR);
  78. $safe_response = new Response($message, 400);
  79. }
  80. $event->setResponse($safe_response);
  81. }
  82. }
  83. }
  84. /**
  85. * Converts the passed in destination into an absolute URL.
  86. *
  87. * @param string $destination
  88. * The path for the destination. In case it starts with a slash it should
  89. * have the base path included already.
  90. * @param string $scheme_and_host
  91. * The scheme and host string of the current request.
  92. *
  93. * @return string
  94. * The destination as absolute URL.
  95. */
  96. protected function getDestinationAsAbsoluteUrl($destination, $scheme_and_host) {
  97. if (!UrlHelper::isExternal($destination)) {
  98. // The destination query parameter can be a relative URL in the sense of
  99. // not including the scheme and host, but its path is expected to be
  100. // absolute (start with a '/'). For such a case, prepend the scheme and
  101. // host, because the 'Location' header must be absolute.
  102. if (strpos($destination, '/') === 0) {
  103. $destination = $scheme_and_host . $destination;
  104. }
  105. else {
  106. // Legacy destination query parameters can be internal paths that have
  107. // not yet been converted to URLs.
  108. $destination = UrlHelper::parse($destination);
  109. $uri = 'base:' . $destination['path'];
  110. $options = [
  111. 'query' => $destination['query'],
  112. 'fragment' => $destination['fragment'],
  113. 'absolute' => TRUE,
  114. ];
  115. // Treat this as if it's user input of a path relative to the site's
  116. // base URL.
  117. $destination = $this->unroutedUrlAssembler->assemble($uri, $options);
  118. }
  119. }
  120. return $destination;
  121. }
  122. /**
  123. * Sanitize the destination parameter to prevent open redirect attacks.
  124. *
  125. * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
  126. * The Event to process.
  127. */
  128. public function sanitizeDestination(GetResponseEvent $event) {
  129. $request = $event->getRequest();
  130. // Sanitize the destination parameter (which is often used for redirects) to
  131. // prevent open redirect attacks leading to other domains. Sanitize both
  132. // $_GET['destination'] and $_REQUEST['destination'] to protect code that
  133. // relies on either, but do not sanitize $_POST to avoid interfering with
  134. // unrelated form submissions. The sanitization happens here because
  135. // url_is_external() requires the variable system to be available.
  136. $query_info = $request->query;
  137. $request_info = $request->request;
  138. if ($query_info->has('destination') || $request_info->has('destination')) {
  139. // If the destination is an external URL, remove it.
  140. if ($query_info->has('destination') && UrlHelper::isExternal($query_info->get('destination'))) {
  141. $query_info->remove('destination');
  142. $request_info->remove('destination');
  143. }
  144. // If there's still something in $_REQUEST['destination'] that didn't come
  145. // from $_GET, check it too.
  146. if ($request_info->has('destination') && (!$query_info->has('destination') || $request_info->get('destination') != $query_info->get('destination')) && UrlHelper::isExternal($request_info->get('destination'))) {
  147. $request_info->remove('destination');
  148. }
  149. }
  150. }
  151. /**
  152. * Registers the methods in this class that should be listeners.
  153. *
  154. * @return array
  155. * An array of event listener definitions.
  156. */
  157. public static function getSubscribedEvents() {
  158. $events[KernelEvents::RESPONSE][] = ['checkRedirectUrl'];
  159. $events[KernelEvents::REQUEST][] = ['sanitizeDestination', 100];
  160. return $events;
  161. }
  162. }