Commit ef6c82c4 authored by Kostia Bohach's avatar Kostia Bohach
Browse files

Additional validations for config form and post update to fix existing values

parent bf053b5a
Loading
Loading
Loading
Loading
Loading
+50 −0
Changes for block_attributes.install: 50 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

/**
 * @file
 * Install, update and uninstall functions for the Block Attributes module.
 */

use Drupal\block_attributes\BlockAttributesChecker;

/**
 * Removes stored attribute names that are not valid attribute names.
 */
function block_attributes_update_10201() {
  $config_factory = \Drupal::configFactory();
  $removed = [];

  // The list of available attributes.
  $config = $config_factory->getEditable('block_attributes.config');
  $attributes = $config->get('attributes');
  if (is_array($attributes)) {
    $allowed = array_filter($attributes, [BlockAttributesChecker::class, 'isAttributeAllowed'], ARRAY_FILTER_USE_KEY);
    if (count($allowed) !== count($attributes)) {
      $removed += array_diff_key($attributes, $allowed);
      $config->set('attributes', $allowed)->save();
    }
  }

  // The values stored on the individual blocks, which are what actually gets
  // rendered.
  foreach ($config_factory->listAll('block.block.') as $name) {
    $block = $config_factory->getEditable($name);
    $attributes = $block->get('settings.attributes');
    if (!is_array($attributes)) {
      continue;
    }
    $allowed = array_filter($attributes, [BlockAttributesChecker::class, 'isAttributeAllowed'], ARRAY_FILTER_USE_KEY);
    if (count($allowed) !== count($attributes)) {
      $removed += array_diff_key($attributes, $allowed);
      $block->set('settings.attributes', $allowed)->save();
    }
  }

  if ($removed) {
    return t('Removed the following invalid attribute names: %names. Export your configuration again to keep it in sync.', [
      '%names' => implode(', ', array_keys($removed)),
    ]);
  }

  return t('No invalid attribute names were stored.');
}
+23 −4
Changes for block_attributes.module: 23 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,8 @@
 */

use Drupal\block_attributes\BlockAttributesChecker;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\Component\Utility\Unicode;
@@ -18,6 +20,12 @@ function block_attributes_form_block_form_alter(&$form, FormStateInterface $form
  $config = $block->getConfiguration();
  $attributes = \Drupal::config('block_attributes.config')->get('attributes') ?: [];

  // Names that would break out of the attribute when rendered are skipped,
  // so offering a field for one would only collect a value that never reaches
  // the markup. Filtering here rather than inside the loop below keeps
  // the "no attributes defined" case correct when every stored name is invalid.
  $attributes = array_filter($attributes, [BlockAttributesChecker::class, 'isAttributeAllowed'], ARRAY_FILTER_USE_KEY);

  $form['settings']['attributes'] = [
    '#type' => 'details',
    '#title' => t('Attributes'),
@@ -97,17 +105,28 @@ function block_attributes_form_block_form_submit($form, FormStateInterface $form
  $block->save();
}

/**
 * Implements hook_block_build_alter().
 */
function block_attributes_block_build_alter(array &$build, BlockPluginInterface $block) {
  // The rendered attributes are filtered against block_attributes.config, so
  // a cached block has to be rebuilt when that configuration changes.
  if (!empty($block->getConfiguration()['attributes'])) {
    CacheableMetadata::createFromRenderArray($build)
      ->addCacheableDependency(\Drupal::config('block_attributes.config'))
      ->applyTo($build);
  }
}

/**
 * Implements hook_preprocess().
 */
function block_attributes_preprocess_block(&$variables, $hook) {
  if (isset($variables['configuration']['attributes'])) {
    $attributes = $variables['configuration']['attributes'];
    $definitions = \Drupal::config('block_attributes.config')->get('attributes');
    $attributes = BlockAttributesChecker::filterAttributeValues($variables['configuration']['attributes'], $definitions);

    foreach ($attributes as $name => $value) {
      if (!BlockAttributesChecker::isAttributeAllowed($name)) {
        continue;
      }
      if (isset($variables['attributes'][$name]) && is_array($variables['attributes'][$name])) {
        $values = explode(' ', $value);
        $values = array_map('trim', $values);
+59 −3
Changes for src/BlockAttributesChecker.php: 59 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -9,6 +9,14 @@ namespace Drupal\block_attributes;
 */
class BlockAttributesChecker {

  /**
   * Pattern a whole attribute name has to match to be considered safe.
   *
   * Covers every attribute the module is meant to provide, including class,
   * id, role, data-* and aria-*.
   */
  const ATTRIBUTE_NAME_PATTERN = '/^[a-zA-Z][a-zA-Z0-9_-]*$/';

  /**
   * Checks if a given attribute is allowed.
   *
@@ -19,12 +27,60 @@ class BlockAttributesChecker {
   *   TRUE if the attribute is allowed, FALSE otherwise.
   */
  public static function isAttributeAllowed($attribute) {
    // Trim whitespace and convert to lowercase.
    $clean_key = strtolower(trim($attribute));
    if (!is_string($attribute)) {
      return FALSE;
    }

    // Validate the name as a whole instead of looking for bad substrings.
    if (!preg_match(self::ATTRIBUTE_NAME_PATTERN, $attribute)) {
      return FALSE;
    }

    // Match JS event handlers.
    $result = (bool) preg_match('/^on[a-z]+$/', $clean_key);
    $result = (bool) preg_match('/^on[a-z]+$/', strtolower($attribute));
    // If the attribute matches a JS event handler, return FALSE.
    return !$result;
  }

  /**
   * Filters the attribute values stored on a block before rendering.
   *
   * The values stored on a block can reach the site through configuration
   * import without ever passing the block form, so they are checked again
   * against the attributes defined in config.
   *
   * @param mixed $values
   *   The attribute values stored on the block, keyed by attribute name.
   * @param mixed $definitions
   *   The attribute definitions from config, keyed by attribute name.
   *
   * @return string[]
   *   The values that are safe to render, keyed by attribute name.
   */
  public static function filterAttributeValues($values, $definitions) {
    if (!is_array($values) || !is_array($definitions)) {
      return [];
    }

    $filtered = [];
    foreach ($values as $name => $value) {
      if (!static::isAttributeAllowed($name) || !array_key_exists($name, $definitions)) {
        continue;
      }
      if (!is_scalar($value)) {
        continue;
      }
      $value = (string) $value;

      $options = $definitions[$name]['options'] ?? NULL;
      if (!empty($options) && is_array($options) && !in_array($value, array_map('strval', array_keys($options)), TRUE)) {
        continue;
      }

      $filtered[$name] = $value;
    }

    return $filtered;
  }

}
+14 −2
Changes for src/Form/ConfigForm.php: 14 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -124,9 +124,21 @@ class ConfigForm extends ConfigFormBase {
  public function validateForm(array &$form, FormStateInterface $form_state) {
    $config_text = $form_state->getValue('config') ?: 'attributes:';
    $decodedText = Yaml::decode($config_text);
    foreach ($decodedText['attributes'] as $attributeName => $value) {
    $attributes = is_array($decodedText['attributes'] ?? NULL) ? $decodedText['attributes'] : [];
    foreach ($attributes as $attributeName => $info) {
      if (!BlockAttributesChecker::isAttributeAllowed($attributeName)) {
        $form_state->setErrorByName('config', $this->t('HTML event attributes are not allowed here. Visit this <a href="https://www.w3schools.com/tags/ref_eventattributes.asp" rel="noopener noreferrer" target="_blank">page</a> for the whole list unallowed attributes.'));
        $form_state->setErrorByName('config', $this->t('%name is not a valid attribute name. A name has to start with a letter and may only contain letters, digits, hyphens and underscores, and HTML event attributes are not allowed. Visit this <a href="https://www.w3schools.com/tags/ref_eventattributes.asp" rel="noopener noreferrer" target="_blank">page</a> for the whole list of unallowed attributes.', ['%name' => $attributeName]));
        continue;
      }

      // The block form reads the label, description and options of every
      // attribute, so each entry has to have the expected structure.
      if (!is_array($info)
        || !is_scalar($info['label'] ?? '')
        || !is_scalar($info['description'] ?? '')
        || !is_array($info['options'] ?? [])
        || count(array_filter($info['options'] ?? [], 'is_scalar')) !== count($info['options'] ?? [])) {
        $form_state->setErrorByName('config', $this->t('The definition of %name is not valid. The label and description have to be text, and the options have to be a list of values with text labels.', ['%name' => $attributeName]));
      }
    }