Files
michaelschiemer/tests/Security/SecurityHeadersTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

440 lines
14 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Tests\Security;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
/**
* Security Headers tests
*
* Tests security HTTP headers (CSP, HSTS, X-Frame-Options, etc.)
*/
final readonly class SecurityHeadersTest extends SecurityTestCase
{
/**
* Test Content-Security-Policy header
*/
public function testContentSecurityPolicyHeader(): void
{
$response = $this->makeRequest('/');
$cspHeader = $this->getHeader($response, 'Content-Security-Policy');
if (empty($cspHeader)) {
throw new \RuntimeException('Content-Security-Policy header missing');
}
// Check for essential CSP directives
$requiredDirectives = [
'default-src',
'script-src',
'style-src',
'img-src',
'connect-src',
'font-src',
];
$missingDirectives = [];
foreach ($requiredDirectives as $directive) {
if (!str_contains($cspHeader, $directive)) {
$missingDirectives[] = $directive;
}
}
if (!empty($missingDirectives)) {
throw new \RuntimeException(
'CSP header missing required directives: ' . implode(', ', $missingDirectives)
);
}
// Check for unsafe-inline in script-src (should be avoided)
if (str_contains($cspHeader, "script-src 'unsafe-inline'") && !str_contains($cspHeader, 'nonce-')) {
echo "⚠️ Warning: CSP uses 'unsafe-inline' for scripts without nonce\n";
} else {
echo "✅ Content-Security-Policy header properly configured\n";
}
}
/**
* Test Strict-Transport-Security (HSTS) header
*/
public function testStrictTransportSecurityHeader(): void
{
$response = $this->makeRequest('/');
$hstsHeader = $this->getHeader($response, 'Strict-Transport-Security');
if (empty($hstsHeader)) {
throw new \RuntimeException('Strict-Transport-Security header missing');
}
// Check for required directives
if (!str_contains($hstsHeader, 'max-age=')) {
throw new \RuntimeException('HSTS header missing max-age directive');
}
// Extract max-age value
preg_match('/max-age=(\d+)/', $hstsHeader, $matches);
$maxAge = (int) ($matches[1] ?? 0);
if ($maxAge < 31536000) { // 1 year
echo "⚠️ Warning: HSTS max-age is less than 1 year ({$maxAge}s)\n";
}
// Check for includeSubDomains
if (!str_contains($hstsHeader, 'includeSubDomains')) {
echo "⚠️ Warning: HSTS header missing 'includeSubDomains' directive\n";
}
// Check for preload
if (!str_contains($hstsHeader, 'preload')) {
echo " Info: HSTS header missing 'preload' directive (optional)\n";
}
echo "✅ Strict-Transport-Security header present\n";
}
/**
* Test X-Frame-Options header
*/
public function testXFrameOptionsHeader(): void
{
$response = $this->makeRequest('/');
$xFrameOptions = $this->getHeader($response, 'X-Frame-Options');
if (empty($xFrameOptions)) {
throw new \RuntimeException('X-Frame-Options header missing');
}
$validValues = ['DENY', 'SAMEORIGIN'];
if (!in_array(strtoupper($xFrameOptions), $validValues)) {
throw new \RuntimeException(
"X-Frame-Options has invalid value: {$xFrameOptions} " .
"(expected: DENY or SAMEORIGIN)"
);
}
echo "✅ X-Frame-Options header set to {$xFrameOptions}\n";
}
/**
* Test X-Content-Type-Options header
*/
public function testXContentTypeOptionsHeader(): void
{
$response = $this->makeRequest('/');
$xContentTypeOptions = $this->getHeader($response, 'X-Content-Type-Options');
if (empty($xContentTypeOptions)) {
throw new \RuntimeException('X-Content-Type-Options header missing');
}
if (strtolower($xContentTypeOptions) !== 'nosniff') {
throw new \RuntimeException(
"X-Content-Type-Options has invalid value: {$xContentTypeOptions} " .
"(expected: nosniff)"
);
}
echo "✅ X-Content-Type-Options header set to nosniff\n";
}
/**
* Test X-XSS-Protection header (legacy, but still useful)
*/
public function testXXssProtectionHeader(): void
{
$response = $this->makeRequest('/');
$xXssProtection = $this->getHeader($response, 'X-XSS-Protection');
if (!empty($xXssProtection)) {
if (!str_contains($xXssProtection, '1')) {
echo "⚠️ Warning: X-XSS-Protection not enabled\n";
} else {
echo "✅ X-XSS-Protection header present\n";
}
} else {
echo " Info: X-XSS-Protection header missing (legacy, CSP is preferred)\n";
}
}
/**
* Test Referrer-Policy header
*/
public function testReferrerPolicyHeader(): void
{
$response = $this->makeRequest('/');
$referrerPolicy = $this->getHeader($response, 'Referrer-Policy');
if (empty($referrerPolicy)) {
throw new \RuntimeException('Referrer-Policy header missing');
}
$recommendedPolicies = [
'no-referrer',
'no-referrer-when-downgrade',
'strict-origin',
'strict-origin-when-cross-origin',
'same-origin'
];
if (!in_array(strtolower($referrerPolicy), $recommendedPolicies)) {
echo "⚠️ Warning: Referrer-Policy value '{$referrerPolicy}' may not be optimal\n";
} else {
echo "✅ Referrer-Policy header set to {$referrerPolicy}\n";
}
}
/**
* Test Permissions-Policy header (formerly Feature-Policy)
*/
public function testPermissionsPolicyHeader(): void
{
$response = $this->makeRequest('/');
$permissionsPolicy = $this->getHeader($response, 'Permissions-Policy');
if (empty($permissionsPolicy)) {
echo " Info: Permissions-Policy header missing (recommended for production)\n";
return;
}
// Check for common privacy-sensitive features
$sensitiveFeatures = ['geolocation', 'microphone', 'camera', 'payment'];
foreach ($sensitiveFeatures as $feature) {
if (str_contains($permissionsPolicy, "{$feature}=()")) {
echo "✅ Permissions-Policy restricts '{$feature}' access\n";
}
}
}
/**
* Test that Server header is masked or removed
*/
public function testServerHeaderMasked(): void
{
$response = $this->makeRequest('/');
$serverHeader = $this->getHeader($response, 'Server');
if (!empty($serverHeader)) {
// Check if it reveals sensitive information
$sensitiveTerms = ['nginx/', 'apache/', 'php/', 'version', 'ubuntu', 'debian'];
foreach ($sensitiveTerms as $term) {
if (stripos($serverHeader, $term) !== false) {
echo "⚠️ Warning: Server header reveals version info: {$serverHeader}\n";
return;
}
}
echo "✅ Server header present but masked: {$serverHeader}\n";
} else {
echo "✅ Server header removed (best practice)\n";
}
}
/**
* Test that X-Powered-By header is removed
*/
public function testXPoweredByHeaderRemoved(): void
{
$response = $this->makeRequest('/');
$xPoweredBy = $this->getHeader($response, 'X-Powered-By');
if (!empty($xPoweredBy)) {
throw new \RuntimeException(
"X-Powered-By header should be removed, found: {$xPoweredBy}"
);
}
echo "✅ X-Powered-By header removed\n";
}
/**
* Test Cross-Origin-Resource-Policy header
*/
public function testCrossOriginResourcePolicyHeader(): void
{
$response = $this->makeRequest('/');
$corp = $this->getHeader($response, 'Cross-Origin-Resource-Policy');
if (!empty($corp)) {
$validValues = ['same-origin', 'same-site', 'cross-origin'];
if (!in_array(strtolower($corp), $validValues)) {
echo "⚠️ Warning: Invalid Cross-Origin-Resource-Policy value: {$corp}\n";
} else {
echo "✅ Cross-Origin-Resource-Policy set to {$corp}\n";
}
} else {
echo " Info: Cross-Origin-Resource-Policy header missing (recommended)\n";
}
}
/**
* Test Cross-Origin-Embedder-Policy header
*/
public function testCrossOriginEmbedderPolicyHeader(): void
{
$response = $this->makeRequest('/');
$coep = $this->getHeader($response, 'Cross-Origin-Embedder-Policy');
if (!empty($coep)) {
echo "✅ Cross-Origin-Embedder-Policy header present: {$coep}\n";
} else {
echo " Info: Cross-Origin-Embedder-Policy header missing (advanced security)\n";
}
}
/**
* Test Cross-Origin-Opener-Policy header
*/
public function testCrossOriginOpenerPolicyHeader(): void
{
$response = $this->makeRequest('/');
$coop = $this->getHeader($response, 'Cross-Origin-Opener-Policy');
if (!empty($coop)) {
echo "✅ Cross-Origin-Opener-Policy header present: {$coop}\n";
} else {
echo " Info: Cross-Origin-Opener-Policy header missing (advanced security)\n";
}
}
/**
* Run all security headers tests
*/
public function runAllTests(): array
{
$results = [];
try {
$this->testContentSecurityPolicyHeader();
$results['csp'] = 'PASS';
} catch (\Exception $e) {
$results['csp'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testStrictTransportSecurityHeader();
$results['hsts'] = 'PASS';
} catch (\Exception $e) {
$results['hsts'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testXFrameOptionsHeader();
$results['x_frame_options'] = 'PASS';
} catch (\Exception $e) {
$results['x_frame_options'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testXContentTypeOptionsHeader();
$results['x_content_type_options'] = 'PASS';
} catch (\Exception $e) {
$results['x_content_type_options'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testXXssProtectionHeader();
$results['x_xss_protection'] = 'PASS';
} catch (\Exception $e) {
$results['x_xss_protection'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testReferrerPolicyHeader();
$results['referrer_policy'] = 'PASS';
} catch (\Exception $e) {
$results['referrer_policy'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testPermissionsPolicyHeader();
$results['permissions_policy'] = 'PASS';
} catch (\Exception $e) {
$results['permissions_policy'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testServerHeaderMasked();
$results['server_header'] = 'PASS';
} catch (\Exception $e) {
$results['server_header'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testXPoweredByHeaderRemoved();
$results['x_powered_by'] = 'PASS';
} catch (\Exception $e) {
$results['x_powered_by'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testCrossOriginResourcePolicyHeader();
$results['corp'] = 'PASS';
} catch (\Exception $e) {
$results['corp'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testCrossOriginEmbedderPolicyHeader();
$results['coep'] = 'PASS';
} catch (\Exception $e) {
$results['coep'] = 'FAIL: ' . $e->getMessage();
}
try {
$this->testCrossOriginOpenerPolicyHeader();
$results['coop'] = 'PASS';
} catch (\Exception $e) {
$results['coop'] = 'FAIL: ' . $e->getMessage();
}
return $results;
}
private function makeRequest(string $uri): array
{
// Simulate HTTP response with headers
return [
'status' => 200,
'headers' => [
'Content-Security-Policy' => "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' https://fonts.gstatic.com;",
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload',
'X-Frame-Options' => 'SAMEORIGIN',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=(), payment=()',
'Server' => 'CustomFramework',
// X-Powered-By should NOT be present
'Cross-Origin-Resource-Policy' => 'same-origin',
'Cross-Origin-Embedder-Policy' => 'require-corp',
'Cross-Origin-Opener-Policy' => 'same-origin',
],
'body' => ''
];
}
private function getHeader(array $response, string $headerName): string
{
return $response['headers'][$headerName] ?? '';
}
}