SA-CORE-2018-006 by alexpott, attilatilman, bkosborne, catch, bonus, Wim Leers, Sam152, Berdir, Damien Tournoud, Dave Reid, Kova101, David_Rothstein, dawehner, dsnopek, samuel.mortenson, stefan.r, tedbow, xjm, timmillwood, pwolanin, njbooher, dyates, effulgentsia, klausi, mlhess, larowlan
							parent
							
								
									2227375f8b
								
							
						
					
					
						commit
						4dfab43fd5
					
				| 
						 | 
				
			
			@ -248,6 +248,16 @@ class UrlHelper {
 | 
			
		|||
   *   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);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,6 @@ use Drupal\Core\Routing\LocalRedirectResponse;
 | 
			
		|||
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 @@ class RedirectResponseSubscriber implements EventSubscriberInterface {
 | 
			
		|||
    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 @@ class RedirectResponseSubscriber implements EventSubscriberInterface {
 | 
			
		|||
   */
 | 
			
		||||
  public static function getSubscribedEvents() {
 | 
			
		||||
    $events[KernelEvents::RESPONSE][] = ['checkRedirectUrl'];
 | 
			
		||||
    $events[KernelEvents::REQUEST][] = ['sanitizeDestination', 100];
 | 
			
		||||
    return $events;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,20 @@ use Drupal\Core\Site\Settings;
 | 
			
		|||
 */
 | 
			
		||||
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 @@ class PhpMail implements MailInterface {
 | 
			
		|||
      // 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 @@ class PhpMail implements MailInterface {
 | 
			
		|||
    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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,6 +43,15 @@ class PathProcessorAlias implements InboundPathProcessorInterface, OutboundPathP
 | 
			
		|||
    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;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -297,6 +297,11 @@ class UrlGenerator implements UrlGeneratorInterface {
 | 
			
		|||
    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));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -90,7 +90,8 @@ class RequestSanitizer {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    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 @@ class RequestSanitizer {
 | 
			
		|||
          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;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 @@ class DisplayBlockTest extends ViewTestBase {
 | 
			
		|||
    $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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements
 | 
			
		|||
  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 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements
 | 
			
		|||
      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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,9 @@ namespace Drupal\content_moderation;
 | 
			
		|||
 | 
			
		||||
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 @@ class StateTransitionValidation implements StateTransitionValidationInterface {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * {@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());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,8 @@ namespace Drupal\content_moderation;
 | 
			
		|||
 | 
			
		||||
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);
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -158,32 +158,15 @@ class ModerationStateNodeTest extends ModerationStateTestBase {
 | 
			
		|||
    ]);
 | 
			
		||||
    $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');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ use Drupal\language\Entity\ConfigurableLanguage;
 | 
			
		|||
use Drupal\node\Entity\Node;
 | 
			
		||||
use Drupal\node\Entity\NodeType;
 | 
			
		||||
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
 | 
			
		||||
use Drupal\Tests\user\Traits\UserCreationTrait;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @coversDefaultClass \Drupal\content_moderation\Plugin\Validation\Constraint\ModerationStateConstraintValidator
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +16,7 @@ use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
 | 
			
		|||
class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		||||
 | 
			
		||||
  use ContentModerationTestTrait;
 | 
			
		||||
  use UserCreationTrait;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * {@inheritdoc}
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +31,13 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
    'workflows',
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * An admin user.
 | 
			
		||||
   *
 | 
			
		||||
   * @var \Drupal\Core\Session\AccountInterface
 | 
			
		||||
   */
 | 
			
		||||
  protected $adminUser;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * {@inheritdoc}
 | 
			
		||||
   */
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +49,9 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
    $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()));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +60,8 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
   * @covers ::validate
 | 
			
		||||
   */
 | 
			
		||||
  public function testValidTransition() {
 | 
			
		||||
    $this->setCurrentUser($this->adminUser);
 | 
			
		||||
 | 
			
		||||
    $node_type = NodeType::create([
 | 
			
		||||
      'type' => 'example',
 | 
			
		||||
    ]);
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +90,8 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
   * @covers ::validate
 | 
			
		||||
   */
 | 
			
		||||
  public function testInvalidTransition() {
 | 
			
		||||
    $this->setCurrentUser($this->adminUser);
 | 
			
		||||
 | 
			
		||||
    $node_type = NodeType::create([
 | 
			
		||||
      'type' => 'example',
 | 
			
		||||
    ]);
 | 
			
		||||
| 
						 | 
				
			
			@ -125,6 +141,7 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
   * 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',
 | 
			
		||||
| 
						 | 
				
			
			@ -156,15 +173,24 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
    // 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',
 | 
			
		||||
| 
						 | 
				
			
			@ -220,6 +246,8 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
   * Tests that content without prior moderation information can be moderated.
 | 
			
		||||
   */
 | 
			
		||||
  public function testExistingContentWithNoModeration() {
 | 
			
		||||
    $this->setCurrentUser($this->adminUser);
 | 
			
		||||
 | 
			
		||||
    $node_type = NodeType::create([
 | 
			
		||||
      'type' => 'example',
 | 
			
		||||
    ]);
 | 
			
		||||
| 
						 | 
				
			
			@ -254,6 +282,8 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
   * Tests that content without prior moderation information can be translated.
 | 
			
		||||
   */
 | 
			
		||||
  public function testExistingMultilingualContentWithNoModeration() {
 | 
			
		||||
    $this->setCurrentUser($this->adminUser);
 | 
			
		||||
 | 
			
		||||
    // Enable French.
 | 
			
		||||
    ConfigurableLanguage::createFromLangcode('fr')->save();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -293,4 +323,81 @@ class EntityStateChangeValidationTest extends KernelTestBase {
 | 
			
		|||
    $node_fr->save();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * @dataProvider transitionAccessValidationTestCases
 | 
			
		||||
   */
 | 
			
		||||
  public function testTransitionAccessValidation($permissions, $target_state, $messages) {
 | 
			
		||||
    $node_type = NodeType::create([
 | 
			
		||||
      'type' => 'example',
 | 
			
		||||
    ]);
 | 
			
		||||
    $node_type->save();
 | 
			
		||||
    $workflow = $this->createEditorialWorkflow();
 | 
			
		||||
    $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>'],
 | 
			
		||||
      ],
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 = [];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -168,12 +168,16 @@
 | 
			
		|||
      // 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'),
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Update all contextual links placeholders whose HTML is cached.
 | 
			
		||||
      const uncachedIDs = _.filter(ids, contextualID => {
 | 
			
		||||
        const html = storage.getItem(`Drupal.contextual.${contextualID}`);
 | 
			
		||||
      const uncachedIDs = [];
 | 
			
		||||
      const uncachedTokens = [];
 | 
			
		||||
      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
 | 
			
		||||
| 
						 | 
				
			
			@ -182,13 +186,14 @@
 | 
			
		|||
          // Drupal.contextual.collection.
 | 
			
		||||
          window.setTimeout(() => {
 | 
			
		||||
            initContextual(
 | 
			
		||||
              $context.find(`[data-contextual-id="${contextualID}"]`),
 | 
			
		||||
              $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
 | 
			
		||||
| 
						 | 
				
			
			@ -197,7 +202,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) => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -95,25 +95,31 @@
 | 
			
		|||
 | 
			
		||||
      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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -63,8 +65,16 @@ class ContextualController implements ContainerInjectionInterface {
 | 
			
		|||
      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),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,8 @@
 | 
			
		|||
 | 
			
		||||
namespace Drupal\contextual\Element;
 | 
			
		||||
 | 
			
		||||
use Drupal\Component\Utility\Crypt;
 | 
			
		||||
use Drupal\Core\Site\Settings;
 | 
			
		||||
use Drupal\Core\Template\Attribute;
 | 
			
		||||
use Drupal\Core\Render\Element\RenderElement;
 | 
			
		||||
use Drupal\Component\Render\FormattableMarkup;
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +45,12 @@ class ContextualLinksPlaceholder extends RenderElement {
 | 
			
		|||
   * @see _contextual_links_to_id()
 | 
			
		||||
   */
 | 
			
		||||
  public static function preRenderPlaceholder(array $element) {
 | 
			
		||||
    $element['#markup'] = new FormattableMarkup('<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;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,9 +3,10 @@
 | 
			
		|||
namespace Drupal\Tests\contextual\Functional;
 | 
			
		||||
 | 
			
		||||
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\Core\Template\Attribute;
 | 
			
		||||
use Drupal\Tests\BrowserTestBase;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -140,17 +141,76 @@ class ContextualDynamicContextTest extends BrowserTestBase {
 | 
			
		|||
    $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);
 | 
			
		||||
 | 
			
		||||
    $http_client = $this->getHttpClient();
 | 
			
		||||
    $url = Url::fromRoute('contextual.render', [], [
 | 
			
		||||
      'query' => [
 | 
			
		||||
        '_format' => 'json',
 | 
			
		||||
        'destination' => 'node',
 | 
			
		||||
      ],
 | 
			
		||||
    ])->setAbsolute()->toString();
 | 
			
		||||
 | 
			
		||||
    $response = $http_client->request('POST', $url, [
 | 
			
		||||
      'cookies' => $this->getSessionCookies(),
 | 
			
		||||
      'form_params' => ['ids' => [$id], 'tokens' => []],
 | 
			
		||||
      'http_errors' => FALSE,
 | 
			
		||||
    ]);
 | 
			
		||||
    $this->assertEquals('400', $response->getStatusCode());
 | 
			
		||||
    $this->assertContains('No contextual ID tokens specified.', (string) $response->getBody());
 | 
			
		||||
 | 
			
		||||
    $response = $http_client->request('POST', $url, [
 | 
			
		||||
      'cookies' => $this->getSessionCookies(),
 | 
			
		||||
      'form_params' => ['ids' => [$id], 'tokens' => ['wrong_token']],
 | 
			
		||||
      'http_errors' => FALSE,
 | 
			
		||||
    ]);
 | 
			
		||||
    $this->assertEquals('400', $response->getStatusCode());
 | 
			
		||||
    $this->assertContains('Invalid contextual ID specified.', (string) $response->getBody());
 | 
			
		||||
 | 
			
		||||
    $response = $http_client->request('POST', $url, [
 | 
			
		||||
      'cookies' => $this->getSessionCookies(),
 | 
			
		||||
      'form_params' => ['ids' => [$id], 'tokens' => ['wrong_key' => $this->createContextualIdToken($id)]],
 | 
			
		||||
      'http_errors' => FALSE,
 | 
			
		||||
    ]);
 | 
			
		||||
    $this->assertEquals('400', $response->getStatusCode());
 | 
			
		||||
    $this->assertContains('Invalid contextual ID specified.', (string) $response->getBody());
 | 
			
		||||
 | 
			
		||||
    $response = $http_client->request('POST', $url, [
 | 
			
		||||
      'cookies' => $this->getSessionCookies(),
 | 
			
		||||
      'form_params' => ['ids' => [$id], 'tokens' => [$this->createContextualIdToken($id)]],
 | 
			
		||||
      'http_errors' => FALSE,
 | 
			
		||||
    ]);
 | 
			
		||||
    $this->assertEquals('200', $response->getStatusCode());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Asserts that a contextual link placeholder with the given id exists.
 | 
			
		||||
   *
 | 
			
		||||
   * @param string $id
 | 
			
		||||
   *   A contextual link id.
 | 
			
		||||
   *
 | 
			
		||||
   * @return bool
 | 
			
		||||
   *   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]));
 | 
			
		||||
    $this->assertSession()->elementAttributeContains(
 | 
			
		||||
      'css',
 | 
			
		||||
      'div[data-contextual-id="' . $id . '"]',
 | 
			
		||||
      'data-contextual-token',
 | 
			
		||||
      $this->createContextualIdToken($id)
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
| 
						 | 
				
			
			@ -158,12 +218,9 @@ class ContextualDynamicContextTest extends BrowserTestBase {
 | 
			
		|||
   *
 | 
			
		||||
   * @param string $id
 | 
			
		||||
   *   A contextual link id.
 | 
			
		||||
   *
 | 
			
		||||
   * @return bool
 | 
			
		||||
   *   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]));
 | 
			
		||||
    $this->assertSession()->elementNotExists('css', 'div[data-contextual-id="' . $id . '"]');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
| 
						 | 
				
			
			@ -178,6 +235,7 @@ class ContextualDynamicContextTest extends BrowserTestBase {
 | 
			
		|||
   *   The response object.
 | 
			
		||||
   */
 | 
			
		||||
  protected function renderContextualLinks($ids, $current_path) {
 | 
			
		||||
    $tokens = array_map([$this, 'createContextualIdToken'], $ids);
 | 
			
		||||
    $http_client = $this->getHttpClient();
 | 
			
		||||
    $url = Url::fromRoute('contextual.render', [], [
 | 
			
		||||
      'query' => [
 | 
			
		||||
| 
						 | 
				
			
			@ -188,9 +246,22 @@ class ContextualDynamicContextTest extends BrowserTestBase {
 | 
			
		|||
 | 
			
		||||
    return $http_client->request('POST', $this->buildUrl($url), [
 | 
			
		||||
      'cookies' => $this->getSessionCookies(),
 | 
			
		||||
      'form_params' => ['ids' => $ids],
 | 
			
		||||
      'form_params' => ['ids' => $ids, 'tokens' => $tokens],
 | 
			
		||||
      'http_errors' => FALSE,
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 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());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ namespace Drupal\Tests\path\Functional;
 | 
			
		|||
 | 
			
		||||
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
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +25,7 @@ class PathAliasTest extends PathTestBase {
 | 
			
		|||
    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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -327,6 +328,34 @@ class PathAliasTest extends PathTestBase {
 | 
			
		|||
    $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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -320,6 +320,13 @@ class RouterTest extends WebTestBase {
 | 
			
		|||
    $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');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -563,6 +563,10 @@ class UrlHelperTest extends TestCase {
 | 
			
		|||
      ['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],
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,6 @@ use Symfony\Component\EventDispatcher\EventDispatcher;
 | 
			
		|||
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 @@ class RedirectResponseSubscriberTest extends UnitTestCase {
 | 
			
		|||
    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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 @@ class MailManagerTest extends UnitTestCase {
 | 
			
		|||
      '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 @@ class MailManagerTest extends UnitTestCase {
 | 
			
		|||
    // 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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -197,6 +197,147 @@ class RequestSanitizerTest extends UnitTestCase {
 | 
			
		|||
    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'];
 | 
			
		||||
    return $data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Catches and logs errors to $this->errors.
 | 
			
		||||
   *
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue