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',
],
];
}
}
