fix(security): harden brute-force, rate-limiting and auth-DoS (audit follow-up)
Adversarial audit of the auth surface confirmed several exploitable gaps. Every fix auto-expires and never permanently locks the control plane (bare-IP recovery host and the clusev:reset-admin CLI stay open). Login: - per-IP (20/min) + per-account (30/15min) buckets on top of the existing email+IP 5/min, so a distributed multi-IP brute-force of the single admin account is capped instead of scaling linearly with IPs. ip/acct counters decay on their own (a valid login doesn't reset a flood's budget). - constant-time: the unknown-email branch now runs one bcrypt against a fixed DUMMY_HASH (evaluated before the `! $user ||` short-circuit), closing the account-enumeration timing oracle. 2FA: - TOTP replay protection — verifyTotp uses verifyKeyNewer with a persisted last-used step (two_factor_last_used_step migration), so each 30s code is single-use. Passes 0 (never null) as the old step, since null makes verifyKeyNewer return bare `true` not the step. - challenge + 2FA-proof reset gain an IP-independent per-account backstop, capping the distributed TOTP/backup brute-force surface. Defense-in-depth / DoS: - global throttle on /livewire/update (180/min per user-id-or-IP) via setUpdateRoute, keeping 'web' + the persistent EnsureSecurityOnboarded gate intact. - reauth throttle (5/min per user) on password change/profile; SMTP test-send capped (3/10min per user) so the panel can't relay spam. - SshClient gets a short, separate connect timeout (~5s) so a dead/tarpit host can't hold a worker across several sequential 15s reads (the observed 8.3s /livewire/update DoS). - profile password policy raised to 12 chars + mixed case + numbers (was 10). Tests: BruteForceHardeningTest covers TOTP replay, per-email + per-IP login caps, identical unknown/known rejection, and reauth throttling. Full suite 187 green; Codex clean; verified in a real browser (login/polls/Livewire updates unaffected by the throttle). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
62158eecdc
commit
f2cbf60c04
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -27,6 +27,25 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
|
|||
spiegelt jetzt exakt die Kosten der echten Recovery-Code-Prüfung (gesperrtes `SELECT` in einer
|
||||
Transaktion statt eines schwereren bcrypt-Vergleichs), und Transportfehler beim Reset-Link werden
|
||||
geloggt statt stillschweigend verschluckt.
|
||||
- **Brute-Force-/Rate-Limiting-Härtung (Audit-Nachgang).** Alle neuen Limits laufen automatisch ab
|
||||
und sperren die Control-Plane nie dauerhaft (Bare-IP-Recovery + `clusev:reset-admin` bleiben offen):
|
||||
- **Login** zusätzlich pro IP (20/Min) und pro Konto (30/15 Min) begrenzt — ein verteilter
|
||||
Mehr-IP-Angriff auf das eine Admin-Konto wird gedeckelt statt linear zu skalieren; und der
|
||||
Login-Zweig für unbekannte E-Mails führt jetzt denselben bcrypt aus (Konstantzeit) und schließt
|
||||
so das Konten-Enumerations-Timing-Orakel.
|
||||
- **TOTP-Replay-Schutz:** jeder 6-stellige Code ist genau einmal gültig (zuletzt akzeptierter
|
||||
Zeit-Schritt wird persistiert), ein abgefangener Code lässt sich nicht im 30-Sek-Fenster
|
||||
wiederverwenden.
|
||||
- **2FA-Challenge & 2FA-Beweis-Reset** zusätzlich pro Konto begrenzt (IP-unabhängiger Backstop),
|
||||
sodass die TOTP-/Backup-Code-Brute-Force-Fläche auch verteilt gedeckelt ist.
|
||||
- **Globaler `/livewire/update`-Throttle** (180/Min pro Identität) als Flut-Deckel über den
|
||||
Per-Aktion-Limitern.
|
||||
- **Re-Auth gedrosselt** (Passwortänderung/-Profil, 5/Min pro Nutzer) gegen Passwort-Brute-Force
|
||||
über eine gekaperte Session; **SMTP-Testversand** auf 3/10 Min pro Nutzer begrenzt (kein
|
||||
Spam-Relay).
|
||||
- **DoS-Schutz:** SSH bekommt einen kurzen, separaten Connect-Timeout (~5 s), damit ein toter/
|
||||
langsamer Host einen Worker nicht über mehrere sequentielle Reads × 15 s blockiert; die
|
||||
Profil-Passwort-Policy auf 12 Zeichen + Groß/Klein + Ziffern angehoben (war 10).
|
||||
|
||||
## [0.9.2] - 2026-06-15
|
||||
|
||||
|
|
|
|||
|
|
@ -72,12 +72,20 @@ class ForgotPassword extends Component
|
|||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
|
||||
]);
|
||||
|
||||
$key = 'forgot:'.md5(strtolower($this->email).'|'.request()->ip());
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
// Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset
|
||||
// path is a second TOTP/backup brute-force surface (email + code, no password), so a
|
||||
// distributed (multi-IP) attempt must be capped per account the same way the challenge
|
||||
// is. Both auto-expire — never a permanent lockout (clusev:reset-admin stays open).
|
||||
$email = strtolower($this->email);
|
||||
$key = 'forgot:'.md5($email.'|'.request()->ip());
|
||||
$acctKey = 'forgot-acct:'.md5($email);
|
||||
foreach ([[$key, 5], [$acctKey, 20]] as [$k, $max]) {
|
||||
if (RateLimiter::tooManyAttempts($k, $max)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::where('email', $this->email)->first();
|
||||
|
||||
|
|
@ -108,12 +116,14 @@ class ForgotPassword extends Component
|
|||
}
|
||||
|
||||
if (! $ok) {
|
||||
RateLimiter::hit($key, 60);
|
||||
RateLimiter::hit($key, 60); // 5 / minute (email+IP)
|
||||
RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent)
|
||||
// Generic message — never reveal whether the email exists.
|
||||
throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
RateLimiter::clear($acctKey);
|
||||
// Rotate remember_token too, so a stolen remember-me cookie can't survive the reset.
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($this->password),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ use Livewire\Component;
|
|||
#[Layout('layouts.auth')]
|
||||
class Login extends Component
|
||||
{
|
||||
/**
|
||||
* A fixed, valid cost-12 bcrypt hash. When the e-mail is unknown we still run one
|
||||
* Hash::check against this so the failure branch costs the same bcrypt as a known
|
||||
* account — closing the response-timing oracle that would otherwise reveal which
|
||||
* addresses exist (same class as the password-reset constant-time fix).
|
||||
*/
|
||||
private const DUMMY_HASH = '$2y$12$6gKO7fsTd5ZoshlNsI7JpeuIU60K30r3K0mdPdIryNc2IQy8XwI.u';
|
||||
|
||||
#[Validate('required|email')]
|
||||
public string $email = '';
|
||||
|
||||
|
|
@ -26,22 +34,42 @@ class Login extends Component
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
$key = 'login:'.md5(strtolower($this->email).'|'.request()->ip());
|
||||
// Three auto-expiring counters, none of which can permanently lock the control plane
|
||||
// (all decay in minutes; the host CLI `clusev:reset-admin` is always available):
|
||||
// - pair (email+IP): the tight 5/min bucket (unchanged).
|
||||
// - ip : caps one source hammering many accounts AND the bcrypt CPU it burns.
|
||||
// - acct : an IP-independent backstop so a DISTRIBUTED (multi-IP) brute-force of the
|
||||
// single admin account is still capped instead of scaling linearly with IPs.
|
||||
$email = strtolower($this->email);
|
||||
$pairKey = 'login:'.md5($email.'|'.request()->ip());
|
||||
$ipKey = 'login-ip:'.md5(request()->ip());
|
||||
$acctKey = 'login-acct:'.md5($email);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
foreach ([[$pairKey, 5], [$ipKey, 20], [$acctKey, 30]] as [$k, $max]) {
|
||||
if (RateLimiter::tooManyAttempts($k, $max)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::where('email', $this->email)->first();
|
||||
|
||||
if (! $user || ! Hash::check($this->password, $user->password)) {
|
||||
RateLimiter::hit($key, 60);
|
||||
// Evaluate the bcrypt FIRST (never short-circuit it): an unknown e-mail must still spend
|
||||
// one Hash::check against DUMMY_HASH, otherwise `! $user ||` would skip the hash and the
|
||||
// faster no-account response would re-open the enumeration timing oracle.
|
||||
$validPassword = Hash::check($this->password, $user?->password ?? self::DUMMY_HASH);
|
||||
|
||||
if (! $user || ! $validPassword) {
|
||||
RateLimiter::hit($pairKey, 60); // 5 / minute
|
||||
RateLimiter::hit($ipKey, 60); // 20 / minute
|
||||
RateLimiter::hit($acctKey, 900); // 30 / 15 minutes
|
||||
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
// Only the tight pair bucket is cleared on success; the broader ip/acct counters
|
||||
// decay on their own so one valid login can't reset a flood's budget.
|
||||
RateLimiter::clear($pairKey);
|
||||
|
||||
// 2FA gate: hold the login until the TOTP code is verified.
|
||||
if ($user->hasTwoFactorEnabled()) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ namespace App\Livewire\Auth;
|
|||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -19,6 +21,17 @@ class PasswordChange extends Component
|
|||
|
||||
public function update()
|
||||
{
|
||||
// Throttle the current-password re-auth so a hijacked session can't brute-force the
|
||||
// password. Keyed on the authenticated user id (never IP) and auto-expiring, so it can
|
||||
// only slow the user's own session briefly — never a cross-user or control-plane lockout.
|
||||
$key = 'reauth:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'current' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validate([
|
||||
'current' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
|
||||
|
|
@ -26,6 +39,14 @@ class PasswordChange extends Component
|
|||
'current' => __('auth.attr_current_password'),
|
||||
'password' => __('auth.attr_new_password'),
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
if (array_key_exists('current', $e->errors())) {
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
|
||||
Auth::user()->forceFill([
|
||||
'password' => Hash::make($this->password),
|
||||
|
|
|
|||
|
|
@ -45,17 +45,54 @@ class TwoFactorChallenge extends Component
|
|||
return (bool) $this->pendingUser()?->hasTotp();
|
||||
}
|
||||
|
||||
public function verify()
|
||||
/**
|
||||
* Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
|
||||
* (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user
|
||||
* backstop, so a distributed (multi-IP) attempt to brute-force the 6-digit TOTP is still
|
||||
* capped. Both decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
|
||||
*
|
||||
* @return array<string, array{0:int,1:int}> key => [maxAttempts, decaySeconds]
|
||||
*/
|
||||
private function rateLimitBuckets(): array
|
||||
{
|
||||
$this->validate();
|
||||
$uid = (string) session('2fa.user');
|
||||
|
||||
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
|
||||
return [
|
||||
'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
|
||||
'two-factor-acct:'.md5($uid) => [20, 900],
|
||||
];
|
||||
}
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
private function assertNotRateLimited(): void
|
||||
{
|
||||
foreach ($this->rateLimitBuckets() as $key => [$max]) {
|
||||
if (RateLimiter::tooManyAttempts($key, $max)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function hitRateLimit(): void
|
||||
{
|
||||
foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
|
||||
RateLimiter::hit($key, $decay);
|
||||
}
|
||||
}
|
||||
|
||||
private function clearRateLimit(): void
|
||||
{
|
||||
foreach (array_keys($this->rateLimitBuckets()) as $key) {
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
}
|
||||
|
||||
public function verify()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$this->assertNotRateLimited();
|
||||
|
||||
$user = User::find(session('2fa.user'));
|
||||
|
||||
|
|
@ -69,11 +106,11 @@ class TwoFactorChallenge extends Component
|
|||
$valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
|
||||
|
||||
if (! $valid) {
|
||||
RateLimiter::hit($key, 60);
|
||||
$this->hitRateLimit();
|
||||
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
$this->clearRateLimit();
|
||||
|
||||
$remember = (bool) session('2fa.remember');
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
|
|
@ -106,22 +143,16 @@ class TwoFactorChallenge extends Component
|
|||
/** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
|
||||
public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
|
||||
{
|
||||
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
$this->assertNotRateLimited();
|
||||
|
||||
$user = User::find(session('2fa.user'));
|
||||
|
||||
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
|
||||
RateLimiter::hit($key, 60);
|
||||
$this->hitRateLimit();
|
||||
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
$this->clearRateLimit();
|
||||
|
||||
$remember = (bool) session('2fa.remember');
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\Setting;
|
|||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
|
@ -109,6 +110,16 @@ class Email extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
// Throttle test-sends so the panel can't be turned into a spam/mail-bomb relay.
|
||||
// Per-user + auto-expiring: only ever limits the operator's own test button briefly.
|
||||
$key = 'mail-test:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 3)) {
|
||||
$this->dispatch('notify', message: __('mail.notify_test_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($key, 600); // 3 test e-mails / 10 minutes per user
|
||||
|
||||
$to = Auth::user()->email;
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ namespace App\Livewire\Settings;
|
|||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
|
||||
class Profile extends Component
|
||||
|
|
@ -40,10 +42,28 @@ class Profile extends Component
|
|||
|
||||
public function updatePassword(): void
|
||||
{
|
||||
// Throttle the current-password re-auth (hijacked-session brute-force protection).
|
||||
// User-id keyed + auto-expiring: only ever slows the user's own session briefly.
|
||||
$key = 'reauth:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'current_password' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(10)],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
if (array_key_exists('current_password', $e->errors())) {
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
|
||||
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
|
|
|||
|
|
@ -103,7 +103,26 @@ class User extends Authenticatable
|
|||
}
|
||||
|
||||
try {
|
||||
return (new Google2FA)->verifyKey($this->two_factor_secret, preg_replace('/\s+/', '', $code) ?? '');
|
||||
// Replay protection: verifyKeyNewer rejects any code from a time-step <= the last
|
||||
// accepted one and returns the matched step on success. Persisting it makes each
|
||||
// 30s code single-use, so an intercepted/replayed code can't be used twice.
|
||||
//
|
||||
// Pass 0 (never null) as the old timestamp: google2fa returns the bare bool `true`
|
||||
// — not the matched step — when oldTimestamp is null, which would break the
|
||||
// comparison on the very first use. With 0 it always returns the integer step.
|
||||
$step = (new Google2FA)->verifyKeyNewer(
|
||||
$this->two_factor_secret,
|
||||
preg_replace('/\s+/', '', $code) ?? '',
|
||||
(int) ($this->two_factor_last_used_step ?? 0),
|
||||
);
|
||||
|
||||
if ($step === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->forceFill(['two_factor_last_used_step' => $step])->save();
|
||||
|
||||
return true;
|
||||
} catch (Google2FAException) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ namespace App\Providers;
|
|||
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Throwable;
|
||||
|
|
@ -41,6 +45,19 @@ class AppServiceProvider extends ServiceProvider
|
|||
EnsureSecurityOnboarded::class,
|
||||
]);
|
||||
|
||||
// Defense-in-depth: a coarse per-identity cap on the Livewire action endpoint
|
||||
// (/livewire/update carries every component request, including the auth actions). The
|
||||
// per-action limiters in the components are the precise gate; this is the blunt cap so a
|
||||
// single source (authed user id, else guest IP) can't flood workers regardless of which
|
||||
// action it targets. 180/min sits far above normal polling + interaction yet well below a
|
||||
// flood; it auto-expires, so — like every limiter here — it can never permanently lock the
|
||||
// control plane (the bare-IP recovery host and clusev:reset-admin are unaffected).
|
||||
RateLimiter::for('livewire-update', fn (Request $request) => Limit::perMinute(180)
|
||||
->by($request->user()?->id ?: $request->ip()));
|
||||
|
||||
Livewire::setUpdateRoute(fn ($handle) => Route::post('/livewire/update', $handle)
|
||||
->middleware(['web', 'throttle:livewire-update']));
|
||||
|
||||
config([
|
||||
'broadcasting.connections.reverb.options.host' => 'reverb',
|
||||
'broadcasting.connections.reverb.options.port' => 8080,
|
||||
|
|
|
|||
|
|
@ -19,11 +19,17 @@ class SshClient
|
|||
public function __construct(
|
||||
private readonly CredentialVault $vault,
|
||||
private readonly int $timeout = 15,
|
||||
private readonly int $connectTimeout = 5,
|
||||
) {}
|
||||
|
||||
public function connect(Server $server): self
|
||||
{
|
||||
$ssh = new SSH2($server->ip, $server->ssh_port ?: 22);
|
||||
// Short, SEPARATE connect timeout so an unreachable / black-holing / tarpit host fails
|
||||
// fast (~5s) instead of holding the FPM worker for the full exec timeout. The
|
||||
// server-detail page issues several SSH reads in a row, so a dead host must not stack
|
||||
// timeout × N and exhaust workers (DoS). The exec/read timeout stays at 15s for
|
||||
// legitimately slow commands.
|
||||
$ssh = new SSH2($server->ip, $server->ssh_port ?: 22, $this->connectTimeout);
|
||||
$ssh->setTimeout($this->timeout);
|
||||
|
||||
$this->verifyHostKey($ssh, $server);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* TOTP replay protection: the time-step of the last accepted authenticator code.
|
||||
* verifyKeyNewer() rejects any code from a step <= this, so a single intercepted
|
||||
* 6-digit code can't be reused within its 30s validity window.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('two_factor_last_used_step')->nullable()->after('two_factor_secret');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('two_factor_last_used_step');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -35,5 +35,6 @@ return [
|
|||
'notify_saved' => 'SMTP-Einstellungen gespeichert.',
|
||||
'notify_test_ok' => 'Testmail an :email gesendet.',
|
||||
'notify_test_failed' => 'Testmail fehlgeschlagen: :error',
|
||||
'notify_test_throttled' => 'Zu viele Testmails – bitte in :seconds Sekunden erneut.',
|
||||
'not_configured' => 'SMTP ist nicht konfiguriert.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -35,5 +35,6 @@ return [
|
|||
'notify_saved' => 'SMTP settings saved.',
|
||||
'notify_test_ok' => 'Test mail sent to :email.',
|
||||
'notify_test_failed' => 'Test mail failed: :error',
|
||||
'notify_test_throttled' => 'Too many test e-mails — try again in :seconds seconds.',
|
||||
'not_configured' => 'SMTP is not configured.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Auth\PasswordChange;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Livewire;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Brute-force / rate-limit hardening (security audit follow-up).
|
||||
*
|
||||
* Each test starts with a cleared cache so the array-store RateLimiter buckets (keyed partly
|
||||
* on the fixed test IP) don't leak between tests. The "still blocked with the CORRECT password"
|
||||
* assertions are the point: a working limiter must refuse even valid credentials once tripped,
|
||||
* which a non-working one would let straight through.
|
||||
*/
|
||||
class BruteForceHardeningTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
private const PW = 'Sup3rStr0ngPass!';
|
||||
|
||||
private const NEW_PW = 'An0therStr0ngPass!';
|
||||
|
||||
public function test_totp_code_cannot_be_replayed(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
$otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
|
||||
|
||||
$this->assertTrue($user->verifyTotp($otp), 'first use of a fresh code must succeed');
|
||||
$this->assertFalse($user->fresh()->verifyTotp($otp), 'the same code must be rejected on replay');
|
||||
}
|
||||
|
||||
public function test_per_account_login_throttle_blocks_even_a_correct_password(): void
|
||||
{
|
||||
User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
Livewire::test(Login::class)
|
||||
->set('email', 'admin@clusev.local')->set('password', 'wrong')->call('authenticate');
|
||||
}
|
||||
|
||||
// 6th attempt with the RIGHT password must still be refused (per-(email+IP) cap = 5).
|
||||
Livewire::test(Login::class)
|
||||
->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate')
|
||||
->assertHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_per_ip_login_throttle_blocks_a_flood_across_distinct_emails(): void
|
||||
{
|
||||
User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]);
|
||||
|
||||
// 20 failures spread over distinct emails — the per-(email+IP) bucket never trips (1 each),
|
||||
// but the per-IP bucket (20/min) does.
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
Livewire::test(Login::class)
|
||||
->set('email', "flood{$i}@clusev.local")->set('password', 'wrong')->call('authenticate');
|
||||
}
|
||||
|
||||
// A correct login for the real admin from the same IP is now blocked by the per-IP cap.
|
||||
Livewire::test(Login::class)
|
||||
->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate')
|
||||
->assertHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_unknown_and_known_wrong_email_are_rejected_identically(): void
|
||||
{
|
||||
User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]);
|
||||
|
||||
// Both a known-wrong-password and a non-existent address fail on the SAME field with the
|
||||
// SAME generic auth.invalid_credentials message (by construction), and neither logs in —
|
||||
// no account-existence signal in the response. (The matching bcrypt cost on the unknown
|
||||
// branch, via DUMMY_HASH, closes the timing oracle; that is not asserted here because the
|
||||
// test env uses BCRYPT_ROUNDS=4 while DUMMY_HASH is cost-12.)
|
||||
foreach (['admin@clusev.local', 'ghost@clusev.local'] as $email) {
|
||||
Livewire::test(Login::class)
|
||||
->set('email', $email)->set('password', 'wrong')->call('authenticate')
|
||||
->assertHasErrors('email');
|
||||
}
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_password_reauth_is_throttled_against_a_hijacked_session(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make(self::PW),
|
||||
'must_change_password' => true,
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
Livewire::test(PasswordChange::class)
|
||||
->set('current', 'wrong')->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW)
|
||||
->call('update');
|
||||
}
|
||||
|
||||
// 6th try with the CORRECT current password is still refused (reauth bucket tripped).
|
||||
Livewire::test(PasswordChange::class)
|
||||
->set('current', self::PW)->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW)
|
||||
->call('update')
|
||||
->assertHasErrors('current');
|
||||
|
||||
// Password unchanged.
|
||||
$this->assertTrue(Hash::check(self::PW, $user->fresh()->password));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue