diff --git a/app/Services/BruteforceGuard.php b/app/Services/BruteforceGuard.php new file mode 100644 index 0000000..6dfd9d8 --- /dev/null +++ b/app/Services/BruteforceGuard.php @@ -0,0 +1,171 @@ +whitelist()) as $cidr) { + if ($this->matchesCidr($ip, $cidr)) { + return true; + } + } + + return false; + } + + public function record(?string $ip, string $reason): void + { + if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { + return; + } + + $canonical = $this->canonical($ip); + $key = 'bruteforce:'.md5($canonical); + RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds + $hits = RateLimiter::attempts($key); + + if ($hits < $this->maxretry()) { + return; + } + + // Atomic upsert: two concurrent requests at the threshold must not collide on the + // unique ip index. Eloquent upsert does not auto-manage timestamps for bulk ops. + BannedIp::upsert( + [[ + 'ip' => $canonical, + 'banned_until' => now()->addMinutes($this->bantime()), + 'reason' => $reason, + 'attempts' => $hits, + 'created_at' => now(), + 'updated_at' => now(), + ]], + ['ip'], + ['banned_until', 'reason', 'attempts', 'updated_at'], + ); + RateLimiter::clear($key); + Cache::forget('bruteforce:banned:'.$canonical); + + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.ip_banned', + 'target' => $canonical, + 'ip' => $canonical, + 'meta' => ['reason' => $reason, 'attempts' => $hits], + ]); + } + + public function isBanned(string $ip): bool + { + if ($this->isExempt($ip)) { + return false; // exempt short-circuits — whitelisting unblocks immediately + } + + $canonical = $this->canonical($ip); + + return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query() + ->where('ip', $canonical) + ->where('banned_until', '>', now()) + ->exists()); + } + + public function unban(string $ip): void + { + $canonical = $this->canonical($ip); + BannedIp::query()->where('ip', $canonical)->delete(); + Cache::forget('bruteforce:banned:'.$canonical); + } + + /** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */ + public function matchesCidr(string $ip, string $cidr): bool + { + if (! str_contains($cidr, '/')) { + $a = @inet_pton($ip); + $b = @inet_pton($cidr); + + return $a !== false && $b !== false && $a === $b; + } + + [$subnet, $maskStr] = explode('/', $cidr, 2); + $pip = @inet_pton($ip); + $psub = @inet_pton($subnet); + if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) { + return false; // v4-vs-v6 mismatch or junk mask + } + + $mask = (int) $maskStr; + $bytes = intdiv($mask, 8); + $rem = $mask % 8; + if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) { + return false; + } + if ($rem === 0) { + return true; + } + $b = 0xFF & (0xFF << (8 - $rem)); + + return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b); + } + + private function canonical(string $ip): string + { + $packed = @inet_pton($ip); + + return $packed === false ? $ip : (string) inet_ntop($packed); + } +} diff --git a/tests/Feature/BruteforceGuardExemptTest.php b/tests/Feature/BruteforceGuardExemptTest.php new file mode 100644 index 0000000..8cb3610 --- /dev/null +++ b/tests/Feature/BruteforceGuardExemptTest.php @@ -0,0 +1,46 @@ +guard(); + $this->assertTrue($g->isExempt('127.0.0.1')); + $this->assertTrue($g->isExempt('::1')); + $this->assertTrue($g->isExempt('0:0:0:0:0:0:0:1')); // alternate spelling + } + + public function test_default_private_whitelist_is_exempt_public_is_not(): void + { + $g = $this->guard(); + $this->assertTrue($g->isExempt('192.168.4.20')); // default whitelist + $this->assertTrue($g->isExempt('10.9.9.9')); + $this->assertFalse($g->isExempt('203.0.113.7')); // public + } + + public function test_invalid_ip_is_treated_as_exempt(): void + { + $this->assertTrue($this->guard()->isExempt('not-an-ip')); + } + + public function test_custom_whitelist_cidr_matches(): void + { + Setting::put('bruteforce_whitelist', '203.0.113.0/24'); + $this->assertTrue($this->guard()->isExempt('203.0.113.50')); + $this->assertFalse($this->guard()->isExempt('203.0.114.50')); + } +}