diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 849721a..dcb7127 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -14,6 +14,7 @@ use KTXC\Service\TenantFirewallStatusService; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; +use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SecurityEvent; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistrarInterface; @@ -48,7 +49,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M SecurityEvent::AUTH_SUCCESS, AccessDeniedEvent::class, BruteForceDetectedEvent::class, - SecurityEvent::RATE_LIMIT_EXCEEDED, + RateLimitExceededEvent::class, SecurityEvent::SUSPICIOUS_ACTIVITY, SecurityEvent::FIREWALL_RULE_CREATED, SecurityEvent::FIREWALL_RULE_EXTENDED, diff --git a/core/lib/Security/Event/RateLimitExceededEvent.php b/core/lib/Security/Event/RateLimitExceededEvent.php new file mode 100644 index 0000000..e89a7ec --- /dev/null +++ b/core/lib/Security/Event/RateLimitExceededEvent.php @@ -0,0 +1,104 @@ +reason = sprintf( + '%d requests in %d seconds', + $requestCount, + $windowSeconds, + ); + + parent::__construct( + self::class, + [ + 'requestCount' => $requestCount, + 'windowSeconds' => $windowSeconds, + 'endpoint' => $endpoint, + ], + $tenantId, + ); + } + + public function getIpAddress(): string + { + return $this->ipAddress; + } + + public function getRequestCount(): int + { + return $this->requestCount; + } + + public function getWindowSeconds(): int + { + return $this->windowSeconds; + } + + public function getEndpoint(): ?string + { + return $this->endpoint; + } + + public function getDeviceFingerprint(): ?string + { + return null; + } + + public function getUserAgent(): ?string + { + return null; + } + + public function getRequestPath(): ?string + { + return $this->endpoint; + } + + public function getRequestMethod(): ?string + { + return null; + } + + public function getUserId(): ?string + { + return null; + } + + public function getReason(): string + { + return $this->reason; + } + + public function getSeverity(): int + { + return SecurityEvent::SEVERITY_ERROR; + } +} diff --git a/core/lib/Security/Event/SecurityEvent.php b/core/lib/Security/Event/SecurityEvent.php index 78fa612..42187a7 100644 --- a/core/lib/Security/Event/SecurityEvent.php +++ b/core/lib/Security/Event/SecurityEvent.php @@ -19,7 +19,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface public const ACCESS_GRANTED = 'security.access.granted'; - public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded'; public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity'; public const IP_BLOCKED = 'security.ip.blocked'; @@ -112,30 +111,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface ); } - /** - * Create a rate limit exceeded event - */ - public static function rateLimitExceeded( - string $ipAddress, - int $requestCount, - int $windowSeconds, - ?string $endpoint = null, - ?string $tenantId = null, - ): self { - return self::create( - self::RATE_LIMIT_EXCEEDED, - $ipAddress, - data: [ - 'requestCount' => $requestCount, - 'windowSeconds' => $windowSeconds, - 'endpoint' => $endpoint, - ], - tenantId: $tenantId, - requestPath: $endpoint, - reason: sprintf('%d requests in %d seconds', $requestCount, $windowSeconds), - ); - } - /** * Get default severity for event types */ @@ -149,7 +124,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface self::AUTH_LOGOUT, self::TOKEN_REVOKED => self::SEVERITY_WARNING, - self::RATE_LIMIT_EXCEEDED, self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR, self::IP_BLOCKED, diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index fee5942..6c6f20a 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -13,6 +13,7 @@ use KTXC\Context\TenantContextInterface; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; +use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\SecurityEventInterface; use KTXC\Security\Event\SecurityRequestEventInterface; @@ -283,7 +284,7 @@ class FirewallService AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE, SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK, BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE, - SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT, + RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT, AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH, SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED, diff --git a/tests/php/Unit/Event/RateLimitExceededEventTest.php b/tests/php/Unit/Event/RateLimitExceededEventTest.php new file mode 100644 index 0000000..a82dfdd --- /dev/null +++ b/tests/php/Unit/Event/RateLimitExceededEventTest.php @@ -0,0 +1,56 @@ +getName()); + self::assertSame('203.0.113.10', $event->getIpAddress()); + self::assertSame(101, $event->getRequestCount()); + self::assertSame(60, $event->getWindowSeconds()); + self::assertSame('/login', $event->getEndpoint()); + self::assertSame('/login', $event->getRequestPath()); + self::assertSame('tenant-a', $event->getTenantId()); + self::assertSame('101 requests in 60 seconds', $event->getReason()); + self::assertSame(SecurityEvent::SEVERITY_ERROR, $event->getSeverity()); + } + + #[Test] + #[TestDox('Rate-limit detection rejects invalid measurements')] + public function rejectsInvalidMeasurements(): void + { + foreach ([ + ['', 101, 60, '/login'], + ['203.0.113.10', 0, 60, '/login'], + ['203.0.113.10', 101, 0, '/login'], + ['203.0.113.10', 101, 60, ''], + ] as $arguments) { + try { + new RateLimitExceededEvent(...$arguments); + self::fail('Invalid rate-limit measurements were accepted.'); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Unit/Event/SecurityEventTest.php b/tests/php/Unit/Event/SecurityEventTest.php index 60fb310..833c798 100644 --- a/tests/php/Unit/Event/SecurityEventTest.php +++ b/tests/php/Unit/Event/SecurityEventTest.php @@ -21,7 +21,7 @@ final class SecurityEventTest extends TestCase ); self::assertSame( SecurityEvent::SEVERITY_ERROR, - (new SecurityEvent(SecurityEvent::RATE_LIMIT_EXCEEDED))->getSeverity(), + (new SecurityEvent(SecurityEvent::SUSPICIOUS_ACTIVITY))->getSeverity(), ); self::assertSame( SecurityEvent::SEVERITY_CRITICAL, diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 4afe41f..044733a 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -19,6 +19,7 @@ use KTXC\Event\EventListenerRegistry; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; +use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SecurityEvent; use KTXF\Event\DeliveryMode; use PHPUnit\Framework\Attributes\Test; @@ -47,7 +48,7 @@ final class CoreModuleTest extends TestCase foreach ([ AccessDeniedEvent::class, BruteForceDetectedEvent::class, - SecurityEvent::RATE_LIMIT_EXCEEDED, + RateLimitExceededEvent::class, SecurityEvent::SUSPICIOUS_ACTIVITY, SecurityEvent::FIREWALL_RULE_CREATED, SecurityEvent::FIREWALL_RULE_EXTENDED, diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index e91e177..2676864 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -13,6 +13,7 @@ use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; +use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Service\FirewallService; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; @@ -294,7 +295,7 @@ class FirewallServiceTest extends TestCase && $metadata['windowSeconds'] === 60; })) ->willReturnArgument(0); - $event = \KTXC\Security\Event\SecurityEvent::rateLimitExceeded( + $event = new RateLimitExceededEvent( '203.0.113.10', 101, 60,