We were developing our own intranet system when we came across the issue, a security hole.

This intranet system, called Intra is a decoupled Drupal site with Vue.js application as the frontend. We use this system for tracking employees’ in-office time, absences and other custom events such as bonus points.

For this decoupled direction, we decided to use the Drupal built-in REST API since we found it to be the most optimal solution for our needs. If you would like to read more about this topic, you can find another blog post “Corporate Intranet on Decoupled Drupal”.

A Little Background

For a deeper understanding, let’s discuss the absence logic in our system.

The absence system is designed to manage the absence requests of employees. There are two types of requests: automatic and manual. The automatic request, as the name suggests, are automatically accepted by the system. The manual, on the other hand, means that there is a workflow process behind the scenes. For handling workflows, we have decided to use the Drupal core Workflows module as it keeps everything simple.

Let’s take a look at the common scenario: an employee wishes to take holiday leave so they submit an absence request on the Intra site. The request will generate a notification to the HR department for which they will then decide whether to accept it or not. If an approval has been made, the absence status can be set in the Intra backend.

That was the first version of the business logic by design.

Road to the Hole

After some time, we received a feature request that informed us that every employee should be able to cancel their own, already accepted absence request.


As shrewd developers, we began to research the requirements for this new request. First we tried to post modifications to an existing absence request using Postman. One of our tests included a status change which was forbidden by the configuration. There was no active transition set for this event. At this point of the investigation the things turned interesting. To our surprise, the system accepted the status change!

We were confounded as to why this happened. We reasoned that we must have used a wrong user to test with, or we made some altering which by-passed the transition validation. After several attempts and double-checking the entire flow, we realised the test parameters were correct and the test case should not have worked.

We dug even deeper into the issue to find that the Workflows module and the REST API were not working correctly together. The problem laid with the Workflows’ transition validation since it depended on the form validation- specifically the select widget’s validation. The following shows the lack of transition validation in the ModerationStateConstraintValidator’s validate method:

  1. /**
  2. * {@inheritdoc}
  3. */
  4. public function validate($value, Constraint $constraint) {
  5.  /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
  6.  $entity = $value->getEntity();
  7.  // Ignore entities that are not subject to moderation anyway.
  8.  if (!$this->moderationInformation->isModeratedEntity($entity)) {
  9.    return;
  10.  }
  11.  $workflow = $this->moderationInformation->getWorkflowForEntity($entity);
  12.  if (!$workflow->getTypePlugin()->hasState($entity->moderation_state->value)) {
  13.    // If the state we are transitioning to doesn't exist, we can't validate
  14.    // the transitions for this entity further.
  15.    $this->context->addViolation($constraint->invalidStateMessage, [
  16.      '%state' => $entity->moderation_state->value,
  17.      '%workflow' => $workflow->label(),
  18.    ]);
  19.    return;
  20.  }
  21.  // If a new state is being set and there is an existing state, validate
  22.  // there is a valid transition between them.
  23.  if (!$entity->isNew() && !$this->isFirstTimeModeration($entity)) {
  24.    $original_entity = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadRevision($entity->getLoadedRevisionId());
  25.    if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
  26.      $original_entity = $original_entity->getTranslation($entity->language()->getId());
  27.    }
  28.    // If the state of the original entity doesn't exist on the workflow,
  29.    // we cannot do any further validation of transitions, because none will
  30.    // be setup for a state that doesn't exist. Instead allow any state to
  31.    // take its place.
  32.    if (!$workflow->getTypePlugin()->hasState($original_entity->moderation_state->value)) {
  33.      return;
  34.    }
  35.    $new_state = $workflow->getTypePlugin()->getState($entity->moderation_state->value);
  36.    $original_state = $workflow->getTypePlugin()->getState($original_entity->moderation_state->value);
  37.    if (!$original_state->canTransitionTo($new_state->id())) {
  38.      $this->context->addViolation($constraint->message, [
  39.        '%from' => $original_state->label(),
  40.        '%to' => $new_state->label(),
  41.      ]);
  42.    }
  43.  }
  44. }

As you can see it only validates the transition’s existence but misses the validation of the permissions for the said transition. And here is the corrected code:

  1. /**
  2. * {@inheritdoc}
  3. */
  4. public function validate($value, Constraint $constraint) {
  5.  /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
  6.  $entity = $value->getEntity();
  7.  // Ignore entities that are not subject to moderation anyway.
  8.  if (!$this->moderationInformation->isModeratedEntity($entity)) {
  9.    return;
  10.  }
  11.  $workflow = $this->moderationInformation->getWorkflowForEntity($entity);
  12.  if (!$workflow->getTypePlugin()->hasState($entity->moderation_state->value)) {
  13.    // If the state we are transitioning to doesn't exist, we can't validate
  14.    // the transitions for this entity further.
  15.    $this->context->addViolation($constraint->invalidStateMessage, [
  16.      '%state' => $entity->moderation_state->value,
  17.      '%workflow' => $workflow->label(),
  18.    ]);
  19.    return;
  20.  }
  21.  $new_state = $workflow->getTypePlugin()->getState($entity->moderation_state->value);
  22.  $original_state = $this->getOriginalOrInitialState($entity);
  23.  // If a new state is being set and there is an existing state, validate
  24.  // there is a valid transition between them.
  25.  if (!$original_state->canTransitionTo($new_state->id())) {
  26.    $this->context->addViolation($constraint->message, [
  27.      '%from' => $original_state->label(),
  28.      '%to' => $new_state->label(),
  29.    ]);
  30.  }
  31.  else {
  32.    // If we're sure the transition exists, make sure the user has permission
  33.    // to use it.
  34.    if (!$this->stateTransitionValidation->isTransitionValid($workflow, $original_state, $new_state, $this->currentUser)) {
  35.      $this->context->addViolation($constraint->invalidTransitionAccess, [
  36.        '%original_state' => $original_state->label(),
  37.        '%new_state' => $new_state->label(),
  38.      ]);
  39.    }
  40.  }
  41. }

As you can see here not just the existence of the transition is validated but the permissions as well. In the admin pages missing of this validation didn’t do any harm because the Select widget validated the existence of the available options, but if you’ve changed the widget to Text type, then you could write any kind of state ID by hand. This behaviour were risky combined with the REST module or other API providing module.

Reporting

We realised that this flaw can be exploited by even simple users, (if they have access to a Node which uses the Workflows module). This security hole has the potential to give users an access validation by-pass, allowing them to publish articles or other content without any supervision.

We decided to report this issue to the Drupal security team, including a detailed description on how they can replicate this behaviour. Next to it, we also provided a possible solution which they can implement.

Tools in the Shed

To test the custom REST API endpoints we decided to use Postman. We found this application is perfect for our needs; many options can be set, it supports a number of authorisation types out of the box, and it is easy to use. At the end Postman was the tool, which helped us detect the bug.

Out of the Wild

We got out of the wild, when the issue was fixed and merged into Drupal core version 8.6.2 after six months of developing and testing. For further information you can find the security advisories here: SA-CORE-2018-006.