image_to_media_swapper-2.x-dev/src/SecurityValidationService.php

src/SecurityValidationService.php
<?php

declare(strict_types=1);

namespace Drupal\image_to_media_swapper;

use Drupal\Core\Config\ConfigFactoryInterface;

/**
 * Service for validating URLs and files based on security settings.
 */
final readonly class SecurityValidationService {

  /**
   * The config factory.
   */
  public function __construct(
    private ConfigFactoryInterface $configFactory,
  ) {}

  /**
   * Validates if remote downloads are enabled.
   */
  public function isRemoteDownloadEnabled(): bool {
    return $this->getConfig()->get('enable_remote_downloads') ?? TRUE;
  }

  /**
   * Gets the maximum allowed file size in bytes.
   */
  public function getMaxFileSize(): int {
    $sizeMB = $this->getConfig()->get('max_file_size') ?? 10;
    // Convert MB to bytes.
    return $sizeMB * 1024 * 1024;
  }

  /**
   * Gets the download timeout in seconds.
   */
  public function getDownloadTimeout(): int {
    return $this->getConfig()->get('download_timeout') ?? 30;
  }

  /**
   * Gets the maximum number of redirects allowed.
   */
  public function getMaxRedirects(): int {
    return $this->getConfig()->get('max_redirects') ?? 3;
  }

  /**
   * Validates if a URL is allowed based on security settings.
   */
  public function validateUrl(string $url): array {
    $errors = [];

    // Parse URL components.
    $parsed = parse_url($url);
    if (!$parsed || !isset($parsed['scheme'])) {
      return ['Invalid URL format'];
    }

    // Some complex schemes like jar:http://... might not have a host.
    if (!isset($parsed['host'])) {
      // For schemes without hosts, still validate the scheme.
      if (!in_array(strtolower($parsed['scheme']), ['http', 'https'], TRUE)) {
        return ['Only HTTP and HTTPS protocols are allowed'];
      }
      return ['Invalid URL format - missing host'];
    }

    // Check HTTPS requirement.
    if ($this->getConfig()->get('require_https') &&
        $parsed['scheme'] !== 'https') {
      $errors[] = 'HTTPS is required';
    }

    // Allow HTTP and HTTPS only (case insensitive).
    if (!in_array(strtolower($parsed['scheme']), ['http', 'https'], TRUE)) {
      $errors[] = 'Only HTTP and HTTPS protocols are allowed';
    }

    // Check if private IPs should be blocked.
    if ($this->getConfig()->get('block_private_ips') ?? TRUE) {
      $host = $parsed['host'];

      // Remove IPv6 brackets if present.
      if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
        $host = substr($host, 1, -1);
      }

      // Check if the host is already an IP address.
      if (filter_var($host, FILTER_VALIDATE_IP)) {
        // Direct IP address - validate it.
        if (filter_var($host, FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === FALSE) {
          $errors[] = 'Private/internal IP addresses are not allowed';
        }
      }
      else {
        // It's a hostname - try to resolve it to check for private IPs.
        $ip = gethostbyname($host);
        if (filter_var($ip, FILTER_VALIDATE_IP)) {
          if (filter_var($ip, FILTER_VALIDATE_IP,
              FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === FALSE) {
            $errors[] = 'Private/internal IP addresses are not allowed';
          }
        }
        // If the above failed to resolve, it's still suspicious for security.
        elseif ($ip === $host) {
          $errors[] = 'Invalid IP address or hostname';
        }
      }
    }

    // Check domain restrictions.
    if ($this->getConfig()->get('restrict_domains')) {
      if (!$this->isDomainAllowed($parsed['host'])) {
        $errors[] = 'Domain is not in the allowed list';
      }
    }

    return $errors;
  }

  /**
   * Validates if a file type is allowed.
   */
  public function validateFileType(string $mimeType, string $filename): array {
    $errors = [];

    // Get allowed extensions and MIME types.
    $allowedExtensions = $this->getConfig()
      ->get('allowed_extensions_array') ?? [];
    $allowedMimeTypes = $this->getConfig()
      ->get('allowed_mime_types_array') ?? [];

    // Define dangerous file types that should always be blocked.
    $dangerousExtensions = [
      'exe', 'com', 'bat', 'cmd', 'scr', 'pif', 'vbs', 'js', 'jar',
      'php', 'py', 'rb', 'pl', 'sh', 'asp', 'aspx', 'jsp',
      'zip', 'tar', 'gz', '7z', 'rar',
    // Macro-enabled documents.
      'xlsm', 'docm', 'pptm',
    ];

    $dangerousMimeTypes = [
      'application/x-executable',
      'application/x-msdos-program',
      'application/x-msdownload',
      'text/x-php',
      'application/x-httpd-php',
      'text/x-python',
      'application/javascript',
      'text/javascript',
      'application/zip',
      'application/x-tar',
      'application/x-7z-compressed',
      'application/vnd.ms-excel.sheet.macroEnabled.12',
      'application/vnd.ms-word.document.macroEnabled.12',
      'text/html',
      'application/xhtml+xml',
    ];

    // Check file extension.
    $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

    // Check for dangerous extensions anywhere in filename.
    // This can include double extensions like "file.jpg.exe".
    $filenameLower = strtolower($filename);
    foreach ($dangerousExtensions as $dangerousExt) {
      if (str_contains($filenameLower, '.' . $dangerousExt)) {
        $errors[] = "File contains dangerous extension '{$dangerousExt}' and is blocked for security reasons";
        break;
      }
    }

    // If no dangerous extensions found, check allowed extensions.
    if (empty($errors) && !empty($allowedExtensions) && !in_array($extension, $allowedExtensions)) {
      $errors[] = "File extension '{$extension}' is not in the allowed list";
    }

    // Always block dangerous MIME types.
    if (in_array($mimeType, $dangerousMimeTypes)) {
      $errors[] = "MIME type '{$mimeType}' is blocked for security reasons";
    }
    // If allowed MIME types are configured, check against them.
    elseif (!empty($allowedMimeTypes) && !in_array($mimeType, $allowedMimeTypes)) {
      $errors[] = "MIME type '{$mimeType}' is not in the allowed list";
    }

    return $errors;
  }

  /**
   * Validates file size.
   */
  public function validateFileSize(int $fileSize): array {
    // Reject negative file sizes.
    if ($fileSize < 0) {
      return ['File size cannot be negative'];
    }

    $maxSize = $this->getMaxFileSize();
    if ($fileSize > $maxSize) {
      $maxSizeMB = round($maxSize / 1024 / 1024, 1);
      $fileSizeMB = round($fileSize / 1024 / 1024, 1);
      return ["File size ({$fileSizeMB}MB) exceeds maximum allowed size " .
        "({$maxSizeMB}MB)",
      ];
    }
    return [];
  }

  /**
   * Checks if a domain is allowed based on wildcard patterns.
   */
  private function isDomainAllowed(string $domain): bool {
    $allowedDomains = $this->getConfig()->get('allowed_domains') ?? [];

    if (empty($allowedDomains)) {
      // If no restrictions, allow all.
      return TRUE;
    }

    foreach ($allowedDomains as $allowedDomain) {
      // Handle wildcard subdomains (e.g., *.example.com).
      if (str_starts_with($allowedDomain, '*.')) {
        $baseDomain = substr($allowedDomain, 2);
        if ($domain === $baseDomain ||
            str_ends_with($domain, '.' . $baseDomain)) {
          return TRUE;
        }
      }
      // Exact domain match.
      elseif ($domain === $allowedDomain) {
        return TRUE;
      }
    }

    return FALSE;
  }

  /**
   * Gets the security configuration.
   */
  private function getConfig() {
    return $this->configFactory
      ->get('image_to_media_swapper.security_settings');
  }

  /**
   * Gets Guzzle options based on security settings.
   */
  public function getGuzzleOptions(): array {
    return [
      'timeout' => $this->getDownloadTimeout(),
      'connect_timeout' => 5,
      'allow_redirects' => [
        'max' => $this->getMaxRedirects(),
        'strict' => TRUE,
        'referer' => FALSE,
        'protocols' => $this->getConfig()->get('require_https') ?
          ['https'] : ['http', 'https'],
      ],
      'headers' => [
        'User-Agent' => 'Drupal Image to Media Swapper',
      ],
    ];
  }

}

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

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