feat(auth): BruteforceGuard with inet_pton CIDR exemptions
parent
1f7b06c576
commit
ce98fbdba0
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\BannedIp;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
/**
|
||||
* Application-level brute-force IP ban for the CONTROL PLANE's own login. Independent of
|
||||
* the remote-fleet Fail2banService: this blocks at the HTTP layer (BlockBannedIp middleware),
|
||||
* not via SSH firewall rules. Failures are counted in cache; bans are persisted in banned_ips.
|
||||
*/
|
||||
class BruteforceGuard
|
||||
{
|
||||
/** Always exempt — hard, non-configurable (prevents loopback/health self-lockout). */
|
||||
private const LOOPBACK = ['127.0.0.0/8', '::1', '::ffff:127.0.0.1'];
|
||||
|
||||
/** Default operator whitelist: private ranges (covers LAN/VPN + the Docker/proxy net). */
|
||||
private const DEFAULT_WHITELIST = "10.0.0.0/8\n172.16.0.0/12\n192.168.0.0/16";
|
||||
|
||||
private const CHECK_TTL = 30;
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return Setting::get('bruteforce_enabled', '1') === '1';
|
||||
}
|
||||
|
||||
public function maxretry(): int
|
||||
{
|
||||
return max(1, (int) Setting::get('bruteforce_maxretry', '10'));
|
||||
}
|
||||
|
||||
/** findtime in MINUTES. */
|
||||
public function findtime(): int
|
||||
{
|
||||
return max(1, (int) Setting::get('bruteforce_findtime', '10'));
|
||||
}
|
||||
|
||||
/** bantime in MINUTES. */
|
||||
public function bantime(): int
|
||||
{
|
||||
return max(1, (int) Setting::get('bruteforce_bantime', '60'));
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function whitelist(): array
|
||||
{
|
||||
$raw = (string) Setting::get('bruteforce_whitelist', self::DEFAULT_WHITELIST);
|
||||
|
||||
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
|
||||
}
|
||||
|
||||
public function isExempt(string $ip): bool
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return true; // unknown/malformed IP → never count or ban
|
||||
}
|
||||
|
||||
foreach (array_merge(self::LOOPBACK, $this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\BruteforceGuard;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BruteforceGuardExemptTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function guard(): BruteforceGuard
|
||||
{
|
||||
return app(BruteforceGuard::class);
|
||||
}
|
||||
|
||||
public function test_loopback_and_ipv6_loopback_are_exempt(): void
|
||||
{
|
||||
$g = $this->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'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue