diff --git a/docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md b/docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md new file mode 100644 index 0000000..58747bc --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md @@ -0,0 +1,234 @@ +# Control-plane brute-force IP ban (Anmeldeschutz) — Design + +**Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved + +Adds a **persistent, app-level IP ban** that protects Clusev's **own** login (the control-plane), +fail2ban-style: too many failed auth attempts from one IP → that IP is hard-blocked for unauthenticated +requests for a while, configurable from a new **"Anmeldeschutz"** settings tab that also lists and +clears active bans. + +This is **distinct** from the existing `Fail2banService` (`app/Services/Fail2banService.php`), which +manages fail2ban on **remote fleet servers** over SSH — that stays untouched and shares no state, +config, or audit events with this feature. A real host fail2ban daemon is not an option here: the app +runs in a container with no host firewall / iptables / systemd access, so it can never write host +firewall rules, and a host daemon's ban list could not be shown or managed from the panel. The ban +therefore lives **in the application**: counted in cache, persisted in the DB, enforced in HTTP +middleware. + +Chosen defaults (operator-tunable in the tab): **enabled**, **10 failures / 10 min → 60 min ban**. + +## 0. IP trust model — the security assumption everything rests on + +The ban is only as correct as the client IP it sees. In prod, [`bootstrap/app.php`](../../../bootstrap/app.php) +calls `trustProxies(at: '*')` **only when `APP_ENV=production`**; the app container is internal-only +(`expose: 80`, no host port — `docker-compose.prod.yml`), so its **sole** direct peer is Caddy. +Caddy's `trusted_proxies` (the `TRUSTED_PROXY_CIDR` env, default `127.0.0.1/32` — `docker/caddy/Caddyfile`) +means Caddy does **not** trust a client's forged `X-Forwarded-For` and rewrites it to the real peer. +So `request()->ip()` = the real client, and XFF spoofing is prevented **as long as Caddy is the single +front proxy and its trusted_proxies is correct (the default)**. + +- **Stated assumption (put in the tab help + the `clusev:unban` docs):** Caddy must be the only + intermediary. If the operator puts a second, untrusted proxy in front of Caddy that blindly forwards + XFF, the app can be made to ban a forged/victim IP. The whitelist + the host CLI escape (§7) bound + the damage. +- **Dev:** `trustProxies` is not called, so `request()->ip()` is the Docker gateway, not a real client. + This is a **production feature**; dev/unit tests exercise the logic by injecting the IP (mock + `request()->ip()` / `Request::create(...)`), not by relying on real forwarding. + +--- + +## 1. Two layers, clearly separated + +- **Layer 1 — existing soft throttle (unchanged).** The `RateLimiter` buckets in + [`Login`](../../../app/Livewire/Auth/Login.php) and + [`CompletesTwoFactorChallenge`](../../../app/Livewire/Concerns/CompletesTwoFactorChallenge.php) + (5/60 s etc.) stay exactly as they are — a short, self-expiring, per-(email+IP) friction layer. +- **Layer 2 — new persistent IP ban (this spec).** A longer-horizon escalation: count failures per IP + within `findtime`; at `maxretry` create a **persisted** ban for `bantime` that blocks **unauthenticated** + requests from that IP via early middleware (see §4 for why guests-only). Survives restarts; enumerable + and clearable from the panel + a host CLI. + +## 2. Data model — `BannedIp` + +New model `App\Models\BannedIp` + migration `banned_ips`: + +| column | type | note | +|---|---|---| +| `id` | bigint PK | | +| `ip` | string(45), **unique, indexed** | stored in **canonical form** (`inet_ntop(inet_pton($ip))`) | +| `banned_until` | timestamp | ban auto-expires at this instant (app timezone via `now()`) | +| `reason` | string, nullable | `login` or `2fa` | +| `attempts` | unsigned int | failures that triggered the ban (for the UI) | +| timestamps | | `created_at` = banned-at | + +"Active ban" = row where `banned_until > now()`. Ban **history** is the existing `AuditEvent` stream +(`auth.ip_banned` / `auth.ip_unbanned`), not a second table. + +## 3. `BruteforceGuard` service — record, ban, exempt + +`App\Services\BruteforceGuard` owns all decision logic. + +- **`config()`** reads the `Setting` keys with casts (Setting stores **strings only** — see + [`Setting`](../../../app/Models/Setting.php)): `enabled = Setting::get('bruteforce_enabled', '1') === '1'`, + `maxretry = (int) Setting::get('bruteforce_maxretry', '10')`, `findtime = (int) …('bruteforce_findtime', '10')`, + `bantime = (int) …('bruteforce_bantime', '60')` (both in **minutes**), `whitelist` = the parsed CIDR list. + +- **`record(?string $ip, string $reason): void`** — the failure recorder: + 1. Return immediately if the feature is disabled, `$ip` is empty / not `filter_var(...FILTER_VALIDATE_IP)`, + or `isExempt($ip)` is true (no counting of exempt/loopback/whitelisted IPs). + 2. Register a hit on a rolling counter and read the count: + `RateLimiter::hit('bruteforce:'.md5($ip), $findtime * 60); $hits = RateLimiter::attempts('bruteforce:'.md5($ip));` + (`hit()` returns void in this Laravel version — the count comes from `attempts()`; `findtime` + is minutes → ×60 for the second/decay arg, matching the existing RateLimiter usage). + 3. If `$hits >= $maxretry`: **atomically** upsert the ban (race-safe — two concurrent requests at the + threshold must not collide on the unique `ip` index): + `BannedIp::upsert([['ip' => $canonical, 'banned_until' => now()->addMinutes($bantime), 'reason' => $reason, 'attempts' => $hits]], ['ip'], ['banned_until', 'reason', 'attempts'])`, + then `RateLimiter::clear(...)` the counter, `Cache::forget('bruteforce:banned:'.$canonical)`, + and write `AuditEvent` `auth.ip_banned` (payload in §5). + +- **`isBanned(string $ip): bool`** — `false` if `isExempt($ip)` (exempt short-circuits, so whitelisting + an IP unblocks it **immediately** even while a row lingers); else a cached lookup of an active, + non-expired `BannedIp` for the canonical IP. Cache: `Cache::remember('bruteforce:banned:'.$canonical, 30, fn () => BannedIp::where('ip', $canonical)->where('banned_until', '>', now())->exists())` + — a 30 s per-IP boolean, busted (`Cache::forget`) on every ban and unban so a banned IP is visible + ≤30 s after creation and an unbanned IP is free immediately. + +- **`isExempt(string $ip): bool`** — **hard, non-negotiable safety.** Always true for loopback + (`127.0.0.0/8`, `::1`), the configured trusted-proxy CIDR, and any whitelist entry. Matching uses an + **`inet_pton`-based CIDR helper** (`matchesCidr(string $ip, string $cidr): bool`) that normalises both + sides to packed bytes and compares the network prefix — IPv4, IPv6, and IPv4-mapped-IPv6 + (`::ffff:127.0.0.1`) all handled, mirroring `Fail2banService`'s `inet_pton` comparison so spellings + like `::1` vs `0:0:0:0:0:0:0:1` can't bypass it. + +## 4. `BlockBannedIp` middleware — enforcement (guests only) + +New `App\Http\Middleware\BlockBannedIp`, **prepended to the `web` group BEFORE `PanelScheme`** in +[`bootstrap/app.php`](../../../bootstrap/app.php) (prepend array = `[BlockBannedIp::class, PanelScheme::class]`). +trustProxies has already resolved the real IP by then (§0). + +- If the feature is disabled → pass (one cached `Setting` read; no DB hit). +- **Block guests only:** if `Auth::guest()` **and** `BruteforceGuard::isBanned(request()->ip())` → + return a minimal **403** and **do not call `$next()`** (no downstream middleware/route runs). An + **authenticated** request always passes — an operator already logged in is past the login brute-force + surface, is never cut off mid-session, and can reach the Anmeldeschutz tab to unban their own IP even + while it is banned. The login + 2FA pages are guest requests, so the brute-force surface itself is + still fully covered. +- **403 response:** a dedicated, tiny `errors.blocked` view (no app shell, no Livewire, no JS), + rendered in the saved locale. Generic copy only — "Zugriff vorübergehend gesperrt … später erneut + versuchen" — it must **not** reveal the exact expiry time, the reason, or whether the IP was flagged + (no info leak / no confirmation to an attacker). + +## 5. Hooks — feed the guard + +Centralise one call so all failure sites share it; each runs **after** the existing +`RateLimiter::hit`/`assertNotRateLimited` (additive, never changes the throttle): + +- [`Login::authenticate()`](../../../app/Livewire/Auth/Login.php) on a wrong password → + `app(BruteforceGuard::class)->record(request()->ip(), 'login')` + `AuditEvent`: + `actor='system'`, `action='auth.login_failed'`, `target=$email`, `ip=request()->ip()` (failed logins + are not audited today — this closes that gap). +- The 2FA failure path in `CompletesTwoFactorChallenge` (the three `hitRateLimit()` sites: + `TwoFactorChallenge::verify`/`verifyWebauthn`, `TwoFactorBackup::verify`) → `record(request()->ip(), '2fa')` + + `AuditEvent` `action='auth.2fa_failed'`, `target=(string) session('2fa.user')`, `ip=…`. +- **Audit payloads:** `auth.ip_banned` → `actor='system'`, `target=$ip`, `ip=$ip`, `meta=['reason'=>$reason,'attempts'=>$hits]`. + `auth.ip_unbanned` → `actor=`, `target=$ip`, `ip=request()->ip()`, `meta=['scope'=>'single'|'all'|'whitelist']`. + +## 6. Settings tab — "Anmeldeschutz" + +New top-level settings tab, key **`login-protection`** (English per R13), placed **after** the existing +`security` tab so related settings group together. Label „Anmeldeschutz" / "Login protection". + +- **Component** `App\Livewire\Settings\LoginProtection` + view `livewire.settings.login-protection`; + add the tab object to the `$tabs` array **and** the `@elseif ($tab === 'login-protection')` arm in + [`settings/index.blade.php`](../../../resources/views/livewire/settings/index.blade.php). While there, + add a safe `@else ` default to the tab switch (today an unknown `?tab=` + renders nothing). +- **Config form** — typed component props loaded with casts and saved stringified: + load `$this->enabled = Setting::get('bruteforce_enabled','1') === '1'`, `$this->maxretry = (int) …`; + save `Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0')`, `Setting::put('bruteforce_maxretry', (string) $this->maxretry)`, etc. + Fields: enabled (toggle) · maxretry (int) · findtime (min) · bantime (min) · whitelist (textarea of + IP/CIDR). Whitelist validated by a new **`app/Rules/ValidIpOrCidr`** rule (rejects junk; default value + = the private ranges `10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16`). On save, **delete any `BannedIp` + rows now covered by the (new) whitelist** so widening it clears matching bans immediately. +- **Current-IP self-service (lockout mitigation):** the tab shows the operator's current + `request()->ip()` with its ban/whitelist status and a one-click **„Meine IP zur Whitelist hinzufügen"** + — so an operator can exempt themselves before/while enabling the feature. +- **Active-bans table:** IP · reason · attempts · banned-until (relative + absolute) · **„Entbannen"**. + A **„Alle entbannen"** action. Empty-state when none. Reads `BannedIp::where('banned_until','>',now())`. + Both unban actions are **destructive → wire-elements/modal confirm (R5)**, matching the sessions / + webauthn-keys tabs (no Alpine `confirm()`). Unban deletes the row(s), `Cache::forget` the IP key(s), + and writes `auth.ip_unbanned`. + +## 7. Lockout safety (it ships enabled — these are the guarantees) + +- **Guests-only enforcement (§4):** an authenticated operator is never blocked and can unban their own + IP from the tab even while it is banned. This is the primary self-lockout safeguard. +- **Always-exempt** loopback + trusted-proxy CIDR (§3) — non-configurable; prevents the proxy-collapse + total lockout. +- **Self-whitelist** of the current IP from the tab (§6); the default whitelist already covers private + ranges, so a LAN/VPN operator is exempt out of the box. +- **Bans are always time-bounded** (`banned_until`); they expire on their own. No permanent ban. +- **Host escape hatch:** new artisan command `clusev:unban {ip?} {--all}` (clears one or all bans, + bypasses HTTP) **plus** an `unban) compose exec app php artisan clusev:unban "$@" ;;` case added to the + host `clusev` wrapper (`docker/clusev/clusev`) and its usage text — so `sudo clusev unban ` works + from the VM shell, same philosophy as `clusev:reset-admin`. +- **Residual risk to surface (tab help + CHANGELOG):** an operator on a **public / CGNAT / shared** IP + who is **not** logged in can be locked out (by their own fumbling or by an attacker sharing the IP) + until the ban expires or they run the CLI. Mitigations above bound it; the help text states it and + points at the CLI. +- **Disable behaviour:** turning the feature off does **not** delete `BannedIp` rows; re-enabling resumes + any still-valid bans. Documented in the tab; covered by a test. + +## 8. i18n + +- Tab/config/table/unban strings → `lang/{de,en}/settings.php` (`tab_*` nav label + a `login_protection_*` + group). The **403 block-page** strings → `lang/{de,en}/errors.php` (error-page group per R16, mirroring + the view tree), e.g. `blocked_title` / `blocked_body`. DE + EN, identical keys, terse, no emoji (R9/R16). + +## Files touched + +New: `app/Models/BannedIp.php`, `database/migrations/xxxx_create_banned_ips_table.php`, +`app/Services/BruteforceGuard.php`, `app/Http/Middleware/BlockBannedIp.php`, `app/Rules/ValidIpOrCidr.php`, +`app/Console/Commands/UnbanIp.php` (`clusev:unban`), `app/Livewire/Settings/LoginProtection.php` + +`resources/views/livewire/settings/login-protection.blade.php`, `resources/views/errors/blocked.blade.php`, +the feature tests. Modify: `bootstrap/app.php` (prepend `BlockBannedIp` before `PanelScheme`), +`app/Livewire/Auth/Login.php` + `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` (record + audit +hooks), `resources/views/livewire/settings/index.blade.php` (new tab + `@else` default), `lang/de/settings.php`, +`lang/en/settings.php`, `lang/de/errors.php`, `lang/en/errors.php`, `docker/clusev/clusev` (add `unban`). +**Not touched:** `Fail2banService`, `FirewallService`, the existing RateLimiter logic, `trustProxies`. + +## Testing + +Feature tests (PHPUnit, `RefreshDatabase`, `Cache::flush()` in `setUp`); inject the client IP where +needed (mock `request()->ip()` / build the request) since dev has no real forwarding (§0): + +- **Guard:** `maxretry` failures within `findtime` create a `BannedIp` with the right `banned_until` and + `auth.ip_banned`; fewer do not; the counter resets after a ban; `findtime` minutes→seconds is correct. +- **Exemptions:** whitelisted, loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`), and trusted-proxy IPs + are **never** counted and **never** banned, however many failures; IPv6 spellings of the same address + are treated as one. +- **Concurrency:** the upsert is race-safe — a second ban attempt at the threshold updates, never throws + on the unique `ip` index. +- **Middleware:** a **guest** request from a banned IP gets 403 on `/login` and another guest route; an + **authenticated** request from the same banned IP **passes**; a non-banned guest passes; feature + disabled ⇒ a banned-row IP passes; an **expired** ban passes. +- **Cache:** ban then unban → the next request from that IP is **not** blocked (cache busted, no stale + positive); a fresh ban is visible within the 30 s window. +- **Hooks:** a real failed `Login` and a failed 2FA `verify` (both `TwoFactorChallenge` and + `TwoFactorBackup`) each drive the guard to a ban at the threshold (through the actual components). +- **Settings tab:** saving persists+casts the `Setting` keys; `ValidIpOrCidr` rejects junk; the table + lists active bans; single + bulk unban (via the confirm modal) remove rows + bust cache + audit; + widening the whitelist deletes now-covered bans; the "whitelist my IP" action exempts the current IP; + toggling disabled stops enforcement; re-enabling resumes a still-valid ban. +- **CLI:** `clusev:unban ` and `--all` clear bans. +- **R12** browser-verify (DE+EN): the new tab loads (200, no console errors, DOM leak scan scoped to the + tab, 3 breakpoints). The 403 `errors.blocked` view is asserted in a feature test (it returns 403, not + 200) for content + i18n + no leaks, not via the R12 200-page path. +- **R15** `/codex:review` clean; Pint clean. + +## Out of scope + +- The optional **host-fail2ban logfile hook** (emit a fail2ban-format auth-failure log + ship a jail + template for an operator-run host daemon). Explicitly deferred. +- Any change to the remote-fleet `Fail2banService` / `FirewallService`, or to `trustProxies`. +- Banning on anything other than failed control-plane auth (no WAF, no per-route rules, no geo-blocking). +- A separate ban-history table or analytics — history is the `AuditEvent` stream.