Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
233
tests/Framework/Security/AuthenticationSecurityTest.php
Normal file
233
tests/Framework/Security/AuthenticationSecurityTest.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Security;
|
||||
|
||||
use App\Framework\Auth\Auth;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Middlewares\AuthMiddleware;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Critical Security Tests for Authentication
|
||||
* Tests gegen OWASP A07:2021 – Identification and Authentication Failures
|
||||
*/
|
||||
final class AuthenticationSecurityTest extends TestCase
|
||||
{
|
||||
private SessionInterface $session;
|
||||
|
||||
private Auth $auth;
|
||||
|
||||
private AuthMiddleware $middleware;
|
||||
|
||||
private RequestStateManager $stateManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->session = $this->createMock(SessionInterface::class);
|
||||
$this->auth = $this->createMock(Auth::class);
|
||||
$this->middleware = new AuthMiddleware($this->auth);
|
||||
$this->stateManager = $this->createMock(RequestStateManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Nicht-authentifizierte Benutzer werden blockiert
|
||||
* OWASP A07:2021 – Identification and Authentication Failures
|
||||
*/
|
||||
public function test_blocks_unauthenticated_access(): void
|
||||
{
|
||||
// Arrange: Benutzer nicht authentifiziert
|
||||
$this->auth->method('isAuthenticated')->willReturn(false);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->expects($this->never())->method('__invoke');
|
||||
|
||||
// Act & Assert: Exception erwartet
|
||||
$this->expectException(\App\Framework\Http\Exception\HttpException::class);
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Authentifizierte Benutzer werden durchgelassen
|
||||
*/
|
||||
public function test_allows_authenticated_access(): void
|
||||
{
|
||||
// Arrange: Benutzer authentifiziert
|
||||
$this->auth->method('isAuthenticated')->willReturn(true);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->expects($this->once())->method('__invoke')->willReturn($context);
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Session Hijacking Schutz
|
||||
* Validiert Session-Fingerprinting
|
||||
*/
|
||||
public function test_session_fingerprint_validation(): void
|
||||
{
|
||||
// Arrange: Authentifiziert aber Session-Fingerprint geändert
|
||||
$this->auth->method('isAuthenticated')->willReturn(true);
|
||||
$this->auth->method('validateSessionFingerprint')->willReturn(false);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: Session Hijacking erkannt
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Session fingerprint validation failed');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Concurrent Login Detection
|
||||
* Erkennt verdächtige gleichzeitige Logins
|
||||
*/
|
||||
public function test_concurrent_login_detection(): void
|
||||
{
|
||||
// Arrange: Benutzer von verschiedenen IPs eingeloggt
|
||||
$this->auth->method('isAuthenticated')->willReturn(true);
|
||||
$this->auth->method('validateSessionFingerprint')->willReturn(true);
|
||||
$this->auth->method('detectConcurrentSessions')->willReturn(true);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: Concurrent Session erkannt
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Concurrent session detected');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Session Timeout Validierung
|
||||
*/
|
||||
public function test_session_timeout_validation(): void
|
||||
{
|
||||
// Arrange: Session abgelaufen
|
||||
$this->auth->method('isAuthenticated')->willReturn(true);
|
||||
$this->auth->method('isSessionExpired')->willReturn(true);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: Session Timeout
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Session expired');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Rate Limiting für Authentication Failures
|
||||
* Verhindert Brute Force Attacks
|
||||
*/
|
||||
public function test_authentication_rate_limiting(): void
|
||||
{
|
||||
// Arrange: Zu viele fehlgeschlagene Login-Versuche
|
||||
$this->auth->method('isAuthenticated')->willReturn(false);
|
||||
$this->auth->method('isRateLimited')->willReturn(true);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: Rate Limit erreicht
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Authentication rate limit exceeded');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: IP Whitelist Validation
|
||||
* Admin-Bereiche nur von bestimmten IPs
|
||||
*/
|
||||
public function test_ip_whitelist_validation(): void
|
||||
{
|
||||
// Arrange: IP nicht in Whitelist
|
||||
$this->auth->method('isAuthenticated')->willReturn(true);
|
||||
$this->auth->method('isIpWhitelisted')->willReturn(false);
|
||||
|
||||
$request = $this->createRequestWithIp('192.168.1.100');
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: IP nicht erlaubt
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Access from this IP address is not allowed');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Account Lockout nach fehlgeschlagenen Versuchen
|
||||
*/
|
||||
public function test_account_lockout_after_failed_attempts(): void
|
||||
{
|
||||
// Arrange: Account gesperrt nach zu vielen Versuchen
|
||||
$this->auth->method('isAuthenticated')->willReturn(false);
|
||||
$this->auth->method('isAccountLocked')->willReturn(true);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: Account gesperrt
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Account is locked due to too many failed attempts');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Two-Factor Authentication Validation
|
||||
*/
|
||||
public function test_two_factor_authentication_required(): void
|
||||
{
|
||||
// Arrange: 2FA erforderlich aber nicht bereitgestellt
|
||||
$this->auth->method('isAuthenticated')->willReturn(true);
|
||||
$this->auth->method('requires2FA')->willReturn(true);
|
||||
$this->auth->method('is2FAValid')->willReturn(false);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: 2FA erforderlich
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Two-factor authentication required');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
private function createRequestWithIp(string $ip): Request
|
||||
{
|
||||
$request = $this->createMock(Request::class);
|
||||
$request->method('getClientIp')->willReturn($ip);
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
230
tests/Framework/Security/CsrfSecurityTest.php
Normal file
230
tests/Framework/Security/CsrfSecurityTest.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Security;
|
||||
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Middlewares\CsrfMiddleware;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Session\Session;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\Security\CsrfToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Critical Security Tests for CSRF Protection
|
||||
* Tests gegen OWASP A01:2021 – Broken Access Control
|
||||
*/
|
||||
final class CsrfSecurityTest extends TestCase
|
||||
{
|
||||
private SessionInterface $session;
|
||||
|
||||
private CsrfMiddleware $middleware;
|
||||
|
||||
private RequestStateManager $stateManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->session = $this->createMock(SessionInterface::class);
|
||||
$this->middleware = new CsrfMiddleware($this->session);
|
||||
$this->stateManager = $this->createMock(RequestStateManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: CSRF-Schutz für state-changing Operations
|
||||
* OWASP A01:2021 – Broken Access Control
|
||||
*/
|
||||
public function test_csrf_protection_blocks_post_without_token(): void
|
||||
{
|
||||
// Arrange: Session ist gestartet aber kein CSRF Token
|
||||
$this->session->method('isStarted')->willReturn(true);
|
||||
|
||||
$request = $this->createPostRequest([], []);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: Exception erwartet
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('CSRF protection requires both form ID and token');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: CSRF-Token Validation gegen Token-Forgery
|
||||
*/
|
||||
public function test_csrf_validation_rejects_invalid_token(): void
|
||||
{
|
||||
// Arrange: Ungültiges Token
|
||||
$this->session->method('isStarted')->willReturn(true);
|
||||
|
||||
$csrf = $this->createMock(\App\Framework\Http\Session\CsrfProtection::class);
|
||||
$csrf->method('validateToken')->willReturn(false);
|
||||
$this->session->csrf = $csrf;
|
||||
|
||||
$request = $this->createPostRequest([
|
||||
'_form_id' => 'login_form',
|
||||
'_token' => 'invalid_token_value',
|
||||
], []);
|
||||
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('CSRF token validation failed');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Valide CSRF-Token werden akzeptiert
|
||||
*/
|
||||
public function test_csrf_validation_accepts_valid_token(): void
|
||||
{
|
||||
// Arrange: Valides Token
|
||||
$this->session->method('isStarted')->willReturn(true);
|
||||
|
||||
$csrf = $this->createMock(\App\Framework\Http\Session\CsrfProtection::class);
|
||||
$csrf->method('validateToken')
|
||||
->with('login_form', $this->isInstanceOf(CsrfToken::class))
|
||||
->willReturn(true);
|
||||
$this->session->csrf = $csrf;
|
||||
|
||||
$request = $this->createPostRequest([
|
||||
'_form_id' => 'login_form',
|
||||
'_token' => 'valid_token_abc123',
|
||||
], []);
|
||||
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->expects($this->once())->method('__invoke')->willReturn($context);
|
||||
|
||||
// Act: Sollte ohne Exception durchlaufen
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: GET-Requests benötigen keinen CSRF-Schutz
|
||||
*/
|
||||
public function test_get_requests_bypass_csrf_validation(): void
|
||||
{
|
||||
// Arrange: GET Request ohne Token
|
||||
$this->session->method('isStarted')->willReturn(true);
|
||||
|
||||
$request = $this->createGetRequest();
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->expects($this->once())->method('__invoke')->willReturn($context);
|
||||
|
||||
// Act: Sollte ohne Validation durchlaufen
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Session muss vor CSRF-Validation gestartet sein
|
||||
* Verhindert Race Conditions
|
||||
*/
|
||||
public function test_requires_started_session(): void
|
||||
{
|
||||
// Arrange: Session nicht gestartet
|
||||
$this->session->method('isStarted')->willReturn(false);
|
||||
|
||||
$request = $this->createPostRequest(['_token' => 'abc'], []);
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Session must be started before CSRF validation');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: CSRF-Token über HTTP Headers (AJAX)
|
||||
*/
|
||||
public function test_csrf_token_via_headers(): void
|
||||
{
|
||||
// Arrange: Token über Headers statt Form Data
|
||||
$this->session->method('isStarted')->willReturn(true);
|
||||
|
||||
$csrf = $this->createMock(\App\Framework\Http\Session\CsrfProtection::class);
|
||||
$csrf->method('validateToken')->willReturn(true);
|
||||
$this->session->csrf = $csrf;
|
||||
|
||||
$request = $this->createPostRequest([], [
|
||||
'X-CSRF-Form-ID' => 'ajax_form',
|
||||
'X-CSRF-Token' => 'header_token_xyz',
|
||||
]);
|
||||
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->expects($this->once())->method('__invoke')->willReturn($context);
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$this->assertSame($context, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Malformed CSRF Token wird abgelehnt
|
||||
*/
|
||||
public function test_malformed_csrf_token_rejected(): void
|
||||
{
|
||||
// Arrange: Malformed Token
|
||||
$this->session->method('isStarted')->willReturn(true);
|
||||
|
||||
$request = $this->createPostRequest([
|
||||
'_form_id' => 'form1',
|
||||
'_token' => 'malformed token with spaces and @#$%',
|
||||
], []);
|
||||
|
||||
$context = new MiddlewareContext($request);
|
||||
$next = $this->createMock(Next::class);
|
||||
|
||||
// Act & Assert: Exception bei malformed Token
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid CSRF token format');
|
||||
|
||||
$this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
private function createPostRequest(array $body, array $headers): Request
|
||||
{
|
||||
$request = $this->createMock(Request::class);
|
||||
$request->method = Method::POST;
|
||||
|
||||
$parsedBody = $this->createMock(\App\Framework\Http\RequestBody::class);
|
||||
$parsedBody->method('get')->willReturnCallback(fn ($key) => $body[$key] ?? null);
|
||||
$request->parsedBody = $parsedBody;
|
||||
|
||||
$headersBag = $this->createMock(\App\Framework\Http\Headers::class);
|
||||
$headersBag->method('getFirst')->willReturnCallback(fn ($key) => $headers[$key] ?? null);
|
||||
$request->headers = $headersBag;
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function createGetRequest(): Request
|
||||
{
|
||||
$request = $this->createMock(Request::class);
|
||||
$request->method = Method::GET;
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
138
tests/Framework/Security/RequestSigning/RequestSignerTest.php
Normal file
138
tests/Framework/Security/RequestSigning/RequestSignerTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\RequestManipulator;
|
||||
use App\Framework\Security\RequestSigning\RequestSigner;
|
||||
use App\Framework\Security\RequestSigning\SigningAlgorithm;
|
||||
use App\Framework\Security\RequestSigning\SigningKey;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RequestSignerTest extends TestCase
|
||||
{
|
||||
private RequestSigner $signer;
|
||||
|
||||
private RequestManipulator $requestManipulator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->requestManipulator = new RequestManipulator();
|
||||
$this->signer = new RequestSigner($this->requestManipulator);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_sign_a_request_with_hmac_sha256(): void
|
||||
{
|
||||
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers(['Host' => 'example.com', 'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT'])
|
||||
);
|
||||
|
||||
$signature = $this->signer->sign($request, $key);
|
||||
|
||||
$this->assertEquals('test-key', $signature->keyId);
|
||||
$this->assertEquals(SigningAlgorithm::HMAC_SHA256, $signature->algorithm);
|
||||
$this->assertNotEmpty($signature->signature);
|
||||
$this->assertEquals(['(request-target)', 'host', 'date'], $signature->headers);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_sign_request_with_custom_headers(): void
|
||||
{
|
||||
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
|
||||
$request = new HttpRequest(
|
||||
method: Method::POST,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Host' => 'example.com',
|
||||
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
|
||||
'Content-Type' => 'application/json',
|
||||
]),
|
||||
body: '{"test": "data"}'
|
||||
);
|
||||
|
||||
$customHeaders = ['(request-target)', 'host', 'date', 'content-type'];
|
||||
$signature = $this->signer->sign($request, $key, $customHeaders);
|
||||
|
||||
$this->assertEquals($customHeaders, $signature->headers);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_create_digest_for_request_body(): void
|
||||
{
|
||||
$body = '{"test": "data"}';
|
||||
$digest = $this->signer->createDigest($body);
|
||||
|
||||
$expectedHash = base64_encode(hash('sha256', $body, true));
|
||||
$this->assertEquals("SHA256={$expectedHash}", $digest);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_sign_complete_request_with_body(): void
|
||||
{
|
||||
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
|
||||
$request = new HttpRequest(
|
||||
method: Method::POST,
|
||||
path: '/api/test',
|
||||
headers: new Headers(['Host' => 'example.com']),
|
||||
body: '{"test": "data"}'
|
||||
);
|
||||
|
||||
$signedRequest = $this->signer->signRequest($request, $key);
|
||||
|
||||
// Should have added Date and Digest headers
|
||||
$this->assertNotNull($signedRequest->headers->getFirst('Date'));
|
||||
$this->assertNotNull($signedRequest->headers->getFirst('Digest'));
|
||||
$this->assertNotNull($signedRequest->headers->getFirst('Signature'));
|
||||
|
||||
// Digest should be correct
|
||||
$expectedHash = base64_encode(hash('sha256', $request->body, true));
|
||||
$this->assertEquals("SHA256={$expectedHash}", $signedRequest->headers->getFirst('Digest'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_throws_exception_for_invalid_key(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Signing key is not valid');
|
||||
|
||||
$key = SigningKey::createHmac('expired-key', 'secret-that-is-long-enough-for-security', expiresAt: new \DateTimeImmutable('2020-01-01'));
|
||||
$request = new HttpRequest(method: Method::GET, path: '/test');
|
||||
|
||||
$this->signer->sign($request, $key);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_generate_rsa_signature(): void
|
||||
{
|
||||
// Generate test RSA key pair
|
||||
$keyPair = openssl_pkey_new([
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($keyPair, $privateKey);
|
||||
|
||||
$key = SigningKey::createRsa('rsa-test-key', $privateKey);
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers(['Host' => 'example.com', 'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT'])
|
||||
);
|
||||
|
||||
$signature = $this->signer->sign($request, $key);
|
||||
|
||||
$this->assertEquals('rsa-test-key', $signature->keyId);
|
||||
$this->assertEquals(SigningAlgorithm::RSA_SHA256, $signature->algorithm);
|
||||
$this->assertNotEmpty($signature->signature);
|
||||
}
|
||||
}
|
||||
191
tests/Framework/Security/RequestSigning/RequestVerifierTest.php
Normal file
191
tests/Framework/Security/RequestSigning/RequestVerifierTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Security\RequestSigning\InMemorySigningKeyRepository;
|
||||
use App\Framework\Security\RequestSigning\RequestVerifier;
|
||||
use App\Framework\Security\RequestSigning\SigningKey;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RequestVerifierTest extends TestCase
|
||||
{
|
||||
private RequestVerifier $verifier;
|
||||
|
||||
private InMemorySigningKeyRepository $keyRepository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->keyRepository = new InMemorySigningKeyRepository();
|
||||
$this->verifier = new RequestVerifier($this->keyRepository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_verify_valid_hmac_signature(): void
|
||||
{
|
||||
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
|
||||
$this->keyRepository->store($key);
|
||||
|
||||
// Manually create a valid signature for testing
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Host' => 'example.com',
|
||||
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
|
||||
'Signature' => 'keyId="test-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="mock-signature"',
|
||||
])
|
||||
);
|
||||
|
||||
// We need to mock the signature creation to match what would be generated
|
||||
$signingString = "(request-target): get /api/test\nhost: example.com\ndate: Thu, 05 Jan 2023 21:31:40 GMT";
|
||||
$expectedSignature = base64_encode(hash_hmac('sha256', $signingString, 'my-secret-key-that-is-long-enough-for-security', true));
|
||||
|
||||
$validRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Host' => 'example.com',
|
||||
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
|
||||
'Signature' => "keyId=\"test-key\",algorithm=\"hmac-sha256\",headers=\"(request-target) host date\",signature=\"{$expectedSignature}\"",
|
||||
])
|
||||
);
|
||||
|
||||
$result = $this->verifier->verify($validRequest);
|
||||
|
||||
$this->assertTrue($result->isSuccess());
|
||||
$this->assertEquals('test-key', $result->signature->keyId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_fails_verification_for_missing_signature(): void
|
||||
{
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers(['Host' => 'example.com'])
|
||||
);
|
||||
|
||||
$result = $this->verifier->verify($request);
|
||||
|
||||
$this->assertTrue($result->isFailure());
|
||||
$this->assertEquals('Missing Signature header', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_fails_verification_for_unknown_key(): void
|
||||
{
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Host' => 'example.com',
|
||||
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
|
||||
'Signature' => 'keyId="unknown-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="test-signature"',
|
||||
])
|
||||
);
|
||||
|
||||
$result = $this->verifier->verify($request);
|
||||
|
||||
$this->assertTrue($result->isFailure());
|
||||
$this->assertEquals('Unknown key ID: unknown-key', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_fails_verification_for_invalid_signature(): void
|
||||
{
|
||||
$key = SigningKey::createHmac('test-key', 'my-secret-key-that-is-long-enough-for-security');
|
||||
$this->keyRepository->store($key);
|
||||
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Host' => 'example.com',
|
||||
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
|
||||
'Signature' => 'keyId="test-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="invalid-signature"',
|
||||
])
|
||||
);
|
||||
|
||||
$result = $this->verifier->verify($request);
|
||||
|
||||
$this->assertTrue($result->isFailure());
|
||||
$this->assertEquals('Invalid signature', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_verify_digest_header(): void
|
||||
{
|
||||
$body = '{"test": "data"}';
|
||||
$expectedHash = base64_encode(hash('sha256', $body, true));
|
||||
|
||||
$request = new HttpRequest(
|
||||
method: Method::POST,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Digest' => "SHA256={$expectedHash}",
|
||||
]),
|
||||
body: $body
|
||||
);
|
||||
|
||||
$this->assertTrue($this->verifier->verifyDigest($request));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_fails_digest_verification_for_wrong_hash(): void
|
||||
{
|
||||
$request = new HttpRequest(
|
||||
method: Method::POST,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Digest' => 'SHA256=wrong-hash',
|
||||
]),
|
||||
body: '{"test": "data"}'
|
||||
);
|
||||
|
||||
$this->assertFalse($this->verifier->verifyDigest($request));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_passes_digest_verification_when_no_digest_header(): void
|
||||
{
|
||||
$request = new HttpRequest(
|
||||
method: Method::POST,
|
||||
path: '/api/test',
|
||||
body: '{"test": "data"}'
|
||||
);
|
||||
|
||||
$this->assertTrue($this->verifier->verifyDigest($request));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_fails_verification_for_expired_key(): void
|
||||
{
|
||||
$expiredKey = SigningKey::createHmac(
|
||||
'expired-key',
|
||||
'secret-that-is-long-enough-for-security',
|
||||
expiresAt: new \DateTimeImmutable('2020-01-01')
|
||||
);
|
||||
$this->keyRepository->store($expiredKey);
|
||||
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/test',
|
||||
headers: new Headers([
|
||||
'Host' => 'example.com',
|
||||
'Date' => 'Thu, 05 Jan 2023 21:31:40 GMT',
|
||||
'Signature' => 'keyId="expired-key",algorithm="hmac-sha256",headers="(request-target) host date",signature="test-signature"',
|
||||
])
|
||||
);
|
||||
|
||||
$result = $this->verifier->verify($request);
|
||||
|
||||
$this->assertTrue($result->isFailure());
|
||||
$this->assertEquals('Signing key is not valid', $result->errorMessage);
|
||||
}
|
||||
}
|
||||
146
tests/Framework/Security/RequestSigning/SigningKeyTest.php
Normal file
146
tests/Framework/Security/RequestSigning/SigningKeyTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Security\RequestSigning;
|
||||
|
||||
use App\Framework\Security\RequestSigning\SigningAlgorithm;
|
||||
use App\Framework\Security\RequestSigning\SigningKey;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SigningKeyTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_can_create_hmac_key(): void
|
||||
{
|
||||
$key = SigningKey::createHmac('test-key', 'my-secret-that-is-long-enough-for-security');
|
||||
|
||||
$this->assertEquals('test-key', $key->keyId);
|
||||
$this->assertEquals('my-secret-that-is-long-enough-for-security', $key->keyMaterial);
|
||||
$this->assertEquals(SigningAlgorithm::HMAC_SHA256, $key->algorithm);
|
||||
$this->assertTrue($key->isActive);
|
||||
$this->assertNull($key->expiresAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_create_hmac_key_with_expiration(): void
|
||||
{
|
||||
$expiresAt = new \DateTimeImmutable('+1 hour');
|
||||
$key = SigningKey::createHmac(
|
||||
'test-key',
|
||||
'my-secret-that-is-long-enough-for-security',
|
||||
SigningAlgorithm::HMAC_SHA512,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
$this->assertEquals(SigningAlgorithm::HMAC_SHA512, $key->algorithm);
|
||||
$this->assertEquals($expiresAt, $key->expiresAt);
|
||||
$this->assertFalse($key->isExpired());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_generate_random_hmac_key(): void
|
||||
{
|
||||
$key = SigningKey::generateHmac('random-key');
|
||||
|
||||
$this->assertEquals('random-key', $key->keyId);
|
||||
$this->assertEquals(SigningAlgorithm::HMAC_SHA256, $key->algorithm);
|
||||
$this->assertGreaterThanOrEqual(64, strlen($key->keyMaterial)); // 32 bytes = 64 hex chars
|
||||
$this->assertTrue($key->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_create_rsa_key(): void
|
||||
{
|
||||
// Generate test RSA key pair
|
||||
$keyPair = openssl_pkey_new([
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($keyPair, $privateKey);
|
||||
|
||||
$key = SigningKey::createRsa('rsa-key', $privateKey);
|
||||
|
||||
$this->assertEquals('rsa-key', $key->keyId);
|
||||
$this->assertEquals(SigningAlgorithm::RSA_SHA256, $key->algorithm);
|
||||
$this->assertEquals($privateKey, $key->keyMaterial);
|
||||
$this->assertTrue($key->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_throws_exception_for_short_key_material(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Key material must be at least 32 bytes long');
|
||||
|
||||
new SigningKey('test', 'short-key', SigningAlgorithm::HMAC_SHA256);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_throws_exception_for_invalid_rsa_key(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid RSA private key');
|
||||
|
||||
SigningKey::createRsa('invalid-rsa', 'not-a-valid-rsa-key');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_detects_expired_keys(): void
|
||||
{
|
||||
$expiredKey = SigningKey::createHmac(
|
||||
'expired-key',
|
||||
'my-secret-that-is-long-enough-for-security',
|
||||
expiresAt: new \DateTimeImmutable('2020-01-01')
|
||||
);
|
||||
|
||||
$this->assertTrue($expiredKey->isExpired());
|
||||
$this->assertFalse($expiredKey->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_validates_non_expired_keys(): void
|
||||
{
|
||||
$futureKey = SigningKey::createHmac(
|
||||
'future-key',
|
||||
'my-secret-that-is-long-enough-for-security',
|
||||
expiresAt: new \DateTimeImmutable('+1 hour')
|
||||
);
|
||||
|
||||
$this->assertFalse($futureKey->isExpired());
|
||||
$this->assertTrue($futureKey->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_handles_keys_without_expiration(): void
|
||||
{
|
||||
$permanentKey = SigningKey::createHmac(
|
||||
'permanent-key',
|
||||
'my-secret-that-is-long-enough-for-security'
|
||||
);
|
||||
|
||||
$this->assertFalse($permanentKey->isExpired());
|
||||
$this->assertTrue($permanentKey->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_throws_exception_for_asymmetric_algorithm_with_hmac_factory(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Algorithm must be symmetric for HMAC keys');
|
||||
|
||||
SigningKey::createHmac('test', 'my-secret-that-is-long-enough-for-security', SigningAlgorithm::RSA_SHA256);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_throws_exception_for_asymmetric_algorithm_with_generate_factory(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Algorithm must be symmetric for HMAC keys');
|
||||
|
||||
SigningKey::generateHmac('test', SigningAlgorithm::RSA_SHA256);
|
||||
}
|
||||
}
|
||||
281
tests/Framework/Security/SecurityHeadersTest.php
Normal file
281
tests/Framework/Security/SecurityHeadersTest.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Security;
|
||||
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Middlewares\SecurityHeaderConfig;
|
||||
use App\Framework\Http\Middlewares\SecurityHeaderMiddleware;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Status;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Critical Security Tests for HTTP Security Headers
|
||||
* Tests gegen OWASP A05:2021 – Security Misconfiguration
|
||||
*/
|
||||
final class SecurityHeadersTest extends TestCase
|
||||
{
|
||||
private SecurityHeaderConfig $config;
|
||||
|
||||
private SecurityHeaderMiddleware $middleware;
|
||||
|
||||
private RequestStateManager $stateManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->config = new SecurityHeaderConfig(
|
||||
hsts: true,
|
||||
hstsMaxAge: 31536000,
|
||||
hstsIncludeSubdomains: true,
|
||||
hstsPreload: true,
|
||||
csp: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
|
||||
xFrameOptions: 'DENY',
|
||||
xContentTypeOptions: true,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
permissionsPolicy: "camera=(), microphone=(), geolocation=()"
|
||||
);
|
||||
$this->middleware = new SecurityHeaderMiddleware($this->config);
|
||||
$this->stateManager = $this->createMock(RequestStateManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: HSTS Header wird korrekt gesetzt
|
||||
* Verhindert SSL-Stripping Attacks
|
||||
*/
|
||||
public function test_hsts_header_set_correctly(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = $this->createHttpsRequest();
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$headers = $result->response->headers;
|
||||
$this->assertEquals(
|
||||
'max-age=31536000; includeSubDomains; preload',
|
||||
$headers->getFirst('Strict-Transport-Security')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: CSP Header verhindert XSS
|
||||
* OWASP A03:2021 – Injection
|
||||
*/
|
||||
public function test_csp_header_prevents_xss(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$headers = $result->response->headers;
|
||||
$cspHeader = $headers->getFirst('Content-Security-Policy');
|
||||
|
||||
$this->assertStringContains("default-src 'self'", $cspHeader);
|
||||
$this->assertStringContains("script-src 'self'", $cspHeader);
|
||||
$this->assertStringNotContains("'unsafe-eval'", $cspHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: X-Frame-Options verhindert Clickjacking
|
||||
* OWASP A05:2021 – Security Misconfiguration
|
||||
*/
|
||||
public function test_x_frame_options_prevents_clickjacking(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$headers = $result->response->headers;
|
||||
$this->assertEquals('DENY', $headers->getFirst('X-Frame-Options'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: X-Content-Type-Options verhindert MIME-Type Confusion
|
||||
*/
|
||||
public function test_x_content_type_options_prevents_mime_sniffing(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$headers = $result->response->headers;
|
||||
$this->assertEquals('nosniff', $headers->getFirst('X-Content-Type-Options'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Referrer Policy schützt vor Information Leakage
|
||||
*/
|
||||
public function test_referrer_policy_prevents_info_leakage(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$headers = $result->response->headers;
|
||||
$this->assertEquals(
|
||||
'strict-origin-when-cross-origin',
|
||||
$headers->getFirst('Referrer-Policy')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Permissions Policy beschränkt Browser-Features
|
||||
*/
|
||||
public function test_permissions_policy_restricts_features(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert
|
||||
$headers = $result->response->headers;
|
||||
$permissionsPolicy = $headers->getFirst('Permissions-Policy');
|
||||
|
||||
$this->assertStringContains('camera=()', $permissionsPolicy);
|
||||
$this->assertStringContains('microphone=()', $permissionsPolicy);
|
||||
$this->assertStringContains('geolocation=()', $permissionsPolicy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: HSTS wird nur über HTTPS gesetzt
|
||||
* Verhindert Mixed Content Probleme
|
||||
*/
|
||||
public function test_hsts_only_over_https(): void
|
||||
{
|
||||
// Arrange: HTTP Request
|
||||
$request = $this->createHttpRequest();
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert: Kein HSTS Header über HTTP
|
||||
$headers = $result->response->headers;
|
||||
$this->assertNull($headers->getFirst('Strict-Transport-Security'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Security Headers werden nicht überschrieben
|
||||
* Respektiert explizit gesetzte Headers
|
||||
*/
|
||||
public function test_existing_security_headers_not_overridden(): void
|
||||
{
|
||||
// Arrange: Response mit bereits gesetztem CSP Header
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$existingHeaders = ['Content-Security-Policy' => ["default-src 'none'"]];
|
||||
$response = new HttpResponse(Status::OK, $existingHeaders, 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $this->middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert: Ursprünglicher CSP Header bleibt erhalten
|
||||
$headers = $result->response->headers;
|
||||
$this->assertEquals("default-src 'none'", $headers->getFirst('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Development-spezifische CSP Policy
|
||||
* Erlaubt eval() und inline styles in Development
|
||||
*/
|
||||
public function test_development_csp_policy(): void
|
||||
{
|
||||
// Arrange: Development Config
|
||||
$devConfig = new SecurityHeaderConfig(
|
||||
csp: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"
|
||||
);
|
||||
$middleware = new SecurityHeaderMiddleware($devConfig);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$context = new MiddlewareContext($request);
|
||||
$response = new HttpResponse(Status::OK, [], 'content');
|
||||
|
||||
$next = $this->createMock(Next::class);
|
||||
$next->method('__invoke')->willReturn(new MiddlewareContext($request, $response));
|
||||
|
||||
// Act
|
||||
$result = $middleware->__invoke($context, $next, $this->stateManager);
|
||||
|
||||
// Assert: Development CSP erlaubt unsafe-eval
|
||||
$headers = $result->response->headers;
|
||||
$cspHeader = $headers->getFirst('Content-Security-Policy');
|
||||
|
||||
$this->assertStringContains("'unsafe-eval'", $cspHeader);
|
||||
$this->assertStringContains("'unsafe-inline'", $cspHeader);
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
private function createHttpsRequest(): Request
|
||||
{
|
||||
$request = $this->createMock(Request::class);
|
||||
$request->method('isSecure')->willReturn(true);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function createHttpRequest(): Request
|
||||
{
|
||||
$request = $this->createMock(Request::class);
|
||||
$request->method('isSecure')->willReturn(false);
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user