From be217f94e1c9ef021afe205034ffb8184ae61b55 Mon Sep 17 00:00:00 2001
From: bcweaver <brianweaver@gmail.com>
Date: Wed, 17 Oct 2018 20:13:47 -0400
Subject: [PATCH] SECURITY UPDATE Core 8.5.8

---
 composer.json                                 |   2 +-
 composer.lock                                 |  12 +-
 vendor/composer/installed.json                |  12 +-
 web/core/lib/Drupal.php                       |   2 +-
 .../Drupal/Component/Utility/UrlHelper.php    |  10 +
 .../RedirectResponseSubscriber.php            |  32 --
 .../Drupal/Core/Mail/Plugin/Mail/PhpMail.php  |  48 ++-
 .../Core/PathProcessor/PathProcessorAlias.php |   9 +
 .../lib/Drupal/Core/Routing/UrlGenerator.php  |   5 +
 .../Drupal/Core/Security/RequestSanitizer.php |  13 +-
 .../src/Functional/Views/DisplayBlockTest.php |  10 +-
 .../Constraint/ModerationStateConstraint.php  |   1 +
 .../ModerationStateConstraintValidator.php    |  90 ++++-
 .../src/StateTransitionValidation.php         |  10 +
 .../StateTransitionValidationInterface.php    |  19 +
 .../Functional/ModerationStateNodeTest.php    |  23 +-
 .../EntityStateChangeValidationTest.php       | 108 +++++
 web/core/modules/contextual/contextual.module |   8 +-
 .../contextual/contextual.post_update.php     |  14 +
 .../modules/contextual/js/contextual.es6.js   |  20 +-
 web/core/modules/contextual/js/contextual.js  |  21 +-
 .../contextual/src/ContextualController.php   |  12 +-
 .../Element/ContextualLinksPlaceholder.php    |  11 +-
 .../Tests/ContextualDynamicContextTest.php    |  65 ++-
 .../node/src/Tests/NodeRevisionsTest.php      |   6 +
 .../Tests/Views/NodeContextualLinksTest.php   |   6 +
 .../tests/src/Functional/PathAliasTest.php    |  31 +-
 .../system/src/Tests/Routing/RouterTest.php   |   7 +
 .../Tests/Component/Utility/UrlHelperTest.php |   4 +
 .../RedirectResponseSubscriberTest.php        |  71 ----
 .../Tests/Core/Mail/MailManagerTest.php       |   9 +
 .../Core/Security/RequestSanitizerTest.php    | 371 ++++++++++++++++++
 32 files changed, 880 insertions(+), 182 deletions(-)
 create mode 100644 web/core/modules/contextual/contextual.post_update.php
 create mode 100644 web/core/tests/Drupal/Tests/Core/Security/RequestSanitizerTest.php

diff --git a/composer.json b/composer.json
index 88eea1561c..012361b5f0 100644
--- a/composer.json
+++ b/composer.json
@@ -83,7 +83,7 @@
         "drupal/config_installer": "^1.0",
         "drupal/console": "^1",
         "drupal/content_access": "1.0-alpha1",
-        "drupal/core": "8.5.7",
+        "drupal/core": "8.5.8",
         "drupal/crop": "2.0-rc1",
         "drupal/css_editor": "1.0",
         "drupal/ctools": "3.0",
diff --git a/composer.lock b/composer.lock
index 6107cbce35..ce92533d8c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "content-hash": "cdf06ca45889b346a4739bcd32760e82",
+    "content-hash": "1e26c9a92826825ab166954dbef61674",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -2316,16 +2316,16 @@
         },
         {
             "name": "drupal/core",
-            "version": "8.5.7",
+            "version": "8.5.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/drupal/core.git",
-                "reference": "d8bdf376c0549e9bfc33030157b498df4b9bec87"
+                "reference": "01703d6f3995fcaa9c67bb02c524d7061dc040b4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/drupal/core/zipball/d8bdf376c0549e9bfc33030157b498df4b9bec87",
-                "reference": "d8bdf376c0549e9bfc33030157b498df4b9bec87",
+                "url": "https://api.github.com/repos/drupal/core/zipball/01703d6f3995fcaa9c67bb02c524d7061dc040b4",
+                "reference": "01703d6f3995fcaa9c67bb02c524d7061dc040b4",
                 "shasum": ""
             },
             "require": {
@@ -2551,7 +2551,7 @@
                 "GPL-2.0-or-later"
             ],
             "description": "Drupal is an open source content management platform powering millions of websites and applications.",
-            "time": "2018-09-05T22:26:39+00:00"
+            "time": "2018-10-17T22:19:47+00:00"
         },
         {
             "name": "drupal/crop",
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 2c9fe51b24..d218a3cebe 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -2392,17 +2392,17 @@
     },
     {
         "name": "drupal/core",
-        "version": "8.5.7",
-        "version_normalized": "8.5.7.0",
+        "version": "8.5.8",
+        "version_normalized": "8.5.8.0",
         "source": {
             "type": "git",
             "url": "https://github.com/drupal/core.git",
-            "reference": "d8bdf376c0549e9bfc33030157b498df4b9bec87"
+            "reference": "01703d6f3995fcaa9c67bb02c524d7061dc040b4"
         },
         "dist": {
             "type": "zip",
-            "url": "https://api.github.com/repos/drupal/core/zipball/d8bdf376c0549e9bfc33030157b498df4b9bec87",
-            "reference": "d8bdf376c0549e9bfc33030157b498df4b9bec87",
+            "url": "https://api.github.com/repos/drupal/core/zipball/01703d6f3995fcaa9c67bb02c524d7061dc040b4",
+            "reference": "01703d6f3995fcaa9c67bb02c524d7061dc040b4",
             "shasum": ""
         },
         "require": {
@@ -2571,7 +2571,7 @@
             "symfony/debug": "^3.4.0",
             "symfony/phpunit-bridge": "^3.4.3"
         },
-        "time": "2018-09-05T22:26:39+00:00",
+        "time": "2018-10-17T22:19:47+00:00",
         "type": "drupal-core",
         "extra": {
             "merge-plugin": {
diff --git a/web/core/lib/Drupal.php b/web/core/lib/Drupal.php
index 901e77b370..b30f585da1 100644
--- a/web/core/lib/Drupal.php
+++ b/web/core/lib/Drupal.php
@@ -82,7 +82,7 @@ class Drupal {
   /**
    * The current system version.
    */
-  const VERSION = '8.5.7';
+  const VERSION = '8.5.8';
 
   /**
    * Core API compatibility.
diff --git a/web/core/lib/Drupal/Component/Utility/UrlHelper.php b/web/core/lib/Drupal/Component/Utility/UrlHelper.php
index 372803590d..8f7019bf90 100644
--- a/web/core/lib/Drupal/Component/Utility/UrlHelper.php
+++ b/web/core/lib/Drupal/Component/Utility/UrlHelper.php
@@ -248,6 +248,16 @@ public static function isExternal($path) {
    *   Exception thrown when a either $url or $bath_url are not fully qualified.
    */
   public static function externalIsLocal($url, $base_url) {
+    // Some browsers treat \ as / so normalize to forward slashes.
+    $url = str_replace('\\', '/', $url);
+
+    // Leading control characters may be ignored or mishandled by browsers, so
+    // assume such a path may lead to an non-local location. The \p{C} character
+    // class matches all UTF-8 control, unassigned, and private characters.
+    if (preg_match('/^\p{C}/u', $url) !== 0) {
+      return FALSE;
+    }
+
     $url_parts = parse_url($url);
     $base_parts = parse_url($base_url);
 
diff --git a/web/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php b/web/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php
index 8397bdef4e..67a4aae422 100644
--- a/web/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php
+++ b/web/core/lib/Drupal/Core/EventSubscriber/RedirectResponseSubscriber.php
@@ -8,7 +8,6 @@
 use Drupal\Core\Routing\RequestContext;
 use Drupal\Core\Utility\UnroutedUrlAssemblerInterface;
 use Symfony\Component\HttpFoundation\Response;
-use Symfony\Component\HttpKernel\Event\GetResponseEvent;
 use Symfony\Component\HttpKernel\KernelEvents;
 use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
 use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -129,36 +128,6 @@ protected function getDestinationAsAbsoluteUrl($destination, $scheme_and_host) {
     return $destination;
   }
 
-  /**
-   * Sanitize the destination parameter to prevent open redirect attacks.
-   *
-   * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
-   *   The Event to process.
-   */
-  public function sanitizeDestination(GetResponseEvent $event) {
-    $request = $event->getRequest();
-    // Sanitize the destination parameter (which is often used for redirects) to
-    // prevent open redirect attacks leading to other domains. Sanitize both
-    // $_GET['destination'] and $_REQUEST['destination'] to protect code that
-    // relies on either, but do not sanitize $_POST to avoid interfering with
-    // unrelated form submissions. The sanitization happens here because
-    // url_is_external() requires the variable system to be available.
-    $query_info = $request->query;
-    $request_info = $request->request;
-    if ($query_info->has('destination') || $request_info->has('destination')) {
-      // If the destination is an external URL, remove it.
-      if ($query_info->has('destination') && UrlHelper::isExternal($query_info->get('destination'))) {
-        $query_info->remove('destination');
-        $request_info->remove('destination');
-      }
-      // If there's still something in $_REQUEST['destination'] that didn't come
-      // from $_GET, check it too.
-      if ($request_info->has('destination') && (!$query_info->has('destination') || $request_info->get('destination') != $query_info->get('destination')) && UrlHelper::isExternal($request_info->get('destination'))) {
-        $request_info->remove('destination');
-      }
-    }
-  }
-
   /**
    * Registers the methods in this class that should be listeners.
    *
@@ -167,7 +136,6 @@ public function sanitizeDestination(GetResponseEvent $event) {
    */
   public static function getSubscribedEvents() {
     $events[KernelEvents::RESPONSE][] = ['checkRedirectUrl'];
-    $events[KernelEvents::REQUEST][] = ['sanitizeDestination', 100];
     return $events;
   }
 
diff --git a/web/core/lib/Drupal/Core/Mail/Plugin/Mail/PhpMail.php b/web/core/lib/Drupal/Core/Mail/Plugin/Mail/PhpMail.php
index e5361492b5..27e5c76e5d 100644
--- a/web/core/lib/Drupal/Core/Mail/Plugin/Mail/PhpMail.php
+++ b/web/core/lib/Drupal/Core/Mail/Plugin/Mail/PhpMail.php
@@ -18,6 +18,20 @@
  */
 class PhpMail implements MailInterface {
 
+  /**
+   * The configuration factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * PhpMail constructor.
+   */
+  public function __construct() {
+    $this->configFactory = \Drupal::configFactory();
+  }
+
   /**
    * Concatenates and wraps the email body for plain-text mails.
    *
@@ -86,7 +100,10 @@ public function mail(array $message) {
       // On most non-Windows systems, the "-f" option to the sendmail command
       // is used to set the Return-Path. There is no space between -f and
       // the value of the return path.
-      $additional_headers = isset($message['Return-Path']) ? '-f' . $message['Return-Path'] : '';
+      // We validate the return path, unless it is equal to the site mail, which
+      // we assume to be safe.
+      $site_mail = $this->configFactory->get('system.site')->get('mail');
+      $additional_headers = isset($message['Return-Path']) && ($site_mail === $message['Return-Path'] || static::_isShellSafe($message['Return-Path'])) ? '-f' . $message['Return-Path'] : '';
       $mail_result = @mail(
         $message['to'],
         $mail_subject,
@@ -112,4 +129,33 @@ public function mail(array $message) {
     return $mail_result;
   }
 
+  /**
+   * Disallows potentially unsafe shell characters.
+   *
+   * Functionally similar to PHPMailer::isShellSafe() which resulted from
+   * CVE-2016-10045. Note that escapeshellarg and escapeshellcmd are inadequate
+   * for this purpose.
+   *
+   * @param string $string
+   *   The string to be validated.
+   *
+   * @return bool
+   *   True if the string is shell-safe.
+   *
+   * @see https://github.com/PHPMailer/PHPMailer/issues/924
+   * @see https://github.com/PHPMailer/PHPMailer/blob/v5.2.21/class.phpmailer.php#L1430
+   *
+   * @todo Rename to ::isShellSafe() and/or discuss whether this is the correct
+   *   location for this helper.
+   */
+  protected static function _isShellSafe($string) {
+    if (escapeshellcmd($string) !== $string || !in_array(escapeshellarg($string), ["'$string'", "\"$string\""])) {
+      return FALSE;
+    }
+    if (preg_match('/[^a-zA-Z0-9@_\-.]/', $string) !== 0) {
+      return FALSE;
+    }
+    return TRUE;
+  }
+
 }
diff --git a/web/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php b/web/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php
index b85737f3cf..d0690fee20 100644
--- a/web/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php
+++ b/web/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php
@@ -43,6 +43,15 @@ public function processOutbound($path, &$options = [], Request $request = NULL,
     if (empty($options['alias'])) {
       $langcode = isset($options['language']) ? $options['language']->getId() : NULL;
       $path = $this->aliasManager->getAliasByPath($path, $langcode);
+      // Ensure the resulting path has at most one leading slash, to prevent it
+      // becoming an external URL without a protocol like //example.com. This
+      // is done in \Drupal\Core\Routing\UrlGenerator::generateFromRoute()
+      // also, to protect against this problem in arbitrary path processors,
+      // but it is duplicated here to protect any other URL generation code
+      // that might call this method separately.
+      if (strpos($path, '//') === 0) {
+        $path = '/' . ltrim($path, '/');
+      }
     }
     return $path;
   }
diff --git a/web/core/lib/Drupal/Core/Routing/UrlGenerator.php b/web/core/lib/Drupal/Core/Routing/UrlGenerator.php
index 3b5c8d256b..853a5f7b4e 100644
--- a/web/core/lib/Drupal/Core/Routing/UrlGenerator.php
+++ b/web/core/lib/Drupal/Core/Routing/UrlGenerator.php
@@ -297,6 +297,11 @@ public function generateFromRoute($name, $parameters = [], $options = [], $colle
     if ($options['path_processing']) {
       $path = $this->processPath($path, $options, $generated_url);
     }
+    // Ensure the resulting path has at most one leading slash, to prevent it
+    // becoming an external URL without a protocol like //example.com.
+    if (strpos($path, '//') === 0) {
+      $path = '/' . ltrim($path, '/');
+    }
     // The contexts base URL is already encoded
     // (see Symfony\Component\HttpFoundation\Request).
     $path = str_replace($this->decodedChars[0], $this->decodedChars[1], rawurlencode($path));
diff --git a/web/core/lib/Drupal/Core/Security/RequestSanitizer.php b/web/core/lib/Drupal/Core/Security/RequestSanitizer.php
index 3f48f3d59c..e1626ed383 100644
--- a/web/core/lib/Drupal/Core/Security/RequestSanitizer.php
+++ b/web/core/lib/Drupal/Core/Security/RequestSanitizer.php
@@ -90,7 +90,8 @@ protected static function processParameterBag(ParameterBag $bag, $whitelist, $lo
     }
 
     if ($bag->has('destination')) {
-      $destination_dangerous_keys = static::checkDestination($bag->get('destination'), $whitelist);
+      $destination = $bag->get('destination');
+      $destination_dangerous_keys = static::checkDestination($destination, $whitelist);
       if (!empty($destination_dangerous_keys)) {
         // The destination is removed rather than sanitized because the URL
         // generator service is not available and this method is called very
@@ -101,6 +102,16 @@ protected static function processParameterBag(ParameterBag $bag, $whitelist, $lo
           trigger_error(sprintf('Potentially unsafe destination removed from %s parameter bag because it contained the following keys: %s', $bag_name, implode(', ', $destination_dangerous_keys)));
         }
       }
+      // Sanitize the destination parameter (which is often used for redirects)
+      // to prevent open redirect attacks leading to other domains.
+      if (UrlHelper::isExternal($destination)) {
+        // The destination is removed because it is an external URL.
+        $bag->remove('destination');
+        $sanitized = TRUE;
+        if ($log_sanitized_keys) {
+          trigger_error(sprintf('Potentially unsafe destination removed from %s parameter bag because it points to an external URL.', $bag_name));
+        }
+      }
     }
     return $sanitized;
   }
diff --git a/web/core/modules/block/tests/src/Functional/Views/DisplayBlockTest.php b/web/core/modules/block/tests/src/Functional/Views/DisplayBlockTest.php
index 0a18d7fa0f..cdb1998241 100644
--- a/web/core/modules/block/tests/src/Functional/Views/DisplayBlockTest.php
+++ b/web/core/modules/block/tests/src/Functional/Views/DisplayBlockTest.php
@@ -3,6 +3,8 @@
 namespace Drupal\Tests\block\Functional\Views;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Site\Settings;
 use Drupal\Core\Url;
 use Drupal\Tests\block\Functional\AssertBlockAppearsTrait;
 use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
@@ -360,14 +362,16 @@ public function testBlockContextualLinks() {
     $this->drupalGet('test-page');
 
     $id = 'block:block=' . $block->id() . ':langcode=en|entity.view.edit_form:view=test_view_block:location=block&name=test_view_block&display_id=block_1&langcode=en';
+    $id_token = Crypt::hmacBase64($id, Settings::getHashSalt() . $this->container->get('private_key')->get());
     $cached_id = 'block:block=' . $cached_block->id() . ':langcode=en|entity.view.edit_form:view=test_view_block:location=block&name=test_view_block&display_id=block_1&langcode=en';
+    $cached_id_token = Crypt::hmacBase64($cached_id, Settings::getHashSalt() . $this->container->get('private_key')->get());
     // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder()
-    $this->assertRaw('<div' . new Attribute(['data-contextual-id' => $id]) . '></div>', format_string('Contextual link placeholder with id @id exists.', ['@id' => $id]));
-    $this->assertRaw('<div' . new Attribute(['data-contextual-id' => $cached_id]) . '></div>', format_string('Contextual link placeholder with id @id exists.', ['@id' => $cached_id]));
+    $this->assertRaw('<div' . new Attribute(['data-contextual-id' => $id, 'data-contextual-token' => $id_token]) . '></div>', format_string('Contextual link placeholder with id @id exists.', ['@id' => $id]));
+    $this->assertRaw('<div' . new Attribute(['data-contextual-id' => $cached_id, 'data-contextual-token' => $cached_id_token]) . '></div>', format_string('Contextual link placeholder with id @id exists.', ['@id' => $cached_id]));
 
     // Get server-rendered contextual links.
     // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks()
-    $post = ['ids[0]' => $id, 'ids[1]' => $cached_id];
+    $post = ['ids[0]' => $id, 'ids[1]' => $cached_id, 'tokens[0]' => $id_token, 'tokens[1]' => $cached_id_token];
     $url = 'contextual/render?_format=json,destination=test-page';
     $this->getSession()->getDriver()->getClient()->request('POST', $url, $post);
     $this->assertResponse(200);
diff --git a/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php b/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
index 7f7c756b6b..4fcde36059 100644
--- a/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
+++ b/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
@@ -16,5 +16,6 @@ class ModerationStateConstraint extends Constraint {
 
   public $message = 'Invalid state transition from %from to %to';
   public $invalidStateMessage = 'State %state does not exist on %workflow workflow';
+  public $invalidTransitionAccess = 'You do not have access to transition from %original_state to %new_state';
 
 }
diff --git a/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php b/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
index 4b879737da..c3b9c815fe 100644
--- a/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
+++ b/web/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
@@ -2,10 +2,13 @@
 
 namespace Drupal\content_moderation\Plugin\Validation\Constraint;
 
+use Drupal\content_moderation\StateTransitionValidationInterface;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\Validator\Constraint;
 use Symfony\Component\Validator\ConstraintValidator;
@@ -29,6 +32,20 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements
    */
   protected $moderationInformation;
 
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The state transition validation service.
+   *
+   * @var \Drupal\content_moderation\StateTransitionValidationInterface
+   */
+  protected $stateTransitionValidation;
+
   /**
    * Creates a new ModerationStateConstraintValidator instance.
    *
@@ -36,10 +53,16 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements
    *   The entity type manager.
    * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
    *   The moderation information.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\content_moderation\StateTransitionValidationInterface $state_transition_validation
+   *   The state transition validation service.
    */
-  public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information) {
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information, AccountInterface $current_user, StateTransitionValidationInterface $state_transition_validation) {
     $this->entityTypeManager = $entity_type_manager;
     $this->moderationInformation = $moderation_information;
+    $this->currentUser = $current_user;
+    $this->stateTransitionValidation = $state_transition_validation;
   }
 
   /**
@@ -48,7 +71,9 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, Mod
   public static function create(ContainerInterface $container) {
     return new static(
       $container->get('entity_type.manager'),
-      $container->get('content_moderation.moderation_information')
+      $container->get('content_moderation.moderation_information'),
+      $container->get('current_user'),
+      $container->get('content_moderation.state_transition_validation')
     );
   }
 
@@ -76,32 +101,59 @@ public function validate($value, Constraint $constraint) {
       return;
     }
 
+    $new_state = $workflow->getTypePlugin()->getState($entity->moderation_state->value);
+    $original_state = $this->getOriginalOrInitialState($entity);
+
     // If a new state is being set and there is an existing state, validate
     // there is a valid transition between them.
+    if (!$original_state->canTransitionTo($new_state->id())) {
+      $this->context->addViolation($constraint->message, [
+        '%from' => $original_state->label(),
+        '%to' => $new_state->label(),
+      ]);
+    }
+    else {
+      // If we're sure the transition exists, make sure the user has permission
+      // to use it.
+      if (!$this->stateTransitionValidation->isTransitionValid($workflow, $original_state, $new_state, $this->currentUser)) {
+        $this->context->addViolation($constraint->invalidTransitionAccess, [
+          '%original_state' => $original_state->label(),
+          '%new_state' => $new_state->label(),
+        ]);
+      }
+    }
+  }
+
+  /**
+   * Gets the original or initial state of the given entity.
+   *
+   * When a state is being validated, the original state is used to validate
+   * that a valid transition exists for target state and the user has access
+   * to the transition between those two states. If the entity has been
+   * moderated before, we can load the original unmodified revision and
+   * translation for this state.
+   *
+   * If the entity is new we need to load the initial state from the workflow.
+   * Even if a value was assigned to the moderation_state field, the initial
+   * state is used to compute an appropriate transition for the purposes of
+   * validation.
+   *
+   * @return \Drupal\workflows\StateInterface
+   *   The original or default moderation state.
+   */
+  protected function getOriginalOrInitialState(ContentEntityInterface $entity) {
+    $state = NULL;
+    $workflow_type = $this->moderationInformation->getWorkflowForEntity($entity)->getTypePlugin();
     if (!$entity->isNew() && !$this->isFirstTimeModeration($entity)) {
       $original_entity = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadRevision($entity->getLoadedRevisionId());
       if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
         $original_entity = $original_entity->getTranslation($entity->language()->getId());
       }
-
-      // If the state of the original entity doesn't exist on the workflow,
-      // we cannot do any further validation of transitions, because none will
-      // be setup for a state that doesn't exist. Instead allow any state to
-      // take its place.
-      if (!$workflow->getTypePlugin()->hasState($original_entity->moderation_state->value)) {
-        return;
-      }
-
-      $new_state = $workflow->getTypePlugin()->getState($entity->moderation_state->value);
-      $original_state = $workflow->getTypePlugin()->getState($original_entity->moderation_state->value);
-
-      if (!$original_state->canTransitionTo($new_state->id())) {
-        $this->context->addViolation($constraint->message, [
-          '%from' => $original_state->label(),
-          '%to' => $new_state->label()
-        ]);
+      if ($workflow_type->hasState($original_entity->moderation_state->value)) {
+        $state = $workflow_type->getState($original_entity->moderation_state->value);
       }
     }
+    return $state ?: $workflow_type->getInitialState($entity);
   }
 
   /**
diff --git a/web/core/modules/content_moderation/src/StateTransitionValidation.php b/web/core/modules/content_moderation/src/StateTransitionValidation.php
index 01b2ad8458..35d657e550 100644
--- a/web/core/modules/content_moderation/src/StateTransitionValidation.php
+++ b/web/core/modules/content_moderation/src/StateTransitionValidation.php
@@ -4,7 +4,9 @@
 
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Session\AccountInterface;
+use Drupal\workflows\StateInterface;
 use Drupal\workflows\Transition;
+use Drupal\workflows\WorkflowInterface;
 
 /**
  * Validates whether a certain state transition is allowed.
@@ -47,4 +49,12 @@ public function getValidTransitions(ContentEntityInterface $entity, AccountInter
     });
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isTransitionValid(WorkflowInterface $workflow, StateInterface $original_state, StateInterface $new_state, AccountInterface $user) {
+    $transition = $workflow->getTypePlugin()->getTransitionFromStateToState($original_state->id(), $new_state->id());
+    return $user->hasPermission('use ' . $workflow->id() . ' transition ' . $transition->id());
+  }
+
 }
diff --git a/web/core/modules/content_moderation/src/StateTransitionValidationInterface.php b/web/core/modules/content_moderation/src/StateTransitionValidationInterface.php
index 1acbf052fd..c793fe53e2 100644
--- a/web/core/modules/content_moderation/src/StateTransitionValidationInterface.php
+++ b/web/core/modules/content_moderation/src/StateTransitionValidationInterface.php
@@ -4,6 +4,8 @@
 
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Session\AccountInterface;
+use Drupal\workflows\StateInterface;
+use Drupal\workflows\WorkflowInterface;
 
 /**
  * Validates whether a certain state transition is allowed.
@@ -23,4 +25,21 @@ interface StateTransitionValidationInterface {
    */
   public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user);
 
+  /**
+   * Checks if a transition between two states if valid for the given user.
+   *
+   * @param \Drupal\workflows\WorkflowInterface $workflow
+   *   The workflow entity.
+   * @param \Drupal\workflows\StateInterface $original_state
+   *   The original workflow state.
+   * @param \Drupal\workflows\StateInterface $new_state
+   *   The new workflow state.
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *   The user to validate.
+   *
+   * @return bool
+   *   Returns TRUE if transition is valid, otherwise FALSE.
+   */
+  public function isTransitionValid(WorkflowInterface $workflow, StateInterface $original_state, StateInterface $new_state, AccountInterface $user);
+
 }
diff --git a/web/core/modules/content_moderation/tests/src/Functional/ModerationStateNodeTest.php b/web/core/modules/content_moderation/tests/src/Functional/ModerationStateNodeTest.php
index 11deaa72c0..5fd168d0bb 100644
--- a/web/core/modules/content_moderation/tests/src/Functional/ModerationStateNodeTest.php
+++ b/web/core/modules/content_moderation/tests/src/Functional/ModerationStateNodeTest.php
@@ -158,32 +158,15 @@ public function testNoContentModerationPermissions() {
     ]);
     $this->drupalLogin($limited_user);
 
-    // Check the user can add content, but can't see the moderation state
-    // select.
+    // Check the user can see the content entity form, but can't see the
+    // moderation state select or save the entity form.
     $this->drupalGet('node/add/moderated_content');
     $session_assert->statusCodeEquals(200);
     $session_assert->fieldNotExists('moderation_state[0][state]');
     $this->drupalPostForm(NULL, [
       'title[0][value]' => 'moderated content',
     ], 'Save');
-
-    // Manually move the content to archived because the user doesn't have
-    // permission to do this.
-    $node = $this->getNodeByTitle('moderated content');
-    $node->moderation_state->value = 'archived';
-    $node->save();
-
-    // Check the user can see the current state but not the select.
-    $this->drupalGet('node/' . $node->id() . '/edit');
-    $session_assert->statusCodeEquals(200);
-    $session_assert->pageTextContains('Archived');
-    $session_assert->fieldNotExists('moderation_state[0][state]');
-    $this->drupalPostForm(NULL, [], 'Save');
-
-    // When saving they should still be on the edit form, and see the validation
-    // error message.
-    $session_assert->pageTextContains('Edit Moderated content moderated content');
-    $session_assert->pageTextContains('Invalid state transition from Archived to Archived');
+    $session_assert->pageTextContains('You do not have access to transition from Draft to Draft');
   }
 
 }
diff --git a/web/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/web/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
index aeed8486f5..9f304dd3f9 100644
--- a/web/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
+++ b/web/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
@@ -6,6 +6,7 @@
 use Drupal\language\Entity\ConfigurableLanguage;
 use Drupal\node\Entity\Node;
 use Drupal\node\Entity\NodeType;
+use Drupal\Tests\user\Traits\UserCreationTrait;
 use Drupal\workflows\Entity\Workflow;
 
 /**
@@ -14,6 +15,8 @@
  */
 class EntityStateChangeValidationTest extends KernelTestBase {
 
+  use UserCreationTrait;
+
   /**
    * {@inheritdoc}
    */
@@ -27,6 +30,13 @@ class EntityStateChangeValidationTest extends KernelTestBase {
     'workflows',
   ];
 
+  /**
+   * An admin user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $adminUser;
+
   /**
    * {@inheritdoc}
    */
@@ -38,6 +48,9 @@ protected function setUp() {
     $this->installEntitySchema('user');
     $this->installEntitySchema('content_moderation_state');
     $this->installConfig('content_moderation');
+    $this->installSchema('system', ['sequences']);
+
+    $this->adminUser = $this->createUser(array_keys($this->container->get('user.permissions')->getPermissions()));
   }
 
   /**
@@ -46,6 +59,8 @@ protected function setUp() {
    * @covers ::validate
    */
   public function testValidTransition() {
+    $this->setCurrentUser($this->adminUser);
+
     $node_type = NodeType::create([
       'type' => 'example',
     ]);
@@ -74,6 +89,8 @@ public function testValidTransition() {
    * @covers ::validate
    */
   public function testInvalidTransition() {
+    $this->setCurrentUser($this->adminUser);
+
     $node_type = NodeType::create([
       'type' => 'example',
     ]);
@@ -123,6 +140,7 @@ public function testInvalidState() {
    * Test validation with content that has no initial state or an invalid state.
    */
   public function testInvalidStateWithoutExisting() {
+    $this->setCurrentUser($this->adminUser);
     // Create content without moderation enabled for the content type.
     $node_type = NodeType::create([
       'type' => 'example',
@@ -154,15 +172,24 @@ public function testInvalidStateWithoutExisting() {
     // validating.
     $workflow->getTypePlugin()->deleteState('deleted_state');
     $workflow->save();
+
+    // When there is an invalid state, the content will revert to "draft". This
+    // will allow a draft to draft transition.
     $node->moderation_state->value = 'draft';
     $violations = $node->validate();
     $this->assertCount(0, $violations);
+    // This will disallow a draft to archived transition.
+    $node->moderation_state->value = 'archived';
+    $violations = $node->validate();
+    $this->assertCount(1, $violations);
   }
 
   /**
    * Test state transition validation with multiple languages.
    */
   public function testInvalidStateMultilingual() {
+    $this->setCurrentUser($this->adminUser);
+
     ConfigurableLanguage::createFromLangcode('fr')->save();
     $node_type = NodeType::create([
       'type' => 'example',
@@ -218,6 +245,8 @@ public function testInvalidStateMultilingual() {
    * Tests that content without prior moderation information can be moderated.
    */
   public function testExistingContentWithNoModeration() {
+    $this->setCurrentUser($this->adminUser);
+
     $node_type = NodeType::create([
       'type' => 'example',
     ]);
@@ -252,6 +281,8 @@ public function testExistingContentWithNoModeration() {
    * Tests that content without prior moderation information can be translated.
    */
   public function testExistingMultilingualContentWithNoModeration() {
+    $this->setCurrentUser($this->adminUser);
+
     // Enable French.
     ConfigurableLanguage::createFromLangcode('fr')->save();
 
@@ -291,4 +322,81 @@ public function testExistingMultilingualContentWithNoModeration() {
     $node_fr->save();
   }
 
+  /**
+   * @dataProvider transitionAccessValidationTestCases
+   */
+  public function testTransitionAccessValidation($permissions, $target_state, $messages) {
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->save();
+    $workflow = Workflow::load('editorial');
+    $workflow->getTypePlugin()->addState('foo', 'Foo');
+    $workflow->getTypePlugin()->addTransition('draft_to_foo', 'Draft to foo', ['draft'], 'foo');
+    $workflow->getTypePlugin()->addTransition('foo_to_foo', 'Foo to foo', ['foo'], 'foo');
+    $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
+    $workflow->save();
+
+    $this->setCurrentUser($this->createUser($permissions));
+
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Test content',
+      'moderation_state' => $target_state,
+    ]);
+    $this->assertTrue($node->isNew());
+    $violations = $node->validate();
+    $this->assertCount(count($messages), $violations);
+    foreach ($messages as $i => $message) {
+      $this->assertEquals($message, $violations->get($i)->getMessage());
+    }
+  }
+
+  /**
+   * Test cases for ::testTransitionAccessValidation.
+   */
+  public function transitionAccessValidationTestCases() {
+    return [
+      'Invalid transition, no permissions validated' => [
+        [],
+        'archived',
+        ['Invalid state transition from <em class="placeholder">Draft</em> to <em class="placeholder">Archived</em>'],
+      ],
+      'Valid transition, missing permission' => [
+        [],
+        'published',
+        ['You do not have access to transition from <em class="placeholder">Draft</em> to <em class="placeholder">Published</em>'],
+      ],
+      'Valid transition, granted published permission' => [
+        ['use editorial transition publish'],
+        'published',
+        [],
+      ],
+      'Valid transition, granted draft permission' => [
+        ['use editorial transition create_new_draft'],
+        'draft',
+        [],
+      ],
+      'Valid transition, incorrect permission granted' => [
+        ['use editorial transition create_new_draft'],
+        'published',
+        ['You do not have access to transition from <em class="placeholder">Draft</em> to <em class="placeholder">Published</em>'],
+      ],
+      // Test with an additional state and set of transitions, since the
+      // "published" transition can start from either "draft" or "published", it
+      // does not capture bugs that fail to correctly distinguish the initial
+      // workflow state from the set state of a new entity.
+      'Valid transition, granted foo permission' => [
+        ['use editorial transition draft_to_foo'],
+        'foo',
+        [],
+      ],
+      'Valid transition, incorrect  foo permission granted' => [
+        ['use editorial transition foo_to_foo'],
+        'foo',
+        ['You do not have access to transition from <em class="placeholder">Draft</em> to <em class="placeholder">Foo</em>'],
+      ],
+    ];
+  }
+
 }
diff --git a/web/core/modules/contextual/contextual.module b/web/core/modules/contextual/contextual.module
index a814c391f1..901ecf116f 100644
--- a/web/core/modules/contextual/contextual.module
+++ b/web/core/modules/contextual/contextual.module
@@ -191,13 +191,19 @@ function _contextual_links_to_id($contextual_links) {
 /**
  * Unserializes the result of _contextual_links_to_id().
  *
- * @see _contextual_links_to_id
+ * Note that $id is user input. Before calling this method the ID should be
+ * checked against the token stored in the 'data-contextual-token' attribute
+ * which is passed via the 'tokens' request parameter to
+ * \Drupal\contextual\ContextualController::render().
  *
  * @param string $id
  *   A serialized representation of a #contextual_links property value array.
  *
  * @return array
  *   The value for a #contextual_links property.
+ *
+ * @see _contextual_links_to_id()
+ * @see \Drupal\contextual\ContextualController::render()
  */
 function _contextual_id_to_links($id) {
   $contextual_links = [];
diff --git a/web/core/modules/contextual/contextual.post_update.php b/web/core/modules/contextual/contextual.post_update.php
new file mode 100644
index 0000000000..8decad05f0
--- /dev/null
+++ b/web/core/modules/contextual/contextual.post_update.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * @file
+ * Post update functions for Contextual Links.
+ */
+
+/**
+ * Ensure new page loads use the updated JS and get the updated markup.
+ */
+function contextual_post_update_fixed_endpoint_and_markup() {
+  // Empty update to trigger a change to css_js_query_string and invalidate
+  // cached markup.
+}
diff --git a/web/core/modules/contextual/js/contextual.es6.js b/web/core/modules/contextual/js/contextual.es6.js
index 701ed126cb..c864006364 100644
--- a/web/core/modules/contextual/js/contextual.es6.js
+++ b/web/core/modules/contextual/js/contextual.es6.js
@@ -154,12 +154,17 @@
       // Collect the IDs for all contextual links placeholders.
       const ids = [];
       $placeholders.each(function () {
-        ids.push($(this).attr('data-contextual-id'));
+        ids.push({
+          id: $(this).attr('data-contextual-id'),
+          token: $(this).attr('data-contextual-token'),
+        });
       });
 
+      const uncachedIDs = [];
+      const uncachedTokens = [];
       // Update all contextual links placeholders whose HTML is cached.
-      const uncachedIDs = _.filter(ids, (contextualID) => {
-        const html = storage.getItem(`Drupal.contextual.${contextualID}`);
+      ids.forEach((contextualID) => {
+        const html = storage.getItem(`Drupal.contextual.${contextualID.id}`);
         if (html && html.length) {
           // Initialize after the current execution cycle, to make the AJAX
           // request for retrieving the uncached contextual links as soon as
@@ -167,11 +172,12 @@
           // the chance to set up an event listener on the Backbone collection
           // Drupal.contextual.collection.
           window.setTimeout(() => {
-            initContextual($context.find(`[data-contextual-id="${contextualID}"]`), html);
+            initContextual($context.find(`[data-contextual-id="${contextualID.id}"]`), html);
           });
-          return false;
+          return;
         }
-        return true;
+        uncachedIDs.push(contextualID.id);
+        uncachedTokens.push(contextualID.token);
       });
 
       // Perform an AJAX request to let the server render the contextual links
@@ -180,7 +186,7 @@
         $.ajax({
           url: Drupal.url('contextual/render'),
           type: 'POST',
-          data: { 'ids[]': uncachedIDs },
+          data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
           dataType: 'json',
           success(results) {
             _.each(results, (html, contextualID) => {
diff --git a/web/core/modules/contextual/js/contextual.js b/web/core/modules/contextual/js/contextual.js
index ed210d070d..e0eaddf899 100644
--- a/web/core/modules/contextual/js/contextual.js
+++ b/web/core/modules/contextual/js/contextual.js
@@ -95,25 +95,32 @@
 
       var ids = [];
       $placeholders.each(function () {
-        ids.push($(this).attr('data-contextual-id'));
+        ids.push({
+          id: $(this).attr('data-contextual-id'),
+          token: $(this).attr('data-contextual-token')
+        });
       });
 
-      var uncachedIDs = _.filter(ids, function (contextualID) {
-        var html = storage.getItem('Drupal.contextual.' + contextualID);
+      var uncachedIDs = [];
+      var uncachedTokens = [];
+
+      ids.forEach(function (contextualID) {
+        var html = storage.getItem('Drupal.contextual.' + contextualID.id);
         if (html && html.length) {
           window.setTimeout(function () {
-            initContextual($context.find('[data-contextual-id="' + contextualID + '"]'), html);
+            initContextual($context.find('[data-contextual-id="' + contextualID.id + '"]'), html);
           });
-          return false;
+          return;
         }
-        return true;
+        uncachedIDs.push(contextualID.id);
+        uncachedTokens.push(contextualID.token);
       });
 
       if (uncachedIDs.length > 0) {
         $.ajax({
           url: Drupal.url('contextual/render'),
           type: 'POST',
-          data: { 'ids[]': uncachedIDs },
+          data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
           dataType: 'json',
           success: function success(results) {
             _.each(results, function (html, contextualID) {
diff --git a/web/core/modules/contextual/src/ContextualController.php b/web/core/modules/contextual/src/ContextualController.php
index 59a2ba0f95..206fb307e5 100644
--- a/web/core/modules/contextual/src/ContextualController.php
+++ b/web/core/modules/contextual/src/ContextualController.php
@@ -2,8 +2,10 @@
 
 namespace Drupal\contextual;
 
+use Drupal\Component\Utility\Crypt;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
 use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Site\Settings;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
@@ -56,8 +58,16 @@ public function render(Request $request) {
       throw new BadRequestHttpException(t('No contextual ids specified.'));
     }
 
+    $tokens = $request->request->get('tokens');
+    if (!isset($tokens)) {
+      throw new BadRequestHttpException(t('No contextual ID tokens specified.'));
+    }
+
     $rendered = [];
-    foreach ($ids as $id) {
+    foreach ($ids as $key => $id) {
+      if (!isset($tokens[$key]) || !Crypt::hashEquals($tokens[$key], Crypt::hmacBase64($id, Settings::getHashSalt() . \Drupal::service('private_key')->get()))) {
+        throw new BadRequestHttpException('Invalid contextual ID specified.');
+      }
       $element = [
         '#type' => 'contextual_links',
         '#contextual_links' => _contextual_id_to_links($id),
diff --git a/web/core/modules/contextual/src/Element/ContextualLinksPlaceholder.php b/web/core/modules/contextual/src/Element/ContextualLinksPlaceholder.php
index 2fba8db3f1..86d485692d 100644
--- a/web/core/modules/contextual/src/Element/ContextualLinksPlaceholder.php
+++ b/web/core/modules/contextual/src/Element/ContextualLinksPlaceholder.php
@@ -2,9 +2,11 @@
 
 namespace Drupal\contextual\Element;
 
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Site\Settings;
 use Drupal\Core\Template\Attribute;
 use Drupal\Core\Render\Element\RenderElement;
-use Drupal\Component\Utility\SafeMarkup;
 
 /**
  * Provides a contextual_links_placeholder element.
@@ -43,7 +45,12 @@ public function getInfo() {
    * @see _contextual_links_to_id()
    */
   public static function preRenderPlaceholder(array $element) {
-    $element['#markup'] = SafeMarkup::format('<div@attributes></div>', ['@attributes' => new Attribute(['data-contextual-id' => $element['#id']])]);
+    $token = Crypt::hmacBase64($element['#id'], Settings::getHashSalt() . \Drupal::service('private_key')->get());
+    $attribute = new Attribute([
+      'data-contextual-id' => $element['#id'],
+      'data-contextual-token' => $token,
+    ]);
+    $element['#markup'] = new FormattableMarkup('<div@attributes></div>', ['@attributes' => $attribute]);
 
     return $element;
   }
diff --git a/web/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php b/web/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php
index 00ade5a5ac..73a7428ef1 100644
--- a/web/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php
+++ b/web/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php
@@ -3,6 +3,8 @@
 namespace Drupal\contextual\Tests;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Site\Settings;
 use Drupal\Core\Url;
 use Drupal\language\Entity\ConfigurableLanguage;
 use Drupal\simpletest\WebTestBase;
@@ -140,6 +142,43 @@ public function testDifferentPermissions() {
     $this->assertRaw('<li class="menu-testcontextual-hidden-manage-edit"><a href="' . base_path() . 'menu-test-contextual/1/edit" class="use-ajax" data-dialog-type="modal" data-is-something>Edit menu - contextual</a></li>');
   }
 
+  /**
+   * Tests the contextual placeholder content is protected by a token.
+   */
+  public function testTokenProtection() {
+    $this->drupalLogin($this->editorUser);
+
+    // Create a node that will have a contextual link.
+    $node1 = $this->drupalCreateNode(['type' => 'article', 'promote' => 1]);
+
+    // Now, on the front page, all article nodes should have contextual links
+    // placeholders, as should the view that contains them.
+    $id = 'node:node=' . $node1->id() . ':changed=' . $node1->getChangedTime() . '&langcode=en';
+
+    // Editor user: can access contextual links and can edit articles.
+    $this->drupalGet('node');
+    $this->assertContextualLinkPlaceHolder($id);
+
+    $post = ['ids[0]' => $id];
+    $this->drupalPostWithFormat('contextual/render', 'json', $post, ['query' => ['destination' => 'node']]);
+    $this->assertResponse(400);
+    $this->assertRaw('No contextual ID tokens specified.');
+
+    $post = ['ids[0]' => $id, 'tokens[0]' => 'wrong_token'];
+    $this->drupalPostWithFormat('contextual/render', 'json', $post, ['query' => ['destination' => 'node']]);
+    $this->assertResponse(400);
+    $this->assertRaw('Invalid contextual ID specified.');
+
+    $post = ['ids[0]' => $id, 'tokens[wrong_key]' => $this->createContextualIdToken($id)];
+    $this->drupalPostWithFormat('contextual/render', 'json', $post, ['query' => ['destination' => 'node']]);
+    $this->assertResponse(400);
+    $this->assertRaw('Invalid contextual ID specified.');
+
+    $post = ['ids[0]' => $id, 'tokens[0]' => $this->createContextualIdToken($id)];
+    $this->drupalPostWithFormat('contextual/render', 'json', $post, ['query' => ['destination' => 'node']]);
+    $this->assertResponse(200);
+  }
+
   /**
    * Asserts that a contextual link placeholder with the given id exists.
    *
@@ -150,7 +189,11 @@ public function testDifferentPermissions() {
    *   The result of the assertion.
    */
   protected function assertContextualLinkPlaceHolder($id) {
-    return $this->assertRaw('<div' . new Attribute(['data-contextual-id' => $id]) . '></div>', format_string('Contextual link placeholder with id @id exists.', ['@id' => $id]));
+    $attribute = new Attribute([
+      'data-contextual-id' => $id,
+      'data-contextual-token' => $this->createContextualIdToken($id),
+    ]);
+    return $this->assertRaw('<div' . $attribute . '></div>', format_string('Contextual link placeholder with id @id exists.', ['@id' => $id]));
   }
 
   /**
@@ -163,7 +206,11 @@ protected function assertContextualLinkPlaceHolder($id) {
    *   The result of the assertion.
    */
   protected function assertNoContextualLinkPlaceHolder($id) {
-    return $this->assertNoRaw('<div' . new Attribute(['data-contextual-id' => $id]) . '></div>', format_string('Contextual link placeholder with id @id does not exist.', ['@id' => $id]));
+    $attribute = new Attribute([
+      'data-contextual-id' => $id,
+      'data-contextual-token' => $this->createContextualIdToken($id),
+    ]);
+    return $this->assertNoRaw('<div' . $attribute . '></div>', format_string('Contextual link placeholder with id @id does not exist.', ['@id' => $id]));
   }
 
   /**
@@ -181,8 +228,22 @@ protected function renderContextualLinks($ids, $current_path) {
     $post = [];
     for ($i = 0; $i < count($ids); $i++) {
       $post['ids[' . $i . ']'] = $ids[$i];
+      $post['tokens[' . $i . ']'] = $this->createContextualIdToken($ids[$i]);
     }
     return $this->drupalPostWithFormat('contextual/render', 'json', $post, ['query' => ['destination' => $current_path]]);
   }
 
+  /**
+   * Creates a contextual ID token.
+   *
+   * @param string $id
+   *   The contextual ID to create a token for.
+   *
+   * @return string
+   *   The contextual ID token.
+   */
+  protected function createContextualIdToken($id) {
+    return Crypt::hmacBase64($id, Settings::getHashSalt() . $this->container->get('private_key')->get());
+  }
+
 }
diff --git a/web/core/modules/node/src/Tests/NodeRevisionsTest.php b/web/core/modules/node/src/Tests/NodeRevisionsTest.php
index c36452d9ab..5b85688a13 100644
--- a/web/core/modules/node/src/Tests/NodeRevisionsTest.php
+++ b/web/core/modules/node/src/Tests/NodeRevisionsTest.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\node\Tests;
 
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Site\Settings;
 use Drupal\Core\Url;
 use Drupal\field\Entity\FieldConfig;
 use Drupal\field\Entity\FieldStorageConfig;
@@ -367,6 +369,10 @@ protected function renderContextualLinks(array $ids, $current_path) {
     $post = [];
     for ($i = 0; $i < count($ids); $i++) {
       $post['ids[' . $i . ']'] = $ids[$i];
+      $post['tokens[' . $i . ']'] = Crypt::hmacBase64(
+        $ids[$i],
+        Settings::getHashSalt() . \Drupal::service('private_key')->get()
+      );
     }
     $response = $this->drupalPost('contextual/render', 'application/json', $post, ['query' => ['destination' => $current_path]]);
 
diff --git a/web/core/modules/node/src/Tests/Views/NodeContextualLinksTest.php b/web/core/modules/node/src/Tests/Views/NodeContextualLinksTest.php
index dc23d0ce55..45b4d086b4 100644
--- a/web/core/modules/node/src/Tests/Views/NodeContextualLinksTest.php
+++ b/web/core/modules/node/src/Tests/Views/NodeContextualLinksTest.php
@@ -3,6 +3,8 @@
 namespace Drupal\node\Tests\Views;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Site\Settings;
 use Drupal\user\Entity\User;
 
 /**
@@ -66,6 +68,10 @@ protected function renderContextualLinks($ids, $current_path) {
     $post = [];
     for ($i = 0; $i < count($ids); $i++) {
       $post['ids[' . $i . ']'] = $ids[$i];
+      $post['tokens[' . $i . ']'] = Crypt::hmacBase64(
+        $ids[$i],
+        Settings::getHashSalt() . \Drupal::service('private_key')->get()
+      );
     }
 
     // Serialize POST values.
diff --git a/web/core/modules/path/tests/src/Functional/PathAliasTest.php b/web/core/modules/path/tests/src/Functional/PathAliasTest.php
index a12b282d67..7ac5ca1cf4 100644
--- a/web/core/modules/path/tests/src/Functional/PathAliasTest.php
+++ b/web/core/modules/path/tests/src/Functional/PathAliasTest.php
@@ -5,6 +5,7 @@
 use Drupal\Component\Utility\Unicode;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Database\Database;
+use Drupal\Core\Url;
 
 /**
  * Add, edit, delete, and change alias and verify its consistency in the
@@ -25,7 +26,7 @@ protected function setUp() {
     parent::setUp();
 
     // Create test user and log in.
-    $web_user = $this->drupalCreateUser(['create page content', 'edit own page content', 'administer url aliases', 'create url aliases']);
+    $web_user = $this->drupalCreateUser(['create page content', 'edit own page content', 'administer url aliases', 'create url aliases', 'access content overview']);
     $this->drupalLogin($web_user);
   }
 
@@ -328,6 +329,34 @@ public function testNodeAlias() {
     $node5->delete();
     $path_alias = \Drupal::service('path.alias_storage')->lookupPathAlias('/node/' . $node5->id(), $node5->language()->getId());
     $this->assertFalse($path_alias, 'Alias was successfully deleted when the referenced node was deleted.');
+
+    // Create sixth test node.
+    $node6 = $this->drupalCreateNode();
+
+    // Create an invalid alias with two leading slashes and verify that the
+    // extra slash is removed when the link is generated. This ensures that URL
+    // aliases cannot be used to inject external URLs.
+    // @todo The user interface should either display an error message or
+    //   automatically trim these invalid aliases, rather than allowing them to
+    //   be silently created, at which point the functional aspects of this
+    //   test will need to be moved elsewhere and switch to using a
+    //   programmatically-created alias instead.
+    $alias = $this->randomMachineName(8);
+    $edit = ['path[0][alias]' => '//' . $alias];
+    $this->drupalPostForm($node6->toUrl('edit-form'), $edit, t('Save'));
+    $this->drupalGet(Url::fromRoute('system.admin_content'));
+    // This checks the link href before clicking it, rather than using
+    // \Drupal\Tests\BrowserTestBase::assertSession()->addressEquals() after
+    // clicking it, because the test browser does not always preserve the
+    // correct number of slashes in the URL when it visits internal links;
+    // using \Drupal\Tests\BrowserTestBase::assertSession()->addressEquals()
+    // would actually make the test pass unconditionally on the testbot (or
+    // anywhere else where Drupal is installed in a subdirectory).
+    $link_xpath = $this->xpath('//a[normalize-space(text())=:label]', [':label' => $node6->getTitle()]);
+    $link_href = $link_xpath[0]->getAttribute('href');
+    $this->assertEquals($link_href, base_path() . $alias);
+    $this->clickLink($node6->getTitle());
+    $this->assertResponse(404);
   }
 
   /**
diff --git a/web/core/modules/system/src/Tests/Routing/RouterTest.php b/web/core/modules/system/src/Tests/Routing/RouterTest.php
index 83a9c55b39..8d7c43e86a 100644
--- a/web/core/modules/system/src/Tests/Routing/RouterTest.php
+++ b/web/core/modules/system/src/Tests/Routing/RouterTest.php
@@ -320,6 +320,13 @@ public function testLeadingSlashes() {
     $this->drupalGet($url);
     $this->assertEqual(1, $this->redirectCount, $url . " redirected to " . $this->url);
     $this->assertUrl($request->getUriForPath('/router_test/test1') . '?qs=test');
+
+    // Ensure that external URLs in destination query params are not redirected
+    // to.
+    $url = $request->getUriForPath('/////////////////////////////////////////////////router_test/test1') . '?qs=test&destination=http://www.example.com%5c@drupal8alt.test';
+    $this->drupalGet($url);
+    $this->assertEqual(1, $this->redirectCount, $url . " redirected to " . $this->url);
+    $this->assertUrl($request->getUriForPath('/router_test/test1') . '?qs=test');
   }
 
 }
diff --git a/web/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php b/web/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
index d185219c9a..beaa472c26 100644
--- a/web/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
+++ b/web/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
@@ -563,6 +563,10 @@ public function providerTestExternalIsLocal() {
       ['http://example.com/foo', 'http://example.com/bar', FALSE],
       ['http://example.com', 'http://example.com/bar', FALSE],
       ['http://example.com/bar', 'http://example.com/bar/', FALSE],
+      // Ensure \ is normalised to / since some browsers do that.
+      ['http://www.example.ca\@example.com', 'http://example.com', FALSE],
+      // Some browsers ignore or strip leading control characters.
+      ["\x00//www.example.ca", 'http://example.com', FALSE],
     ];
   }
 
diff --git a/web/core/tests/Drupal/Tests/Core/EventSubscriber/RedirectResponseSubscriberTest.php b/web/core/tests/Drupal/Tests/Core/EventSubscriber/RedirectResponseSubscriberTest.php
index 8659a6f126..85b3da313c 100644
--- a/web/core/tests/Drupal/Tests/Core/EventSubscriber/RedirectResponseSubscriberTest.php
+++ b/web/core/tests/Drupal/Tests/Core/EventSubscriber/RedirectResponseSubscriberTest.php
@@ -11,7 +11,6 @@
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
-use Symfony\Component\HttpKernel\Event\GetResponseEvent;
 use Symfony\Component\HttpKernel\HttpKernelInterface;
 use Symfony\Component\HttpKernel\KernelEvents;
 
@@ -192,74 +191,4 @@ public function providerTestDestinationRedirectWithInvalidUrl() {
     return $data;
   }
 
-  /**
-   * Tests that $_GET only contain internal URLs.
-   *
-   * @covers ::sanitizeDestination
-   *
-   * @dataProvider providerTestSanitizeDestination
-   *
-   * @see \Drupal\Component\Utility\UrlHelper::isExternal
-   */
-  public function testSanitizeDestinationForGet($input, $output) {
-    $request = new Request();
-    $request->query->set('destination', $input);
-
-    $listener = new RedirectResponseSubscriber($this->urlAssembler, $this->requestContext);
-    $kernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface');
-    $event = new GetResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST);
-
-    $dispatcher = new EventDispatcher();
-    $dispatcher->addListener(KernelEvents::REQUEST, [$listener, 'sanitizeDestination'], 100);
-    $dispatcher->dispatch(KernelEvents::REQUEST, $event);
-
-    $this->assertEquals($output, $request->query->get('destination'));
-  }
-
-  /**
-   * Tests that $_REQUEST['destination'] only contain internal URLs.
-   *
-   * @covers ::sanitizeDestination
-   *
-   * @dataProvider providerTestSanitizeDestination
-   *
-   * @see \Drupal\Component\Utility\UrlHelper::isExternal
-   */
-  public function testSanitizeDestinationForPost($input, $output) {
-    $request = new Request();
-    $request->request->set('destination', $input);
-
-    $listener = new RedirectResponseSubscriber($this->urlAssembler, $this->requestContext);
-    $kernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface');
-    $event = new GetResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST);
-
-    $dispatcher = new EventDispatcher();
-    $dispatcher->addListener(KernelEvents::REQUEST, [$listener, 'sanitizeDestination'], 100);
-    $dispatcher->dispatch(KernelEvents::REQUEST, $event);
-
-    $this->assertEquals($output, $request->request->get('destination'));
-  }
-
-  /**
-   * Data provider for testSanitizeDestination().
-   */
-  public function providerTestSanitizeDestination() {
-    $data = [];
-    // Standard internal example node path is present in the 'destination'
-    // parameter.
-    $data[] = ['node', 'node'];
-    // Internal path with one leading slash is allowed.
-    $data[] = ['/example.com', '/example.com'];
-    // External URL without scheme is not allowed.
-    $data[] = ['//example.com/test', ''];
-    // Internal URL using a colon is allowed.
-    $data[] = ['example:test', 'example:test'];
-    // External URL is not allowed.
-    $data[] = ['http://example.com', ''];
-    // Javascript URL is allowed because it is treated as an internal URL.
-    $data[] = ['javascript:alert(0)', 'javascript:alert(0)'];
-
-    return $data;
-  }
-
 }
diff --git a/web/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php b/web/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php
index 9ba2fb52b7..7df7dd48b4 100644
--- a/web/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php
+++ b/web/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Tests\Core\Mail;
 
+use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\Render\RenderContext;
 use Drupal\Core\Render\RendererInterface;
 use Drupal\Tests\UnitTestCase;
@@ -103,6 +104,9 @@ protected function setUpMailManager($interface = []) {
       'system.mail' => [
         'interface' => $interface,
       ],
+      'system.site' => [
+        'mail' => 'test@example.com',
+      ],
     ]);
     $logger_factory = $this->getMock('\Drupal\Core\Logger\LoggerChannelFactoryInterface');
     $string_translation = $this->getStringTranslationStub();
@@ -110,6 +114,11 @@ protected function setUpMailManager($interface = []) {
     // Construct the manager object and override its discovery.
     $this->mailManager = new TestMailManager(new \ArrayObject(), $this->cache, $this->moduleHandler, $this->configFactory, $logger_factory, $string_translation, $this->renderer);
     $this->mailManager->setDiscovery($this->discovery);
+
+    // @see \Drupal\Core\Plugin\Factory\ContainerFactory::createInstance()
+    $container = new ContainerBuilder();
+    $container->set('config.factory', $this->configFactory);
+    \Drupal::setContainer($container);
   }
 
   /**
diff --git a/web/core/tests/Drupal/Tests/Core/Security/RequestSanitizerTest.php b/web/core/tests/Drupal/Tests/Core/Security/RequestSanitizerTest.php
new file mode 100644
index 0000000000..9a502c13b3
--- /dev/null
+++ b/web/core/tests/Drupal/Tests/Core/Security/RequestSanitizerTest.php
@@ -0,0 +1,371 @@
+<?php
+
+namespace Drupal\Tests\Core\Security;
+
+use Drupal\Core\Security\RequestSanitizer;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Tests RequestSanitizer class.
+ *
+ * @coversDefaultClass \Drupal\Core\Security\RequestSanitizer
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ * @group Security
+ */
+class RequestSanitizerTest extends UnitTestCase {
+
+  /**
+   * Log of errors triggered during sanitization.
+   *
+   * @var array
+   */
+  protected $errors;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->errors = [];
+    set_error_handler([$this, "errorHandler"]);
+  }
+
+  /**
+   * Tests RequestSanitizer class.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request to sanitize.
+   * @param array $expected
+   *   An array of expected request parameters after sanitization. The possible
+   *   keys are 'cookies', 'query', 'request' which correspond to the parameter
+   *   bags names on the request object. These values are also used to test the
+   *   PHP globals post sanitization.
+   * @param array|null $expected_errors
+   *   An array of expected errors. If set to NULL then error logging is
+   *   disabled.
+   * @param array $whitelist
+   *   An array of keys to whitelist and not sanitize.
+   *
+   * @dataProvider providerTestRequestSanitization
+   */
+  public function testRequestSanitization(Request $request, array $expected = [], array $expected_errors = NULL, array $whitelist = []) {
+    // Set up globals.
+    $_GET = $request->query->all();
+    $_POST = $request->request->all();
+    $_COOKIE = $request->cookies->all();
+    $_REQUEST = array_merge($request->query->all(), $request->request->all());
+    $request->server->set('QUERY_STRING', http_build_query($request->query->all()));
+    $_SERVER['QUERY_STRING'] = $request->server->get('QUERY_STRING');
+
+    $request = RequestSanitizer::sanitize($request, $whitelist, is_null($expected_errors) ? FALSE : TRUE);
+
+    // Normalise the expected data.
+    $expected += ['cookies' => [], 'query' => [], 'request' => []];
+    $expected_query_string = http_build_query($expected['query']);
+
+    // Test the request.
+    $this->assertEquals($expected['cookies'], $request->cookies->all());
+    $this->assertEquals($expected['query'], $request->query->all());
+    $this->assertEquals($expected['request'], $request->request->all());
+    $this->assertTrue($request->attributes->get(RequestSanitizer::SANITIZED));
+    // The request object normalizes the request query string.
+    $this->assertEquals(Request::normalizeQueryString($expected_query_string), $request->getQueryString());
+
+    // Test PHP globals.
+    $this->assertEquals($expected['cookies'], $_COOKIE);
+    $this->assertEquals($expected['query'], $_GET);
+    $this->assertEquals($expected['request'], $_POST);
+    $expected_request = array_merge($expected['query'], $expected['request']);
+    $this->assertEquals($expected_request, $_REQUEST);
+    $this->assertEquals($expected_query_string, $_SERVER['QUERY_STRING']);
+
+    // Ensure any expected errors have been triggered.
+    if (!empty($expected_errors)) {
+      foreach ($expected_errors as $expected_error) {
+        $this->assertError($expected_error, E_USER_NOTICE);
+      }
+    }
+    else {
+      $this->assertEquals([], $this->errors);
+    }
+  }
+
+  /**
+   * Data provider for testRequestSanitization.
+   *
+   * @return array
+   */
+  public function providerTestRequestSanitization() {
+    $tests = [];
+
+    $request = new Request(['q' => 'index.php']);
+    $tests['no sanitization GET'] = [$request, ['query' => ['q' => 'index.php']]];
+
+    $request = new Request([], ['field' => 'value']);
+    $tests['no sanitization POST'] = [$request, ['request' => ['field' => 'value']]];
+
+    $request = new Request([], [], [], ['key' => 'value']);
+    $tests['no sanitization COOKIE'] = [$request, ['cookies' => ['key' => 'value']]];
+
+    $request = new Request(['q' => 'index.php'], ['field' => 'value'], [], ['key' => 'value']);
+    $tests['no sanitization GET, POST, COOKIE'] = [$request, ['query' => ['q' => 'index.php'], 'request' => ['field' => 'value'], 'cookies' => ['key' => 'value']]];
+
+    $request = new Request(['q' => 'index.php']);
+    $tests['no sanitization GET log'] = [$request, ['query' => ['q' => 'index.php']], []];
+
+    $request = new Request([], ['field' => 'value']);
+    $tests['no sanitization POST log'] = [$request, ['request' => ['field' => 'value']], []];
+
+    $request = new Request([], [], [], ['key' => 'value']);
+    $tests['no sanitization COOKIE log'] = [$request, ['cookies' => ['key' => 'value']], []];
+
+    $request = new Request(['#q' => 'index.php']);
+    $tests['sanitization GET'] = [$request];
+
+    $request = new Request([], ['#field' => 'value']);
+    $tests['sanitization POST'] = [$request];
+
+    $request = new Request([], [], [], ['#key' => 'value']);
+    $tests['sanitization COOKIE'] = [$request];
+
+    $request = new Request(['#q' => 'index.php'], ['#field' => 'value'], [], ['#key' => 'value']);
+    $tests['sanitization GET, POST, COOKIE'] = [$request];
+
+    $request = new Request(['#q' => 'index.php']);
+    $tests['sanitization GET log'] = [$request, [], ['Potentially unsafe keys removed from query string parameters (GET): #q']];
+
+    $request = new Request([], ['#field' => 'value']);
+    $tests['sanitization POST log'] = [$request, [], ['Potentially unsafe keys removed from request body parameters (POST): #field']];
+
+    $request = new Request([], [], [], ['#key' => 'value']);
+    $tests['sanitization COOKIE log'] = [$request, [], ['Potentially unsafe keys removed from cookie parameters: #key']];
+
+    $request = new Request(['#q' => 'index.php'], ['#field' => 'value'], [], ['#key' => 'value']);
+    $tests['sanitization GET, POST, COOKIE log'] = [$request, [], ['Potentially unsafe keys removed from query string parameters (GET): #q', 'Potentially unsafe keys removed from request body parameters (POST): #field', 'Potentially unsafe keys removed from cookie parameters: #key']];
+
+    $request = new Request(['q' => 'index.php', 'foo' => ['#bar' => 'foo']]);
+    $tests['recursive sanitization log'] = [$request, ['query' => ['q' => 'index.php', 'foo' => []]], ['Potentially unsafe keys removed from query string parameters (GET): #bar']];
+
+    $request = new Request(['q' => 'index.php', 'foo' => ['#bar' => 'foo']]);
+    $tests['recursive no sanitization whitelist'] = [$request, ['query' => ['q' => 'index.php', 'foo' => ['#bar' => 'foo']]], [], ['#bar']];
+
+    $request = new Request([], ['#field' => 'value']);
+    $tests['no sanitization POST whitelist'] = [$request, ['request' => ['#field' => 'value']], [], ['#field']];
+
+    $request = new Request(['q' => 'index.php', 'foo' => ['#bar' => 'foo', '#foo' => 'bar']]);
+    $tests['recursive multiple sanitization log'] = [$request, ['query' => ['q' => 'index.php', 'foo' => []]], ['Potentially unsafe keys removed from query string parameters (GET): #bar, #foo']];
+
+    $request = new Request(['#q' => 'index.php']);
+    $request->attributes->set(RequestSanitizer::SANITIZED, TRUE);
+    $tests['already sanitized request'] = [$request, ['query' => ['#q' => 'index.php']]];
+
+    $request = new Request(['destination' => 'whatever?%23test=value']);
+    $tests['destination removal GET'] = [$request];
+
+    $request = new Request([], ['destination' => 'whatever?%23test=value']);
+    $tests['destination removal POST'] = [$request];
+
+    $request = new Request([], [], [], ['destination' => 'whatever?%23test=value']);
+    $tests['destination removal COOKIE'] = [$request];
+
+    $request = new Request(['destination' => 'whatever?%23test=value']);
+    $tests['destination removal GET log'] = [$request, [], ['Potentially unsafe destination removed from query parameter bag because it contained the following keys: #test']];
+
+    $request = new Request([], ['destination' => 'whatever?%23test=value']);
+    $tests['destination removal POST log'] = [$request, [], ['Potentially unsafe destination removed from request parameter bag because it contained the following keys: #test']];
+
+    $request = new Request([], [], [], ['destination' => 'whatever?%23test=value']);
+    $tests['destination removal COOKIE log'] = [$request, [], ['Potentially unsafe destination removed from cookies parameter bag because it contained the following keys: #test']];
+
+    $request = new Request(['destination' => 'whatever?q[%23test]=value']);
+    $tests['destination removal subkey'] = [$request];
+
+    $request = new Request(['destination' => 'whatever?q[%23test]=value']);
+    $tests['destination whitelist'] = [$request, ['query' => ['destination' => 'whatever?q[%23test]=value']], [], ['#test']];
+
+    $request = new Request(['destination' => "whatever?\x00bar=base&%23test=value"]);
+    $tests['destination removal zero byte'] = [$request];
+
+    $request = new Request(['destination' => 'whatever?q=value']);
+    $tests['destination kept'] = [$request, ['query' => ['destination' => 'whatever?q=value']]];
+
+    $request = new Request(['destination' => 'whatever']);
+    $tests['destination no query'] = [$request, ['query' => ['destination' => 'whatever']]];
+
+    return $tests;
+  }
+
+  /**
+   * Tests acceptable destinations are not removed from GET requests.
+   *
+   * @param string $destination
+   *   The destination string to test.
+   *
+   * @dataProvider providerTestAcceptableDestinations
+   */
+  public function testAcceptableDestinationGet($destination) {
+    // Set up a GET request.
+    $request = $this->createRequestForTesting(['destination' => $destination]);
+
+    $request = RequestSanitizer::sanitize($request, [], TRUE);
+
+    $this->assertSame($destination, $request->query->get('destination', NULL));
+    $this->assertNull($request->request->get('destination', NULL));
+    $this->assertSame($destination, $_GET['destination']);
+    $this->assertSame($destination, $_REQUEST['destination']);
+    $this->assertArrayNotHasKey('destination', $_POST);
+    $this->assertEquals([], $this->errors);
+  }
+
+  /**
+   * Tests unacceptable destinations are removed from GET requests.
+   *
+   * @param string $destination
+   *   The destination string to test.
+   *
+   * @dataProvider providerTestSanitizedDestinations
+   */
+  public function testSanitizedDestinationGet($destination) {
+    // Set up a GET request.
+    $request = $this->createRequestForTesting(['destination' => $destination]);
+
+    $request = RequestSanitizer::sanitize($request, [], TRUE);
+
+    $this->assertNull($request->request->get('destination', NULL));
+    $this->assertNull($request->query->get('destination', NULL));
+    $this->assertArrayNotHasKey('destination', $_POST);
+    $this->assertArrayNotHasKey('destination', $_REQUEST);
+    $this->assertArrayNotHasKey('destination', $_GET);
+    $this->assertError('Potentially unsafe destination removed from query parameter bag because it points to an external URL.', E_USER_NOTICE);
+  }
+
+  /**
+   * Tests acceptable destinations are not removed from POST requests.
+   *
+   * @param string $destination
+   *   The destination string to test.
+   *
+   * @dataProvider providerTestAcceptableDestinations
+   */
+  public function testAcceptableDestinationPost($destination) {
+    // Set up a POST request.
+    $request = $this->createRequestForTesting([], ['destination' => $destination]);
+
+    $request = RequestSanitizer::sanitize($request, [], TRUE);
+
+    $this->assertSame($destination, $request->request->get('destination', NULL));
+    $this->assertNull($request->query->get('destination', NULL));
+    $this->assertSame($destination, $_POST['destination']);
+    $this->assertSame($destination, $_REQUEST['destination']);
+    $this->assertArrayNotHasKey('destination', $_GET);
+    $this->assertEquals([], $this->errors);
+  }
+
+  /**
+   * Tests unacceptable destinations are removed from GET requests.
+   *
+   * @param string $destination
+   *   The destination string to test.
+   *
+   * @dataProvider providerTestSanitizedDestinations
+   */
+  public function testSanitizedDestinationPost($destination) {
+    // Set up a POST request.
+    $request = $this->createRequestForTesting([], ['destination' => $destination]);
+
+    $request = RequestSanitizer::sanitize($request, [], TRUE);
+
+    $this->assertNull($request->request->get('destination', NULL));
+    $this->assertNull($request->query->get('destination', NULL));
+    $this->assertArrayNotHasKey('destination', $_POST);
+    $this->assertArrayNotHasKey('destination', $_REQUEST);
+    $this->assertArrayNotHasKey('destination', $_GET);
+    $this->assertError('Potentially unsafe destination removed from request parameter bag because it points to an external URL.', E_USER_NOTICE);
+  }
+
+  /**
+   * Creates a request and sets PHP globals for testing.
+   *
+   * @param array $query
+   *   (optional) The GET parameters.
+   * @param array $request
+   *   (optional) The POST parameters.
+   *
+   * @return \Symfony\Component\HttpFoundation\Request
+   *   The request object.
+   */
+  protected function createRequestForTesting(array $query = [], array $request = []) {
+    $request = new Request($query, $request);
+
+    // Set up globals.
+    $_GET = $request->query->all();
+    $_POST = $request->request->all();
+    $_COOKIE = $request->cookies->all();
+    $_REQUEST = array_merge($request->query->all(), $request->request->all());
+    $request->server->set('QUERY_STRING', http_build_query($request->query->all()));
+    $_SERVER['QUERY_STRING'] = $request->server->get('QUERY_STRING');
+    return $request;
+  }
+
+  /**
+   * Data provider for testing acceptable destinations.
+   */
+  public function providerTestAcceptableDestinations() {
+    $data = [];
+    // Standard internal example node path is present in the 'destination'
+    // parameter.
+    $data[] = ['node'];
+    // Internal path with one leading slash is allowed.
+    $data[] = ['/example.com'];
+    // Internal URL using a colon is allowed.
+    $data[] = ['example:test'];
+    // Javascript URL is allowed because it is treated as an internal URL.
+    $data[] = ['javascript:alert(0)'];
+    return $data;
+  }
+
+  /**
+   * Data provider for testing sanitized destinations.
+   */
+  public function providerTestSanitizedDestinations() {
+    $data = [];
+    // External URL without scheme is not allowed.
+    $data[] = ['//example.com/test'];
+    // External URL is not allowed.
+    $data[] = ['http://example.com'];
+    $data[] = ['http://example.com//?destination=http://www.evilsite.com%5C@http://www.drupalsite.com'];
+    return $data;
+  }
+
+  /**
+   * Catches and logs errors to $this->errors.
+   *
+   * @param int $errno
+   *   The severity level of the error.
+   * @param string $errstr
+   *   The error message.
+   */
+  public function errorHandler($errno, $errstr) {
+    $this->errors[] = compact('errno', 'errstr');
+  }
+
+  /**
+   * Asserts that the expected error has been logged.
+   *
+   * @param string $errstr
+   *   The error message.
+   * @param int $errno
+   *   The severity level of the error.
+   */
+  protected function assertError($errstr, $errno) {
+    foreach ($this->errors as $error) {
+      if ($error['errstr'] === $errstr && $error['errno'] === $errno) {
+        return;
+      }
+    }
+    $this->fail("Error with level $errno and message '$errstr' not found in " . var_export($this->errors, TRUE));
+  }
+
+}
-- 
GitLab