From c2f63f6121a07de8f784827388de358b067add20 Mon Sep 17 00:00:00 2001 From: Yasen Pramatarov Date: Wed, 19 Feb 2025 11:08:42 +0200 Subject: [PATCH] Adds security headers and CSRF protection tests --- app/includes/security_headers_middleware.php | 178 ++++++++++-------- .../Security/CsrfProtectionTest.php | 153 +++++++++++++++ .../Security/SecurityHeadersTest.php | 160 ++++++++++++++++ tests/framework/phpunit.xml | 3 + 4 files changed, 414 insertions(+), 80 deletions(-) create mode 100644 tests/framework/Integration/Security/CsrfProtectionTest.php create mode 100644 tests/framework/Integration/Security/SecurityHeadersTest.php diff --git a/app/includes/security_headers_middleware.php b/app/includes/security_headers_middleware.php index 329921c..f4ab237 100644 --- a/app/includes/security_headers_middleware.php +++ b/app/includes/security_headers_middleware.php @@ -12,88 +12,106 @@ * - Permissions-Policy: Control browser features */ -// Get current page -$current_page = $_GET['page'] ?? 'dashboard'; +function applySecurityHeaders($testMode = false) { + $headers = []; + + // Get current page + $current_page = $_GET['page'] ?? 'dashboard'; -// Define pages that need media access -$media_enabled_pages = [ - // 'conference' => ['camera', 'microphone'], - // 'call' => ['microphone'], - // Add more pages and their required permissions as needed -]; + // Define pages that need media access + $media_enabled_pages = [ + // 'conference' => ['camera', 'microphone'], + // 'call' => ['microphone'], + // Add more pages and their required permissions as needed + ]; -// Strict Transport Security (HSTS) -// Only enable if HTTPS is properly configured -if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { - header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'); -} - -// Content Security Policy (CSP) -$csp = [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Required for Bootstrap and jQuery - "style-src 'self' 'unsafe-inline' https://use.fontawesome.com", // Allow FontAwesome CSS - "img-src 'self' data:", // Allow data: URLs for images - "font-src 'self' https://use.fontawesome.com", // Allow FontAwesome fonts - "connect-src 'self'", - "frame-ancestors 'none'", // Equivalent to X-Frame-Options: DENY - "form-action 'self'", - "base-uri 'self'", - "upgrade-insecure-requests" // Force HTTPS for all requests -]; -header("Content-Security-Policy: " . implode('; ', $csp)); - -// X-Frame-Options (legacy support) -header('X-Frame-Options: DENY'); - -// X-Content-Type-Options -header('X-Content-Type-Options: nosniff'); - -// Referrer-Policy -header('Referrer-Policy: strict-origin-when-cross-origin'); - -// Permissions-Policy -$permissions = [ - 'geolocation=()', - 'payment=()', - 'usb=()', - 'accelerometer=()', - 'autoplay=()', - 'document-domain=()', - 'encrypted-media=()', - 'fullscreen=(self)', - 'magnetometer=()', - 'midi=()', - 'sync-xhr=(self)', - 'usb=()' -]; - -// Add camera/microphone permissions based on current page -$camera_allowed = false; -$microphone_allowed = false; - -if (isset($media_enabled_pages[$current_page])) { - $allowed_media = $media_enabled_pages[$current_page]; - if (in_array('camera', $allowed_media)) { - $camera_allowed = true; + // Strict Transport Security (HSTS) + // Only enable if HTTPS is properly configured + if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { + $headers[] = 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'; } - if (in_array('microphone', $allowed_media)) { - $microphone_allowed = true; + + // Content Security Policy (CSP) + $csp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Required for Bootstrap and jQuery + "style-src 'self' 'unsafe-inline' https://use.fontawesome.com", // Allow FontAwesome CSS + "img-src 'self' data:", // Allow data: URLs for images + "font-src 'self' https://use.fontawesome.com", // Allow FontAwesome fonts + "connect-src 'self'", + "frame-ancestors 'none'", // Equivalent to X-Frame-Options: DENY + "form-action 'self'", + "base-uri 'self'", + "upgrade-insecure-requests" // Force HTTPS for all requests + ]; + $headers[] = "Content-Security-Policy: " . implode('; ', $csp); + + // X-Frame-Options (legacy support) + $headers[] = 'X-Frame-Options: DENY'; + + // X-Content-Type-Options + $headers[] = 'X-Content-Type-Options: nosniff'; + + // X-XSS-Protection + $headers[] = 'X-XSS-Protection: 1; mode=block'; + + // Referrer-Policy + $headers[] = 'Referrer-Policy: strict-origin-when-cross-origin'; + + // Permissions-Policy + $permissions = [ + 'geolocation=()', + 'payment=()', + 'usb=()', + 'accelerometer=()', + 'autoplay=()', + 'document-domain=()', + 'encrypted-media=()', + 'fullscreen=(self)', + 'magnetometer=()', + 'midi=()', + 'sync-xhr=(self)', + 'usb=()' + ]; + + // Add camera/microphone permissions based on current page + $camera_allowed = false; + $microphone_allowed = false; + + if (isset($media_enabled_pages[$current_page])) { + $allowed_media = $media_enabled_pages[$current_page]; + if (in_array('camera', $allowed_media)) { + $camera_allowed = true; + } + if (in_array('microphone', $allowed_media)) { + $microphone_allowed = true; + } + } + + // Add media permissions + $permissions[] = $camera_allowed ? 'camera=(self)' : 'camera=()'; + $permissions[] = $microphone_allowed ? 'microphone=(self)' : 'microphone=()'; + + $headers[] = 'Permissions-Policy: ' . implode(', ', $permissions); + + // Clear PHP version + if (!$testMode) { + header_remove('X-Powered-By'); + } + + // Prevent caching of sensitive pages + if (in_array($current_page, ['login', 'register', 'profile', 'security'])) { + $headers[] = 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0'; + $headers[] = 'Pragma: no-cache'; + $headers[] = 'Expires: ' . gmdate('D, d M Y H:i:s', time() - 3600) . ' GMT'; + } + + if ($testMode) { + return $headers; + } + + // Apply headers in production + foreach ($headers as $header) { + header($header); } } - -// Add media permissions -$permissions[] = $camera_allowed ? 'camera=(self)' : 'camera=()'; -$permissions[] = $microphone_allowed ? 'microphone=(self)' : 'microphone=()'; - -header('Permissions-Policy: ' . implode(', ', $permissions)); - -// Clear PHP version -header_remove('X-Powered-By'); - -// Prevent caching of sensitive pages -if (in_array($current_page, ['login', 'register', 'profile', 'security'])) { - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); - header('Pragma: no-cache'); - header('Expires: ' . gmdate('D, d M Y H:i:s', time() - 3600) . ' GMT'); -} diff --git a/tests/framework/Integration/Security/CsrfProtectionTest.php b/tests/framework/Integration/Security/CsrfProtectionTest.php new file mode 100644 index 0000000..3e3d6a5 --- /dev/null +++ b/tests/framework/Integration/Security/CsrfProtectionTest.php @@ -0,0 +1,153 @@ + 200, 'message' => '']; + } + + // Skip CSRF check for initial login attempt + if ($_SERVER['REQUEST_METHOD'] === 'POST' && + isset($_GET['page']) && $_GET['page'] === 'login' && + !isset($_SESSION['username'])) { + return ['status' => 200, 'message' => '']; + } + + // Check CSRF token for all other POST requests + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $token = $_POST['csrf_token'] ?? ''; + if (!$security->verifyCsrfToken($token)) { + // Log CSRF attempt + $message = "CSRF attempt detected from IP: " . $_SERVER['REMOTE_ADDR']; + TestLogger::insertLog(0, $message, 'system'); + + // Return error message + return ['status' => 403, 'message' => $message]; + } + } + + return ['status' => 200, 'message' => '']; +} + +class CsrfProtectionTest extends TestCase +{ + private $security; + + protected function setUp(): void + { + parent::setUp(); + $this->security = \SecurityHelper::getInstance(); + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + } + + protected function tearDown(): void + { + parent::tearDown(); + unset($_SESSION['csrf_token']); + unset($_POST['csrf_token']); + unset($_GET['page']); + unset($_SESSION['username']); + } + + public function testCsrfProtectionValidToken() + { + // Generate CSRF token + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + $token = $_SESSION['csrf_token']; + + // Simulate POST request with valid token + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_token'] = $token; + + // Call CSRF middleware + $response = applyCsrfMiddleware(); + + // Assert that the response is success + $this->assertEquals(200, $response['status']); + $this->assertEmpty($response['message']); + } + + public function testCsrfProtectionInvalidToken() + { + // Simulate POST request with invalid token + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_token'] = 'invalid_token'; + + // Call CSRF middleware + $response = applyCsrfMiddleware(); + + // Assert that the response is forbidden with error message + $this->assertEquals(403, $response['status']); + $this->assertStringContainsString("CSRF attempt detected from IP", $response['message']); + } + + public function testCsrfProtectionGetRequest() + { + // Simulate GET request without token + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Call CSRF middleware + $response = applyCsrfMiddleware(); + + // Assert that GET requests are allowed without token + $this->assertEquals(200, $response['status']); + $this->assertEmpty($response['message']); + } + + public function testCsrfProtectionInitialLogin() + { + // Simulate initial login POST request + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_GET['page'] = 'login'; + + // Call CSRF middleware + $response = applyCsrfMiddleware(); + + // Assert that initial login is allowed without token + $this->assertEquals(200, $response['status']); + $this->assertEmpty($response['message']); + } + + public function testCsrfProtectionMissingToken() + { + // Simulate POST request without token + $_SERVER['REQUEST_METHOD'] = 'POST'; + + // Call CSRF middleware + $response = applyCsrfMiddleware(); + + // Assert that missing token is rejected + $this->assertEquals(403, $response['status']); + $this->assertStringContainsString("CSRF attempt detected from IP", $response['message']); + } + + public function testCsrfProtectionEmptyToken() + { + // Simulate POST request with empty token + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_token'] = ''; + + // Call CSRF middleware + $response = applyCsrfMiddleware(); + + // Assert that empty token is rejected + $this->assertEquals(403, $response['status']); + $this->assertStringContainsString("CSRF attempt detected from IP", $response['message']); + } +} diff --git a/tests/framework/Integration/Security/SecurityHeadersTest.php b/tests/framework/Integration/Security/SecurityHeadersTest.php new file mode 100644 index 0000000..2b80561 --- /dev/null +++ b/tests/framework/Integration/Security/SecurityHeadersTest.php @@ -0,0 +1,160 @@ +assertContains('X-Frame-Options: DENY', $headers); + $this->assertContains('X-XSS-Protection: 1; mode=block', $headers); + $this->assertContains('X-Content-Type-Options: nosniff', $headers); + $this->assertContains('Referrer-Policy: strict-origin-when-cross-origin', $headers); + } + + public function testContentSecurityPolicy() + { + // Apply security headers in test mode + $headers = \applySecurityHeaders(true); + + // Get CSP header + $cspHeader = ''; + foreach ($headers as $header) { + if (strpos($header, 'Content-Security-Policy:') === 0) { + $cspHeader = $header; + break; + } + } + + // Check CSP directives + $this->assertStringContainsString("default-src 'self'", $cspHeader); + $this->assertStringContainsString("script-src 'self' 'unsafe-inline' 'unsafe-eval'", $cspHeader); + $this->assertStringContainsString("style-src 'self' 'unsafe-inline'", $cspHeader); + $this->assertStringContainsString("frame-ancestors 'none'", $cspHeader); + $this->assertStringContainsString("form-action 'self'", $cspHeader); + $this->assertStringContainsString("base-uri 'self'", $cspHeader); + } + + public function testHstsHeader() + { + // Simulate HTTPS + $_SERVER['HTTPS'] = 'on'; + + // Apply security headers in test mode + $headers = \applySecurityHeaders(true); + + // Check HSTS header + $this->assertContains( + 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload', + $headers + ); + } + + public function testNoHstsHeaderOnHttp() + { + // Apply security headers in test mode + $headers = \applySecurityHeaders(true); + + // Check HSTS header is not present + $hasHsts = false; + foreach ($headers as $header) { + if (strpos($header, 'Strict-Transport-Security:') === 0) { + $hasHsts = true; + break; + } + } + $this->assertFalse($hasHsts, 'HSTS header should not be present on HTTP'); + } + + public function testCacheControlForSensitivePages() + { + $sensitivePages = ['login', 'register', 'profile', 'security']; + + foreach ($sensitivePages as $page) { + // Set current page + $_GET['page'] = $page; + + // Apply security headers in test mode + $headers = \applySecurityHeaders(true); + + // Check cache control headers + $this->assertContains('Cache-Control: no-store, no-cache, must-revalidate, max-age=0', $headers); + $this->assertContains('Pragma: no-cache', $headers); + $this->assertStringContainsString('Expires:', implode(' ', $headers)); + } + } + + public function testNoCacheControlForNonSensitivePages() + { + $_GET['page'] = 'dashboard'; + + // Apply security headers in test mode + $headers = \applySecurityHeaders(true); + + // Check cache control headers are not present + $this->assertNotContains('Cache-Control: no-store, no-cache, must-revalidate, max-age=0', $headers); + $this->assertNotContains('Pragma: no-cache', $headers); + } + + public function testPermissionsPolicy() + { + // Apply security headers in test mode + $headers = \applySecurityHeaders(true); + + // Get Permissions-Policy header + $permissionsHeader = ''; + foreach ($headers as $header) { + if (strpos($header, 'Permissions-Policy:') === 0) { + $permissionsHeader = $header; + break; + } + } + + // Check basic permissions + $this->assertStringContainsString('geolocation=()', $permissionsHeader); + $this->assertStringContainsString('payment=()', $permissionsHeader); + $this->assertStringContainsString('camera=()', $permissionsHeader); + $this->assertStringContainsString('microphone=()', $permissionsHeader); + $this->assertStringContainsString('fullscreen=(self)', $permissionsHeader); + $this->assertStringContainsString('sync-xhr=(self)', $permissionsHeader); + } + + public function testPermissionsPolicyForMediaEnabledPages() + { + $_SERVER['REQUEST_URI'] = '/media/upload'; + + // Apply security headers in test mode + $headers = \applySecurityHeaders(true); + + // Get Permissions-Policy header + $permissionsHeader = ''; + foreach ($headers as $header) { + if (strpos($header, 'Permissions-Policy:') === 0) { + $permissionsHeader = $header; + break; + } + } + + // Check permissions policy header + $this->assertStringContainsString('camera=()', $permissionsHeader); + $this->assertStringContainsString('microphone=()', $permissionsHeader); + $this->assertStringContainsString('geolocation=()', $permissionsHeader); + $this->assertStringContainsString('payment=()', $permissionsHeader); + } +} diff --git a/tests/framework/phpunit.xml b/tests/framework/phpunit.xml index 706b87e..26cbf9d 100644 --- a/tests/framework/phpunit.xml +++ b/tests/framework/phpunit.xml @@ -8,6 +8,9 @@ ./Unit + + ./Integration +