image_to_media_swapper-2.x-dev/tests/src/Kernel/SecurityValidationServiceTest.php
tests/src/Kernel/SecurityValidationServiceTest.php
<?php
declare(strict_types=1);
namespace Drupal\Tests\image_to_media_swapper\Kernel;
use Drupal\image_to_media_swapper\SecurityValidationService;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the SecurityValidationService.
*
* @group image_to_media_swapper
* @coversDefaultClass \Drupal\image_to_media_swapper\SecurityValidationService
*/
class SecurityValidationServiceTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'image_to_media_swapper',
'system',
];
/**
* The security validation service.
*/
protected SecurityValidationService $securityValidationService;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig(['image_to_media_swapper']);
$this->securityValidationService = $this->container->get('image_to_media_swapper.security_validation');
}
/**
* Tests URL validation with default settings.
*
* @covers ::validateUrl
*/
public function testUrlValidationDefaults(): void {
// Valid HTTPS URL should pass.
$errors = $this->securityValidationService->validateUrl('https://example.com/file.jpg');
$this->assertEmpty($errors);
// Valid HTTP URL should pass (default allows HTTP).
$errors = $this->securityValidationService->validateUrl('http://example.com/file.jpg');
$this->assertEmpty($errors);
// Invalid URL format should fail.
$errors = $this->securityValidationService->validateUrl('not-a-url');
$this->assertNotEmpty($errors);
$this->assertContains('Invalid URL format', $errors);
// Invalid protocol should fail.
$errors = $this->securityValidationService->validateUrl('ftp://example.com/file.jpg');
$this->assertNotEmpty($errors);
$this->assertContains('Only HTTP and HTTPS protocols are allowed', $errors);
}
/**
* Tests SSRF protection against private IPs.
*
* @covers ::validateUrl
*/
public function testSsrfProtection(): void {
// Test private IP ranges are blocked by default.
$privateIPs = [
'http://192.168.1.1/file.jpg',
'http://10.0.0.1/file.jpg',
'http://172.16.0.1/file.jpg',
'http://127.0.0.1/file.jpg',
'http://localhost/file.jpg',
];
foreach ($privateIPs as $url) {
$errors = $this->securityValidationService->validateUrl($url);
$this->assertNotEmpty($errors, "Private IP should be blocked: {$url}");
$this->assertContains('Private/internal IP addresses are not allowed', $errors);
}
// Test that disabling IP blocking allows private IPs.
$config = $this->config('image_to_media_swapper.security_settings');
$config->set('block_private_ips', FALSE)->save();
$errors = $this->securityValidationService->validateUrl('http://192.168.1.1/file.jpg');
$this->assertEmpty($errors);
}
/**
* Tests HTTPS requirement enforcement.
*
* @covers ::validateUrl
*/
public function testHttpsRequirement(): void {
// Enable HTTPS requirement.
$config = $this->config('image_to_media_swapper.security_settings');
$config->set('require_https', TRUE)->save();
// HTTP should now fail.
$errors = $this->securityValidationService->validateUrl('http://example.com/file.jpg');
$this->assertNotEmpty($errors);
$this->assertContains('HTTPS is required', $errors);
// HTTPS should still pass.
$errors = $this->securityValidationService->validateUrl('https://example.com/file.jpg');
$this->assertEmpty($errors);
}
/**
* Tests domain restriction functionality.
*
* @covers ::validateUrl
*/
public function testDomainRestriction(): void {
// Enable domain restriction.
$config = $this->config('image_to_media_swapper.security_settings');
$config->set('restrict_domains', TRUE);
$config->set('allowed_domains', ['example.com', '*.trusted.org']);
$config->save();
// Allowed exact domain should pass.
$errors = $this->securityValidationService->validateUrl('https://example.com/file.jpg');
$this->assertEmpty($errors);
// Wildcard subdomain should pass.
$errors = $this->securityValidationService->validateUrl('https://cdn.trusted.org/file.jpg');
$this->assertEmpty($errors);
// Base domain of wildcard should pass.
$errors = $this->securityValidationService->validateUrl('https://trusted.org/file.jpg');
$this->assertEmpty($errors);
// Non-allowed domain should fail.
$errors = $this->securityValidationService->validateUrl('https://malicious.com/file.jpg');
$this->assertNotEmpty($errors);
$this->assertContains('Domain is not in the allowed list', $errors);
// Subdomain of non-wildcard should fail.
$errors = $this->securityValidationService->validateUrl('https://sub.example.com/file.jpg');
$this->assertNotEmpty($errors);
$this->assertContains('Domain is not in the allowed list', $errors);
}
/**
* Tests file type validation.
*
* @covers ::validateFileType
*/
public function testFileTypeValidation(): void {
// Configure limited file types using the correct array fields.
$config = $this->config('image_to_media_swapper.security_settings');
$config->set('allowed_extensions_array', ['jpg', 'png']);
$config->set('allowed_mime_types_array', ['image/jpeg', 'image/png']);
$config->save();
// Allowed extension and MIME type should pass.
$errors = $this->securityValidationService->validateFileType('image/jpeg', 'test.jpg');
$this->assertEmpty($errors);
// Disallowed extension should fail.
$errors = $this->securityValidationService->validateFileType('image/gif', 'test.gif');
$this->assertNotEmpty($errors);
$this->assertStringContainsString("File extension 'gif' is not in the allowed list", implode(', ', $errors));
// Disallowed MIME type should fail.
$errors = $this->securityValidationService->validateFileType('application/pdf', 'test.jpg');
$this->assertNotEmpty($errors);
$this->assertStringContainsString("MIME type 'application/pdf' is not in the allowed list", implode(', ', $errors));
// Dangerous MIME type should always fail.
$errors = $this->securityValidationService->validateFileType('text/html', 'malicious.jpg');
$this->assertNotEmpty($errors);
$this->assertStringContainsString("MIME type 'text/html' is blocked for security reasons", implode(', ', $errors));
}
/**
* Tests file size validation.
*
* @covers ::validateFileSize
*/
public function testFileSizeValidation(): void {
// Set 5MB limit.
$config = $this->config('image_to_media_swapper.security_settings');
$config->set('max_file_size', 5)->save();
// File under 3MB limit should pass.
$errors = $this->securityValidationService->validateFileSize(3 * 1024 * 1024);
$this->assertEmpty($errors);
// File over 10MB limit should fail.
$errors = $this->securityValidationService->validateFileSize(10 * 1024 * 1024);
$this->assertNotEmpty($errors);
$this->assertStringContainsString('exceeds maximum allowed size', $errors[0]);
}
/**
* Tests Guzzle options generation.
*
* @covers ::getGuzzleOptions
*/
public function testGuzzleOptions(): void {
$options = $this->securityValidationService->getGuzzleOptions();
// Check default timeout.
$this->assertEquals(30, $options['timeout']);
$this->assertEquals(5, $options['connect_timeout']);
// Check redirect settings.
$this->assertEquals(3, $options['allow_redirects']['max']);
$this->assertTrue($options['allow_redirects']['strict']);
$this->assertFalse($options['allow_redirects']['referer']);
// Check protocols include both HTTP and HTTPS by default.
$this->assertEquals(['http', 'https'], $options['allow_redirects']['protocols']);
// Test HTTPS-only mode.
$config = $this->config('image_to_media_swapper.security_settings');
$config->set('require_https', TRUE)->save();
$options = $this->securityValidationService->getGuzzleOptions();
$this->assertEquals(['https'], $options['allow_redirects']['protocols']);
}
/**
* Tests security boundary conditions.
*
* @covers ::validateUrl
* @covers ::validateFileType
* @covers ::validateFileSize
*/
public function testSecurityBoundaryConditions(): void {
// Test edge cases for URL validation.
$edgeCaseUrls = [
'https://example.com/../../../etc/passwd',
'https://example.com/file.jpg?param=../../etc/passwd',
'https://example.com:80/../file.jpg',
'https://user:pass@example.com/file.jpg',
];
foreach ($edgeCaseUrls as $url) {
$errors = $this->securityValidationService->validateUrl($url);
// Should not crash, may or may not pass depending on URL structure.
$this->assertIsArray($errors);
}
// Test empty/null inputs.
$errors = $this->securityValidationService->validateFileType('', '');
$this->assertIsArray($errors);
$errors = $this->securityValidationService->validateFileSize(0);
// Zero size should be allowed.
$this->assertEmpty($errors);
// Test maximum integer file size.
$errors = $this->securityValidationService->validateFileSize(PHP_INT_MAX);
// Should exceed any reasonable limit.
$this->assertNotEmpty($errors);
}
/**
* Tests performance with concurrent URL validations.
*
* @covers ::validateUrl
*/
public function testConcurrentUrlValidationPerformance(): void {
$urls_to_test = [
'https://example.com/file1.jpg',
'https://test.org/file2.png',
'https://secure.site.com/file3.gif',
// Should be blocked.
'http://192.168.1.1/malicious.jpg',
'https://evil.com/file4.jpg',
];
$start_time = microtime(TRUE);
$results = [];
// Simulate concurrent processing by rapidly validating URLs.
foreach ($urls_to_test as $url) {
$results[$url] = $this->securityValidationService->validateUrl($url);
}
$end_time = microtime(TRUE);
$execution_time = $end_time - $start_time;
// Validation should complete quickly (under 1 second for 5 URLs).
$this->assertLessThan(1.0, $execution_time, 'URL validation should be performant');
// Verify results are correct.
$this->assertEmpty($results['https://example.com/file1.jpg'], 'Valid URL should pass');
$this->assertNotEmpty($results['http://192.168.1.1/malicious.jpg'], 'Private IP should be blocked');
// Ensure all results are arrays.
foreach ($results as $url => $errors) {
$this->assertIsArray($errors, "Results for {$url} should be array");
}
}
/**
* Tests file size validation under stress conditions.
*
* @covers ::validateFileSize
*/
public function testFileSizeValidationStress(): void {
$config = $this->config('image_to_media_swapper.security_settings');
// 10MB limit
$config->set('max_file_size', 10)->save();
$test_sizes = [
// Normal sizes (should pass).
// 1KB.
1024,
// 1MB
1024 * 1024,
// 5MB
5 * 1024 * 1024,
// Boundary sizes.
// Just under limit.
(10 * 1024 * 1024) - 1,
// Exactly at limit.
10 * 1024 * 1024,
// Just over limit.
(10 * 1024 * 1024) + 1,
// Stress sizes.
// 100MB.
100 * 1024 * 1024,
// 1GB
1024 * 1024 * 1024,
// Maximum integer.
PHP_INT_MAX,
// Edge cases.
0,
-1,
];
$start_time = microtime(TRUE);
$results = [];
foreach ($test_sizes as $size) {
$results[$size] = $this->securityValidationService->validateFileSize($size);
}
$end_time = microtime(TRUE);
$execution_time = $end_time - $start_time;
// File size validation should be extremely fast.
$this->assertLessThan(0.1, $execution_time, 'File size validation should be very fast');
// Verify boundary conditions.
$this->assertEmpty($results[5 * 1024 * 1024], '5MB should pass');
$this->assertEmpty($results[(10 * 1024 * 1024) - 1], 'Just under limit should pass');
$this->assertNotEmpty($results[(10 * 1024 * 1024) + 1], 'Just over limit should fail');
$this->assertNotEmpty($results[100 * 1024 * 1024], '100MB should fail');
$this->assertNotEmpty($results[-1], 'Negative size should fail');
}
/**
* Tests bulk file type validation for performance.
*
* @covers ::validateFileType
*/
public function testBulkFileTypeValidationPerformance(): void {
$config = $this->config('image_to_media_swapper.security_settings');
$config->set('allowed_extensions_array', ['jpg', 'png', 'gif']);
$config->set('allowed_mime_types_array', ['image/jpeg', 'image/png', 'image/gif']);
$config->save();
$test_files = [
// Valid files.
['image/jpeg', 'photo.jpg'],
['image/png', 'screenshot.png'],
['image/gif', 'animation.gif'],
// Invalid files.
['application/pdf', 'document.pdf'],
['text/html', 'malicious.html'],
['application/x-executable', 'virus.exe'],
['text/x-php', 'shell.php'],
// Edge cases.
['', ''],
['image/jpeg', ''],
['', 'file.jpg'],
];
$start_time = microtime(TRUE);
$results = [];
foreach ($test_files as [$mime, $filename]) {
$key = "{$mime}:{$filename}";
$results[$key] = $this->securityValidationService->validateFileType($mime, $filename);
}
$end_time = microtime(TRUE);
$execution_time = $end_time - $start_time;
// File type validation should be fast.
$this->assertLessThan(0.5, $execution_time, 'Bulk file type validation should be performant');
// Verify some key results.
$this->assertEmpty($results['image/jpeg:photo.jpg'], 'Valid JPEG should pass');
$this->assertNotEmpty($results['text/html:malicious.html'], 'HTML should be blocked');
$this->assertNotEmpty($results['application/x-executable:virus.exe'], 'Executable should be blocked');
}
/**
* Tests memory usage during intensive validation operations.
*
* @covers ::validateUrl
* @covers ::validateFileType
* @covers ::validateFileSize
*/
public function testMemoryUsageDuringIntensiveOperations(): void {
$initial_memory = memory_get_usage(TRUE);
// Generate many validation operations.
for ($i = 0; $i < 1000; $i++) {
// URL validation.
$this->securityValidationService->validateUrl("https://example{$i}.com/file.jpg");
// File type validation.
$this->securityValidationService->validateFileType('image/jpeg', "file{$i}.jpg");
// File size validation.
$this->securityValidationService->validateFileSize($i * 1024);
}
$final_memory = memory_get_usage(TRUE);
$memory_increase = $final_memory - $initial_memory;
// Memory usage should not increase dramatically (less than 10MB).
$this->assertLessThan(10 * 1024 * 1024, $memory_increase,
'Memory usage should not increase dramatically during intensive operations');
}
/**
* Tests validation consistency under repeated operations.
*
* @covers ::validateUrl
* @covers ::validateFileType
* @covers ::validateFileSize
*/
public function testValidationConsistency(): void {
$test_cases = [
['url' => 'https://example.com/test.jpg'],
['mime' => 'image/jpeg', 'filename' => 'test.jpg'],
// 5MB
['size' => 5 * 1024 * 1024],
];
foreach ($test_cases as $test_case) {
$results = [];
// Run the same validation 10 times.
for ($i = 0; $i < 10; $i++) {
if (isset($test_case['url'])) {
$results[] = $this->securityValidationService->validateUrl($test_case['url']);
}
elseif (isset($test_case['mime'])) {
$results[] = $this->securityValidationService->validateFileType($test_case['mime'], $test_case['filename']);
}
elseif (isset($test_case['size'])) {
$results[] = $this->securityValidationService->validateFileSize($test_case['size']);
}
}
// All results should be identical.
$first_result = $results[0];
foreach ($results as $i => $result) {
$this->assertEquals($first_result, $result,
"Validation result {$i} should be consistent with first result");
}
}
}
/**
* Tests behavior under malformed input attacks.
*
* @covers ::validateUrl
* @covers ::validateFileType
*/
public function testMalformedInputHandling(): void {
$malformed_urls = [
// Malformed URLs that might cause issues.
'http://',
'https://',
'://example.com',
'http://.',
'http://256.256.256.256/',
'http://example..com/',
'http://-example.com/',
'http://example-.com/',
// Very long URL.
str_repeat('a', 10000) . '.com',
// Null bytes.
"http://example.com/\x00\x01\x02",
];
foreach ($malformed_urls as $url) {
try {
$errors = $this->securityValidationService->validateUrl($url);
$this->assertIsArray($errors, "Malformed URL should not crash validator: {$url}");
}
catch (\Exception $e) {
$this->fail("Malformed URL caused exception: {$url} - " . $e->getMessage());
}
}
$malformed_files = [
// Malformed MIME types and filenames.
["\x00\x01\x02", "file.jpg"],
["image/jpeg", "\x00\x01\x02"],
// Very long MIME type.
[str_repeat('a', 1000), "file.jpg"],
// Very long filename.
["image/jpeg", str_repeat('b', 1000)],
["", ""],
[NULL, NULL],
];
foreach ($malformed_files as [$mime, $filename]) {
try {
$errors = $this->securityValidationService->validateFileType($mime, $filename);
$this->assertIsArray($errors, "Malformed file type should not crash validator");
}
catch (\TypeError $e) {
// NULL values might cause TypeErrors, which is acceptable.
$this->assertStringContainsString('null', strtolower($e->getMessage()));
}
catch (\Exception $e) {
$this->fail("Malformed file type caused unexpected exception: " . $e->getMessage());
}
}
}
}
