EntityGet ServiceDefinition exposes all user information (incl. mail, pass, ...)

Created on 3 April 2024, 3 months ago

This module has an information disclosure vulnerability.

You can see this vulnerability by:
1. Enabling the module

2. As a user with admin permission
2.1 Enable the module

2.2 Add a service endpoint (called "xxx" here as example) at /admin/structure/service_endpoint

2.3 Enable the "User > Retrieve" service resource (at /admin/structure/service_endpoint/xxx/resources for example with Allowed authentication: json, Allowed authentication: cookie

3. Log out. As any user with {entity-type}.view access (You do not need to log in to reproduce this, if anonymous has user.view access.):

3.1 Make a GET request or simply call https://www.example.com/your-service-endpoint-base-url/user/1
You can see all user / entity information including email, pass (hashed) etc. and code-wise this makes sense, because there's no special handling for entity or field data.

Still I wouldn't expect these values to be ever exposed to the outside world?

What I'd expect is to retrieve only public information from the user profile. What this means surely needs to be discussed in detail.

This is mitigated by the fact that the EntityGet is currently broken at least in Drupal 10 without the patch from this issue: https://www.drupal.org/project/services/issues/3347225 ๐Ÿ› The context is not a valid context (entity get returns empty string message) RTBC

I don't think the patch is the reason for this security issue, but this should also be checked, please.

The Drupal 7 Version 7.x-3.x had a dedicated helper method to remove this information:

/**
 *  Helper function to remove data from the user object.
 *
 *  @param $account
 *    Object user object.
 */
function services_remove_user_data(&$account) {
  global $user;

  // Remove the user password from the account object.
  unset($account->pass);
  if (isset($account->current_pass)) { unset($account->current_pass); }

  // Remove the user mail, if current user don't have "administer users"
  // permission, and the requested account not match the current user.
  if (!user_access('administer users') && isset($account->uid) && isset($user->uid) && $account->uid !== $user->uid) {
    unset($account->mail);
  }

  // Remove the user init, if current user don't have "administer users"
  // permission.
  if (!user_access('administer users')) {
    unset($account->init);
  }

  drupal_alter('services_account_object', $account);

  // Add the full URL to the user picture, if one is present.
  if (variable_get('user_pictures', FALSE) && isset($account->picture->uri)) {
    $account->picture->url = file_create_url($account->picture->uri);
  }
}

https://git.drupalcode.org/project/services/-/blob/7.x-3.x/services.modu...

The Drupal 8 versions don't have this.

This is the relevant code:

/**
 * @ServiceDefinition(
 *   id = "entity_get",
 *   methods = {
 *     "GET"
 *   },
 *   translatable = true,
 *   deriver = "\Drupal\services\Plugin\Deriver\EntityGet"
 * )
 */
class EntityGet extends ServiceDefinitionBase {

  /**
   * {@inheritdoc}
   */
  public function processRoute(Route $route) {
    $route->setRequirement('_entity_access', $this->getDerivativeId() . '.view');
  }

  /**
   * {@inheritdoc}
   */
  public function processRequest(Request $request, RouteMatchInterface $route_match, SerializerInterface $serializer) {
    /** @var \Drupal\Core\Entity\EntityInterface $entity */
    $entity = $this->getContextValue($this->getDerivativeId());

    return $entity->toArray();
  }
}

the result looks like this:

{
  "uid": [
    {
      "value": "89"
    }
  ],
  "uuid": [
    {
      "value": "ef001c15-729c-6543-8abc-b6fg25s30f"
    }
  ],
  "langcode": [
    {
      "value": "de"
    }
  ],
  "preferred_langcode": [
    {
      "value": "de"
    }
  ],
  "preferred_admin_langcode": [
    {
      "value": "de"
    }
  ],
  "name": [
    {
      "value": "my-username"
    }
  ],
  "pass": [
    {
      "value": "lsadfhhflqh345รถ13j45ยง$fdfash54624/35345gsfdgdfg"
    }
  ],
  "mail": [
    {
      "value": "info@example.com"
    }
  ],
  "timezone": [
    {
      "value": "Europe/Berlin"
    }
  ],
  "status": [
    {
      "value": "1"
    }
  ],
  "created": [
    {
      "value": "1366634374"
    }
  ],
  "changed": [
    {
      "value": "1711450576"
    }
  ],
  "access": [
    {
      "value": "1712076896"
    }
  ],
  "login": [
    {
      "value": "1712071850"
    }
  ],
  "init": [
    {
      "value": "info@example.com"
    }
  ],
  "roles": [
    {
      "target_id": "administrator"
    }
  ],
  "default_langcode": [
    {
      "value": "1"
    }
  ],
  "path": [
    {
      "alias": "/users/example",
      "pid": "42039",
      "langcode": "und"
    }
  ],
  "tmgmt_translation_skills": [
    {
      "language_from": "de",
      "language_to": "en"
    },
    {
      "language_from": "en",
      "language_to": "de"
    }
  ],
  "user_picture": [
    {
      "target_id": "2119",
      "alt": "",
      "title": "",
      "width": "160",
      "height": "130"
    }
  ],
  "field_anrede": [
    {
      "value": "Herr"
    }
  ],
  "field_anrede_titel": [
    {
      "value": "default"
    }
  ],
  "field_bic": [
    {
      "value": "DUMMY"
    }
  ],
}

And another important helper method in 7.x-3.x (Drupal 7) which seems to be missing in Drupal 8+ version:

<?php
/**
 * Helper function to remove fields from an entity according to field_access
 *
 * @param $op What you want to do with the entity. ie, view
 * @param $entity_type The entity type. ie node, or user
 * @param $entity The physical entity object
 *
 * returns $cloned_entity with fields removed that are needed.
 */
function services_field_permissions_clean($op, $entity_type, $entity) {
  $cloned_entity = clone $entity;
  //Each entity type seems to have a different key needed from field_info_instances
  //Lets determine that key here.
  switch ($entity_type) {
    case 'comment':
      $key = $cloned_entity->node_type;
      break;
    case 'user':
      $key = $entity_type;
      break;
    case 'node':
      $key = $cloned_entity->type;
      break;
    case 'taxonomy_term':
      $key = $cloned_entity->vocabulary_machine_name;
      break;
    default:
      $key = $entity_type;
  }
  //Allow someone to alter the field lookup key in the case they used their own entity.
  drupal_alter('services_field_permissions_field_lookup_key', $key, $entity);

  //load the fields info
  $fields_info = field_info_instances($entity_type);
  //Loop through all the fields on the content type
  foreach ($fields_info[$key] as $field_name => $field_data) {
    //check for access on our op
    $access = field_access($op, field_info_field($field_name), $entity_type, $entity);
    //If no access unset the field.
    if (!$access) {
      unset($cloned_entity->$field_name);
    }
  }
  return $cloned_entity;
}
?>

I guess these methods urgently need to be reintroduced in 5.x to fix information exposure.

๐Ÿ› Bug report
Status

Active

Version

5.0

Component

Code

Created by

๐Ÿ‡ต๐Ÿ‡นPortugal jcnventura

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Production build 0.69.0 2024