harden(auth): fold IPv4-mapped IPv6, dedupe unbanAll, limit bans render, forged-token test
parent
cabad0e770
commit
6c81ea7fd1
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue