webprofiler-10.0.x-dev/src/Csp/ContentSecurityPolicyHandler.php

src/Csp/ContentSecurityPolicyHandler.php
<?php

declare(strict_types=1);

namespace Drupal\webprofiler\Csp;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Handles Content-Security-Policy HTTP header for the WebProfiler Bundle.
 *
 * @internal
 */
class ContentSecurityPolicyHandler {

  /**
   * TRUE if the Content-Security-Policy is disabled.
   *
   * @var bool
   */
  private bool $cspDisabled = FALSE;

  /**
   * ContentSecurityPolicyHandler constructor.
   *
   * @param \Drupal\webprofiler\Csp\NonceGenerator $nonceGenerator
   *   The nonce generator service.
   */
  public function __construct(
    protected readonly NonceGenerator $nonceGenerator,
  ) {
  }

  /**
   * Returns an array of nonces.
   *
   * To be used in Twig templates and Content-Security-Policy headers.
   *
   * Nonce can be provided by;
   *  - The request - In case HTML content is fetched via AJAX and inserted in
   * DOM, it must use the same nonce as origin.
   *  - The response - A call to getNonces() has already been done previously.
   * Same nonce are returned.
   *  - They are otherwise randomly generated.
   */
  public function getNonces(Request $request, Response $response): array {
    if ($request->headers->has('X-SymfonyProfiler-Script-Nonce') && $request->headers->has('X-SymfonyProfiler-Style-Nonce')) {
      return [
        'csp_script_nonce' => $request->headers->get('X-SymfonyProfiler-Script-Nonce'),
        'csp_style_nonce' => $request->headers->get('X-SymfonyProfiler-Style-Nonce'),
      ];
    }

    if ($response->headers->has('X-SymfonyProfiler-Script-Nonce') && $response->headers->has('X-SymfonyProfiler-Style-Nonce')) {
      return [
        'csp_script_nonce' => $response->headers->get('X-SymfonyProfiler-Script-Nonce'),
        'csp_style_nonce' => $response->headers->get('X-SymfonyProfiler-Style-Nonce'),
      ];
    }

    $nonces = [
      'csp_script_nonce' => $this->generateNonce(),
      'csp_style_nonce' => $this->generateNonce(),
    ];

    $response->headers->set('X-SymfonyProfiler-Script-Nonce', $nonces['csp_script_nonce']);
    $response->headers->set('X-SymfonyProfiler-Style-Nonce', $nonces['csp_style_nonce']);

    return $nonces;
  }

  /**
   * Disables Content-Security-Policy.
   *
   * All related headers will be removed.
   */
  public function disableCsp(): void {
    $this->cspDisabled = TRUE;
  }

  /**
   * Cleanup temporary headers and updates Content-Security-Policy headers.
   *
   * @return array
   *   Nonces used by the bundle in Content-Security-Policy header
   */
  public function updateResponseHeaders(Request $request, Response $response): array {
    if ($this->cspDisabled) {
      $this->removeCspHeaders($response);

      return [];
    }

    $nonces = $this->getNonces($request, $response);
    $this->cleanHeaders($response);
    $this->updateCspHeaders($response, $nonces);

    return $nonces;
  }

  /**
   * Clean headers.
   *
   * @param \Symfony\Component\HttpFoundation\Response $response
   *   The Response object.
   */
  private function cleanHeaders(Response $response): void {
    $response->headers->remove('X-SymfonyProfiler-Script-Nonce');
    $response->headers->remove('X-SymfonyProfiler-Style-Nonce');
  }

  /**
   * Remove CSP headers.
   *
   * @param \Symfony\Component\HttpFoundation\Response $response
   *   The Response object.
   */
  private function removeCspHeaders(Response $response): void {
    $response->headers->remove('X-Content-Security-Policy');
    $response->headers->remove('Content-Security-Policy');
    $response->headers->remove('Content-Security-Policy-Report-Only');
  }

  /**
   * Updates Content-Security-Policy headers in a response.
   */
  private function updateCspHeaders(Response $response, array $nonces = []): array {
    $nonces = \array_replace([
      'csp_script_nonce' => $this->generateNonce(),
      'csp_style_nonce' => $this->generateNonce(),
    ], $nonces);

    $ruleIsSet = FALSE;

    $headers = $this->getCspHeaders($response);

    $types = [
      'script-src' => 'csp_script_nonce',
      'script-src-elem' => 'csp_script_nonce',
      'style-src' => 'csp_style_nonce',
      'style-src-elem' => 'csp_style_nonce',
    ];

    foreach ($headers as $header => $directives) {
      foreach ($types as $type => $tokenName) {
        if ($this->authorizesInline($directives, $type)) {
          continue;
        }
        if (!isset($headers[$header][$type])) {
          if (NULL === $fallback = $this->getDirectiveFallback($directives, $type)) {
            continue;
          }

          if (['\'none\''] === $fallback) {
            // Fallback came from "default-src: 'none'"
            // 'none' is invalid if it's not the only expression in the source
            // list, so we leave it out.
            $fallback = [];
          }

          $headers[$header][$type] = $fallback;
        }
        $ruleIsSet = TRUE;
        if (!\in_array('\'unsafe-inline\'', $headers[$header][$type], TRUE)) {
          $headers[$header][$type][] = '\'unsafe-inline\'';
        }
        $headers[$header][$type][] = \sprintf('\'nonce-%s\'', $nonces[$tokenName]);
      }
    }

    if (!$ruleIsSet) {
      return $nonces;
    }

    foreach ($headers as $header => $directives) {
      $response->headers->set($header, $this->generateCspHeader($directives));
    }

    return $nonces;
  }

  /**
   * Generates a valid Content-Security-Policy nonce.
   */
  private function generateNonce(): string {
    return $this->nonceGenerator->generate();
  }

  /**
   * Converts a directive set array into Content-Security-Policy header.
   */
  private function generateCspHeader(array $directives): string {
    return \array_reduce(\array_keys($directives), static function ($res, $name) use ($directives) {
      return ('' !== $res ? $res . '; ' : '') . \sprintf('%s %s', $name, \implode(' ', $directives[$name]));
    }, '');
  }

  /**
   * Converts a Content-Security-Policy header value into a directive set array.
   */
  private function parseDirectives(string $header): array {
    $directives = [];

    foreach (\explode(';', $header) as $directive) {
      $parts = \explode(' ', \trim($directive));
      if (\count($parts) <= 1) {
        continue;
      }
      $name = \array_shift($parts);
      $directives[$name] = $parts;
    }

    return $directives;
  }

  /**
   * Detects if the 'unsafe-inline' is prevented for a directive.
   */
  private function authorizesInline(array $directivesSet, string $type): bool {
    if (isset($directivesSet[$type])) {
      $directives = $directivesSet[$type];
    }
    elseif (NULL === $directives = $this->getDirectiveFallback($directivesSet, $type)) {
      return FALSE;
    }

    return \in_array('\'unsafe-inline\'', $directives, TRUE) && !$this->hasHashOrNonce($directives);
  }

  /**
   * Check if a directive set contains a hash or a nonce.
   */
  private function hasHashOrNonce(array $directives): bool {
    foreach ($directives as $directive) {
      if (!\str_ends_with($directive, '\'')) {
        continue;
      }
      if (\str_starts_with($directive, '\'nonce-')) {
        return TRUE;
      }
      if (\in_array(\substr($directive, 0, 8), [
        '\'sha256-',
        '\'sha384-',
        '\'sha512-',
      ], TRUE)) {
        return TRUE;
      }
    }

    return FALSE;
  }

  /**
   * Gets the fallback directive for a given directive set.
   */
  private function getDirectiveFallback(array $directiveSet, string $type): array|null {
    if (\in_array($type, [
      'script-src-elem',
      'style-src-elem',
    ], TRUE) || !isset($directiveSet['default-src'])) {
      // Let the browser fallback on it's own.
      return NULL;
    }

    return $directiveSet['default-src'];
  }

  /**
   * Retrieves the Content-Security-Policy headers.
   *
   * Either X-Content-Security-Policy or Content-Security-Policy) from a
   * response.
   */
  private function getCspHeaders(Response $response): array {
    $headers = [];

    if ($response->headers->has('Content-Security-Policy')) {
      $headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
    }

    if ($response->headers->has('Content-Security-Policy-Report-Only')) {
      $headers['Content-Security-Policy-Report-Only'] = $this->parseDirectives($response->headers->get('Content-Security-Policy-Report-Only'));
    }

    if ($response->headers->has('X-Content-Security-Policy')) {
      $headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
    }

    return $headers;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc