diff --git a/app/Console/Commands/UnbanIp.php b/app/Console/Commands/UnbanIp.php index 76c910f..d83047c 100644 --- a/app/Console/Commands/UnbanIp.php +++ b/app/Console/Commands/UnbanIp.php @@ -2,10 +2,8 @@ namespace App\Console\Commands; -use App\Models\BannedIp; use App\Services\BruteforceGuard; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Cache; class UnbanIp extends Command { @@ -16,11 +14,7 @@ class UnbanIp extends Command public function handle(BruteforceGuard $guard): int { if ($this->option('all')) { - $count = BannedIp::query()->count(); - foreach (BannedIp::query()->pluck('ip') as $ip) { - Cache::forget('bruteforce:banned:'.$ip); - } - BannedIp::query()->delete(); + $count = $guard->unbanAll(); $this->info("{$count} Bann/Bänne entfernt."); return self::SUCCESS; diff --git a/app/Livewire/Settings/LoginProtection.php b/app/Livewire/Settings/LoginProtection.php index cad3550..c552122 100644 --- a/app/Livewire/Settings/LoginProtection.php +++ b/app/Livewire/Settings/LoginProtection.php @@ -10,7 +10,6 @@ use App\Services\BruteforceGuard; use App\Support\Confirm\ConfirmToken; use App\Support\Confirm\InvalidConfirmToken; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Validator; use Livewire\Attributes\On; use Livewire\Component; @@ -127,17 +126,14 @@ class LoginProtection extends Component } #[On('bansClearedAll')] - public function unbanAll(string $confirmToken): void + public function unbanAll(string $confirmToken, BruteforceGuard $guard): void { try { ConfirmToken::consume($confirmToken, 'bansClearedAll'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } - foreach (BannedIp::query()->pluck('ip') as $ip) { - Cache::forget('bruteforce:banned:'.$ip); - } - BannedIp::query()->delete(); + $guard->unbanAll(); $this->audit('auth.ip_unbanned', 'all'); } @@ -157,7 +153,7 @@ class LoginProtection extends Component $ip = (string) request()->ip(); return view('livewire.settings.login-protection', [ - 'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->get(), + 'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->limit(200)->get(), 'currentIp' => $ip, 'currentIpExempt' => $guard->isExempt($ip), ]); diff --git a/app/Services/BruteforceGuard.php b/app/Services/BruteforceGuard.php index 54f175b..e4d13fb 100644 --- a/app/Services/BruteforceGuard.php +++ b/app/Services/BruteforceGuard.php @@ -59,6 +59,7 @@ class BruteforceGuard if (filter_var($ip, FILTER_VALIDATE_IP) === false) { return true; // unknown/malformed IP → never count or ban } + $ip = $this->canonical($ip); // fold IPv4-mapped IPv6 so exemption matches the canonical form foreach (array_merge(self::LOOPBACK, $this->whitelist()) as $cidr) { if ($this->matchesCidr($ip, $cidr)) { @@ -143,6 +144,18 @@ class BruteforceGuard Cache::forget('bruteforce:banned:'.$canonical); } + /** Delete every ban (active or expired) and bust each cache key. Returns the count removed. */ + public function unbanAll(): int + { + $ips = BannedIp::query()->pluck('ip'); + foreach ($ips as $ip) { + Cache::forget('bruteforce:banned:'.$this->canonical($ip)); + } + BannedIp::query()->delete(); + + return $ips->count(); + } + /** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */ public function matchesCidr(string $ip, string $cidr): bool { @@ -180,7 +193,15 @@ class BruteforceGuard private function canonical(string $ip): string { $packed = @inet_pton($ip); + if ($packed === false) { + return $ip; + } + // Fold an IPv4-mapped IPv6 (::ffff:a.b.c.d) down to plain IPv4 so the same client can't + // dodge a ban by switching between the two forms (one ban row + one cache key per IP). + if (strlen($packed) === 16 && str_starts_with($packed, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff")) { + $packed = substr($packed, 12); + } - return $packed === false ? $ip : (string) inet_ntop($packed); + return (string) inet_ntop($packed); } } diff --git a/tests/Feature/BruteforceGuardBanTest.php b/tests/Feature/BruteforceGuardBanTest.php index b7e9920..afad66a 100644 --- a/tests/Feature/BruteforceGuardBanTest.php +++ b/tests/Feature/BruteforceGuardBanTest.php @@ -73,4 +73,12 @@ class BruteforceGuardBanTest extends TestCase BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->subMinute(), 'attempts' => 10]); $this->assertFalse($this->guard()->isBanned('203.0.113.5')); } + + public function test_ipv4_mapped_ipv6_shares_one_ban(): void + { + $g = $this->guard(); + Setting::put('bruteforce_maxretry', '1'); + $g->record('203.0.113.5', 'login'); // ban via plain IPv4 + $this->assertTrue($g->isBanned('::ffff:203.0.113.5')); // mapped form is the same ban + } } diff --git a/tests/Feature/Settings/LoginProtectionTabTest.php b/tests/Feature/Settings/LoginProtectionTabTest.php index 49dc15a..16d2378 100644 --- a/tests/Feature/Settings/LoginProtectionTabTest.php +++ b/tests/Feature/Settings/LoginProtectionTabTest.php @@ -101,4 +101,14 @@ class LoginProtectionTabTest extends TestCase ->call('whitelistMyIp') ->assertSet('whitelist', fn ($w) => str_contains($w, '198.51.100.7')); } + + public function test_unban_rejects_a_forged_token(): void + { + $user = User::factory()->create(); + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + + Livewire::actingAs($user)->test(LoginProtection::class)->call('unban', 'not-a-real-token'); + + $this->assertDatabaseCount('banned_ips', 1); // forged token → no-op, ban remains + } }