harden(auth): fold IPv4-mapped IPv6, dedupe unbanAll, limit bans render, forged-token test

feat/v1-foundation
boban 2026-06-20 18:53:52 +02:00
parent cabad0e770
commit 6c81ea7fd1
5 changed files with 44 additions and 15 deletions

View File

@ -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;

View File

@ -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),
]);

View File

@ -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);
}
}

View File

@ -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
}
}

View File

@ -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
}
}