feat(firewall): validate rules and add typed configuration

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-30 21:54:23 -04:00
parent 63d91ca7fa
commit 3b9a5cd5ab
4 changed files with 231 additions and 7 deletions
@@ -11,11 +11,13 @@ class TenantConfiguration extends JsonSerializableObject
{
protected TenantAuthentication $authentication;
protected TenantSecurity $security;
protected TenantFirewall $firewall;
public function __construct()
{
$this->authentication = new TenantAuthentication();
$this->security = new TenantSecurity();
$this->firewall = new TenantFirewall();
}
public function authentication(): TenantAuthentication {
@@ -26,4 +28,8 @@ class TenantConfiguration extends JsonSerializableObject
return $this->security;
}
public function firewall(): TenantFirewall {
return $this->firewall;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use KTXF\Json\JsonSerializableObject;
class TenantFirewall extends JsonSerializableObject
{
protected bool $enabled = true;
protected int $maxAuthFailures = 5;
protected int $authFailureWindow = 300;
protected int $autoBlockDuration = 3600;
public function enabled(): bool
{
return $this->enabled;
}
public function maxAuthFailures(): int
{
return $this->maxAuthFailures;
}
public function authFailureWindow(): int
{
return $this->authFailureWindow;
}
public function autoBlockDuration(): int
{
return $this->autoBlockDuration;
}
}
+78 -6
View File
@@ -29,6 +29,10 @@ class FirewallService
private const DEFAULT_MAX_AUTH_FAILURES = 5;
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
private const MAX_AUTH_FAILURES = 1000;
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
private const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
// Configuration keys
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
@@ -142,13 +146,15 @@ class FirewallService
}
// Check for brute force
$windowSeconds = $this->getConfig(
$windowSeconds = $this->getBoundedIntegerConfig(
self::CONFIG_FAILURE_WINDOW,
self::DEFAULT_AUTH_FAILURE_WINDOW
self::DEFAULT_AUTH_FAILURE_WINDOW,
self::MAX_AUTH_FAILURE_WINDOW
);
$maxFailures = $this->getConfig(
$maxFailures = $this->getBoundedIntegerConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
);
$failureCount = $this->store->countRecentFailures(
@@ -179,9 +185,10 @@ class FirewallService
$this->events->dispatch($event);
// Auto-block the IP
$blockDuration = $this->getConfig(
$blockDuration = $this->getBoundedIntegerConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION
self::DEFAULT_AUTO_BLOCK_DURATION,
self::MAX_AUTO_BLOCK_DURATION
);
$this->blockIp(
@@ -277,6 +284,9 @@ class FirewallService
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$ipAddress = $this->validateIpAddress($ipAddress);
$this->validateDuration($durationSeconds);
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
@@ -331,6 +341,8 @@ class FirewallService
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$ipAddress = $this->validateIpAddress($ipAddress);
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
@@ -368,6 +380,8 @@ class FirewallService
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$cidr = $this->validateCidr($cidr);
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
@@ -399,6 +413,14 @@ class FirewallService
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$fingerprint = trim($fingerprint);
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
throw new \InvalidArgumentException(
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
);
}
$this->validateDuration($durationSeconds);
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
@@ -538,6 +560,9 @@ class FirewallService
private function getConfig(string $key, mixed $default = null): mixed
{
$config = $this->tenantContext->configuration();
if ($config instanceof \JsonSerializable) {
$config = $config->jsonSerialize();
}
$parts = explode('.', $key);
foreach ($parts as $part) {
@@ -550,6 +575,53 @@ class FirewallService
return $config;
}
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
{
$value = $this->getConfig($key, $default);
if (!is_int($value) || $value < 1 || $value > $maximum) {
return $default;
}
return $value;
}
private function validateIpAddress(string $ipAddress): string
{
$ipAddress = trim($ipAddress);
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
}
return $ipAddress;
}
private function validateCidr(string $cidr): string
{
$cidr = trim($cidr);
if (substr_count($cidr, '/') !== 1) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
[$address, $prefix] = explode('/', $cidr, 2);
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
if ((int)$prefix > $maximumPrefix) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
return $cidr;
}
private function validateDuration(?int $durationSeconds): void
{
if ($durationSeconds !== null && $durationSeconds < 1) {
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
}
}
/**
* Get active rules (cached)
* @return FirewallRuleObject[]