docs: spec — control-plane brute-force IP ban (Anmeldeschutz)
App-level persistent IP ban for Clusev's own login (distinct from the remote-fleet Fail2banService): BannedIp model, BruteforceGuard service, guests-only BlockBannedIp middleware, Login/2FA hooks + audit, a new Anmeldeschutz settings tab, clusev:unban CLI. Hardened after an adversarial multi-lens review: documented IP trust model (Caddy sole proxy, prod-only trustProxies), guests-only enforcement so an authed operator is never locked out and can self-unban, current-IP self-whitelist, concrete cache strategy, inet_pton CIDR matching, R5 confirm modals, errors.php for the 403 strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
d58073086c
commit
fe8c25c824
|
|
@ -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=<operator email>`, `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 <livewire:settings.profile />` 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 <ip>` 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 <ip>` 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.
|
||||
Loading…
Reference in New Issue