From e602320c9d6796120e16cded02ab994866cde348 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 02:16:37 +0200 Subject: [PATCH] fix(rbac,honeypot): gate settings/release surfaces + per-install canaries, no reflected/auth-user honeypot bans Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 + app/Http/Controllers/HoneypotController.php | 46 +++- app/Http/Middleware/DetectHoneytoken.php | 35 ++- app/Livewire/Release/Index.php | 8 + app/Livewire/Settings/Email.php | 2 + app/Livewire/Settings/LoginProtection.php | 7 + app/Livewire/Settings/Sessions.php | 2 + app/Services/BruteforceGuard.php | 10 +- config/clusev.php | 15 +- install.sh | 5 + .../views/livewire/settings/index.blade.php | 17 +- .../livewire/settings/sessions.blade.php | 8 +- tests/Feature/HoneypotTest.php | 89 +++++- tests/Feature/RbacSettingsGateTest.php | 255 ++++++++++++++++++ 14 files changed, 462 insertions(+), 42 deletions(-) create mode 100644 tests/Feature/RbacSettingsGateTest.php diff --git a/.env.example b/.env.example index d256458..b3972b8 100644 --- a/.env.example +++ b/.env.example @@ -110,6 +110,11 @@ UPDATE_HMAC_KEY= # it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates # it; leave empty to DISABLE the terminal (the resolve endpoint then 403s). TERMINAL_SIDECAR_SECRET= +# Per-install RANDOM honeypot canary values embedded in the served fake .env. install.sh generates +# them; leave empty to DISABLE honeytoken detection (the deception layer still serves plausible bait). +CLUSEV_HONEYPOT_CANARY_APP= +CLUSEV_HONEYPOT_CANARY_DB= +CLUSEV_HONEYPOT_CANARY_API= # ── Optional demo server (seeded by FleetSeeder) ───────────────────── # Leave HOST empty for an empty fleet (add servers at runtime). With a host set, diff --git a/app/Http/Controllers/HoneypotController.php b/app/Http/Controllers/HoneypotController.php index 382568d..22d2213 100644 --- a/app/Http/Controllers/HoneypotController.php +++ b/app/Http/Controllers/HoneypotController.php @@ -25,6 +25,12 @@ class HoneypotController extends Controller abort(404); } + // An AUTHENTICATED user hitting a decoy is a stray request, NOT an attacker: behave like an + // ordinary 404 (no ban, no deception, no audit) so a logged-in operator/admin never self-bans. + if (auth()->check()) { + abort(404); + } + $ip = (string) $request->ip(); AuditEvent::create([ @@ -54,6 +60,10 @@ class HoneypotController extends Controller return response($this->fakeDotenv(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']); } + if ($path === '.git/config') { + return response($this->fakeGitConfig(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + if (str_starts_with($path, 'wp-login.php') || str_starts_with($path, 'wp-admin')) { return response($this->fakeWordPress(), 200, ['Content-Type' => 'text/html; charset=UTF-8']); } @@ -65,15 +75,23 @@ class HoneypotController extends Controller return response($this->fakeGenericLogin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']); } - /** Plausible-looking dotenv seeded with the three config canaries (the bait). */ + /** + * Plausible-looking dotenv seeded with the per-install canaries (the bait) when configured. When a + * canary is empty (unconfigured/dev), a plausible static value is served instead so the decoy still + * looks authentic — it is simply not registered as a detectable honeytoken. + */ private function fakeDotenv(): string { $c = (array) config('clusev.honeypot.canaries'); + $appKey = ($c['app_key'] ?? '') !== '' ? $c['app_key'] : 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0='; + $dbPassword = ($c['db_password'] ?? '') !== '' ? $c['db_password'] : 'Sup3rS3cr3t-Pr0d-DB-9f2a7c'; + $apiKey = ($c['api_key'] ?? '') !== '' ? $c['api_key'] : 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84'; + return implode("\n", [ 'APP_NAME=Application', 'APP_ENV=production', - 'APP_KEY='.($c['app_key'] ?? ''), + 'APP_KEY='.$appKey, 'APP_DEBUG=false', 'APP_URL=https://app.internal.example.com', '', @@ -82,15 +100,35 @@ class HoneypotController extends Controller 'DB_PORT=3306', 'DB_DATABASE=app_production', 'DB_USERNAME=app_prod', - 'DB_PASSWORD='.($c['db_password'] ?? ''), + 'DB_PASSWORD='.$dbPassword, '', + 'API_KEY='.$apiKey, 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE', - 'AWS_SECRET_ACCESS_KEY='.($c['aws_secret'] ?? ''), + 'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 'AWS_DEFAULT_REGION=eu-central-1', '', ]); } + /** Plausible fake git config (an [core]/[remote "origin"] INI) so the /.git/config decoy looks real. */ + private function fakeGitConfig(): string + { + return implode("\n", [ + '[core]', + "\trepositoryformatversion = 0", + "\tfilemode = true", + "\tbare = false", + "\tlogallrefupdates = true", + '[remote "origin"]', + "\turl = https://git.internal.example.com/app/backend.git", + "\tfetch = +refs/heads/*:refs/remotes/origin/*", + '[branch "main"]', + "\tremote = origin", + "\tmerge = refs/heads/main", + '', + ]); + } + private function fakeWordPress(): string { return <<<'HTML' diff --git a/app/Http/Middleware/DetectHoneytoken.php b/app/Http/Middleware/DetectHoneytoken.php index 106661e..ec0657b 100644 --- a/app/Http/Middleware/DetectHoneytoken.php +++ b/app/Http/Middleware/DetectHoneytoken.php @@ -9,20 +9,22 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** - * Honeytoken tripwire. The honeypot's fake .env seeds three canary secret VALUES that no real - * system secret ever equals (config('clusev.honeypot.canaries')). If an attacker who scraped the - * decoy .env replays one of those values — as a form field, a bearer token, or an Authorization - * header — it is undeniable proof they took the bait, so we instant-ban + 403. + * Honeytoken tripwire. The honeypot's fake .env seeds per-install canary secret VALUES that no real + * system secret ever equals (config('clusev.honeypot.canaries')). If an UNAUTHENTICATED attacker who + * scraped the decoy .env replays one of those values — as a request-BODY credential field, a bearer + * token, or an Authorization header — it is undeniable proof they took the bait, so we instant-ban + 403. * - * Deliberately CHEAP: it inspects only a fixed handful of well-known credential inputs plus the - * bearer token / Authorization header — never a recursive walk of every nested input — so a normal - * request (login, form post, API call) not carrying a canary is a near-free no-op. Appended to the - * web group AFTER AuthenticateSession so the session/IP are resolved. + * Deliberately CHEAP and FALSE-POSITIVE-FREE: it only trips for anonymous requests (a logged-in user + * is never a honeytoken replayer, and this stops a reflected decoy link banning a real victim), it + * scans only a fixed handful of well-known credential inputs from the request BODY (never the query + * string, never `password`) plus the bearer token / Authorization header — never a recursive walk. + * A normal request not carrying a canary is a near-free no-op. Appended to the web group AFTER + * AuthenticateSession so the session/IP are resolved. */ class DetectHoneytoken { - /** Request input keys that plausibly carry a leaked secret. Kept tiny on purpose. */ - private const CANDIDATE_KEYS = ['password', 'api_key', 'token', 'secret', 'key']; + /** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */ + private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key']; public function __construct(private BruteforceGuard $guard) {} @@ -32,15 +34,22 @@ class DetectHoneytoken return $next($request); } - $canaries = (array) config('clusev.honeypot.canaries'); + // Only trap UNAUTHENTICATED attackers. A logged-in user is never a honeytoken replayer, and + // this stops a reflected decoy link from banning an authenticated victim. + if (auth()->check()) { + return $next($request); + } + + $canaries = config('clusev.honeypot.canaries', []); if ($canaries === []) { return $next($request); } - // A small, bounded set of candidate values — no recursion into nested arrays. + // A small, bounded set of candidate values — request BODY only (post()), no query string, + // no recursion into nested arrays. $candidates = []; foreach (self::CANDIDATE_KEYS as $key) { - $value = $request->input($key); + $value = $request->post($key); if (is_string($value) && $value !== '') { $candidates[] = $value; } diff --git a/app/Livewire/Release/Index.php b/app/Livewire/Release/Index.php index f882040..a198d6d 100644 --- a/app/Livewire/Release/Index.php +++ b/app/Livewire/Release/Index.php @@ -43,11 +43,13 @@ class Index extends Component public function mount(): void { abort_unless((bool) config('clusev.release_controls'), 404); + abort_unless(auth()->user()?->can('manage-panel'), 403); } /** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging beta of $target. */ public function confirmDeployStaging(string $target, ReleasePlanner $planner): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) { $this->dispatch('notify', message: __('release.invalid_target'), level: 'error'); @@ -63,6 +65,7 @@ class Index extends Component #[On('releaseStaged')] public function applyDeployStaging(string $confirmToken, ReleasePlanner $planner, ReleaseBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'releaseStaged'); } catch (InvalidConfirmToken) { @@ -80,6 +83,7 @@ class Index extends Component /** Cut + push a beta of $target via the host bridge. $target must be one of the proposed values. */ public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) { $this->dispatch('notify', message: __('release.invalid_target'), level: 'error'); @@ -192,6 +196,7 @@ class Index extends Component public function confirmDeployPublic(): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $tag = $this->currentBetaTag(); if ($tag === null) { return; @@ -204,6 +209,7 @@ class Index extends Component #[On('releasePublic')] public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'releasePublic'); } catch (InvalidConfirmToken) { @@ -222,6 +228,7 @@ class Index extends Component public function confirmYank(string $tag, PromotionService $promotion): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); if (! in_array($tag, $promotion->publicTags(), true)) { $this->dispatch('notify', message: __('release.yank_unknown'), level: 'error'); @@ -235,6 +242,7 @@ class Index extends Component #[On('releaseYank')] public function applyYank(string $confirmToken, PromotionService $promotion): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'releaseYank'); } catch (InvalidConfirmToken) { diff --git a/app/Livewire/Settings/Email.php b/app/Livewire/Settings/Email.php index 5ed6226..ba307bf 100644 --- a/app/Livewire/Settings/Email.php +++ b/app/Livewire/Settings/Email.php @@ -44,6 +44,7 @@ class Email extends Component public function mount(): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $this->mail_host = Setting::get('mail_host', '') ?? ''; $this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587); $this->mail_username = Setting::get('mail_username', '') ?? ''; @@ -68,6 +69,7 @@ class Email extends Component public function save(): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $this->validate(); Setting::put('mail_host', $this->mail_host); diff --git a/app/Livewire/Settings/LoginProtection.php b/app/Livewire/Settings/LoginProtection.php index 4d24037..780da01 100644 --- a/app/Livewire/Settings/LoginProtection.php +++ b/app/Livewire/Settings/LoginProtection.php @@ -28,6 +28,7 @@ class LoginProtection extends Component public function mount(BruteforceGuard $guard): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $this->enabled = $guard->enabled(); $this->maxretry = $guard->maxretry(); $this->findtime = $guard->findtime(); @@ -37,6 +38,7 @@ class LoginProtection extends Component public function save(BruteforceGuard $guard): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $this->validate([ 'maxretry' => ['required', 'integer', 'min:1', 'max:1000'], 'findtime' => ['required', 'integer', 'min:1', 'max:1440'], @@ -72,6 +74,7 @@ class LoginProtection extends Component public function whitelistMyIp(): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $ip = (string) request()->ip(); $lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: []))); if (! in_array($ip, $lines, true)) { @@ -82,6 +85,7 @@ class LoginProtection extends Component public function confirmUnban(string $ip): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ @@ -99,6 +103,7 @@ class LoginProtection extends Component #[On('banCleared')] public function unban(string $confirmToken, BruteforceGuard $guard): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'banCleared'); } catch (InvalidConfirmToken) { @@ -111,6 +116,7 @@ class LoginProtection extends Component public function confirmUnbanAll(): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ @@ -128,6 +134,7 @@ class LoginProtection extends Component #[On('bansClearedAll')] public function unbanAll(string $confirmToken, BruteforceGuard $guard): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); try { ConfirmToken::consume($confirmToken, 'bansClearedAll'); } catch (InvalidConfirmToken) { diff --git a/app/Livewire/Settings/Sessions.php b/app/Livewire/Settings/Sessions.php index 674be33..091ab82 100644 --- a/app/Livewire/Settings/Sessions.php +++ b/app/Livewire/Settings/Sessions.php @@ -57,6 +57,7 @@ class Sessions extends Component */ public function confirmLogoutAll(): void { + abort_unless(auth()->user()?->can('manage-users'), 403); $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ @@ -81,6 +82,7 @@ class Sessions extends Component #[On('sessionsLogoutAll')] public function logoutAll(string $confirmToken, SessionService $sessions) { + abort_unless(auth()->user()?->can('manage-users'), 403); try { ConfirmToken::consume($confirmToken, 'sessionsLogoutAll'); } catch (InvalidConfirmToken) { diff --git a/app/Services/BruteforceGuard.php b/app/Services/BruteforceGuard.php index b24f404..a31bd5f 100644 --- a/app/Services/BruteforceGuard.php +++ b/app/Services/BruteforceGuard.php @@ -122,13 +122,15 @@ class BruteforceGuard * Ban an IP on the FIRST hit — no maxretry threshold, no RateLimiter window. Used by the * honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of * hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the - * throttling. Returns true when a ban was written, false when it no-op'd (disabled / invalid - * / exempt IP) — the caller stays a cheap no-op for legitimate traffic. Reuses the same - * private helpers as record() (canonical/isExempt/enabled/bantime/maxretry). + * throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the + * trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a + * disabled brute-force protection must not silently disable honeypot bans). Returns true when a + * ban was written, false when it no-op'd (invalid / exempt IP) — the caller stays a cheap no-op + * for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry). */ public function banNow(?string $ip, string $reason): bool { - if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { + if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { return false; } diff --git a/config/clusev.php b/config/clusev.php index a8a14d8..d13faaa 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -59,13 +59,14 @@ return [ // Deception layer for unauthenticated attacker probes. ON by default: legit users never hit // decoy paths, so there are no false positives. Set CLUSEV_HONEYPOT=false to disable. 'enabled' => (bool) env('CLUSEV_HONEYPOT', true), - // Canary secret VALUES embedded in the fake .env we serve. No real system secret equals these; - // if any reappears in a later request it PROVES an attacker took the bait -> instant ban. - 'canaries' => [ - 'app_key' => 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0=', - 'db_password' => 'Sup3rS3cr3t-Pr0d-DB-9f2a7c', - 'aws_secret' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', - ], + // Per-install RANDOM canary secret values (install.sh generates them into .env). EMPTY here so + // no guessable/public constant ships; empty => honeytoken detection is OFF (the served fake .env + // still uses plausible static values, they are just not registered as detectable canaries). + 'canaries' => array_filter([ + 'app_key' => (string) env('CLUSEV_HONEYPOT_CANARY_APP', ''), + 'db_password' => (string) env('CLUSEV_HONEYPOT_CANARY_DB', ''), + 'api_key' => (string) env('CLUSEV_HONEYPOT_CANARY_API', ''), + ]), ], 'license' => 'AGPL-3.0', diff --git a/install.sh b/install.sh index edbefb8..a4ccab9 100755 --- a/install.sh +++ b/install.sh @@ -237,6 +237,11 @@ set_kv REVERB_APP_KEY "$(rand_hex 16)" set_kv REVERB_APP_SECRET "$(rand_hex 32)" set_kv UPDATE_HMAC_KEY "$(rand_hex 32)" set_kv TERMINAL_SIDECAR_SECRET "$(rand_hex 24)" +# Per-install RANDOM honeypot canary values. Seeded into the served fake .env; if any reappears +# in a later request it PROVES an attacker took the bait -> instant ban (DetectHoneytoken). +set_kv CLUSEV_HONEYPOT_CANARY_APP "base64:$(openssl rand -base64 24)" +set_kv CLUSEV_HONEYPOT_CANARY_DB "$(rand_hex 20)" +set_kv CLUSEV_HONEYPOT_CANARY_API "$(rand_hex 24)" force_kv APP_ENV production force_kv APP_DEBUG false diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 2c197eb..61a4c91 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -2,14 +2,23 @@ $u = auth()->user(); $initials = strtoupper(mb_substr($u->name, 0, 2)); $twoFactorEnabled = $u->hasTwoFactorEnabled(); + // Profile, security and sessions are self-service (visible to everyone). The + // login-protection + email tabs need manage-panel; the users tab needs + // manage-users — the tab bodies re-guard, so hiding the nav entry is just UX. $tabs = [ ['key' => 'profile', 'label' => __('settings.tab_profile'), 'icon' => 'settings'], ['key' => 'security', 'label' => __('settings.tab_security'), 'icon' => 'shield'], - ['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield'], - ['key' => 'users', 'label' => __('settings.tab_users'), 'icon' => 'user-plus'], - ['key' => 'sessions', 'label' => __('settings.tab_sessions'), 'icon' => 'logout'], - ['key' => 'email', 'label' => __('settings.tab_email'), 'icon' => 'mail'], ]; + if (auth()->user()?->can('manage-panel')) { + $tabs[] = ['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield']; + } + if (auth()->user()?->can('manage-users')) { + $tabs[] = ['key' => 'users', 'label' => __('settings.tab_users'), 'icon' => 'user-plus']; + } + $tabs[] = ['key' => 'sessions', 'label' => __('settings.tab_sessions'), 'icon' => 'logout']; + if (auth()->user()?->can('manage-panel')) { + $tabs[] = ['key' => 'email', 'label' => __('settings.tab_email'), 'icon' => 'mail']; + } @endphp
diff --git a/resources/views/livewire/settings/sessions.blade.php b/resources/views/livewire/settings/sessions.blade.php index a157413..92745ea 100644 --- a/resources/views/livewire/settings/sessions.blade.php +++ b/resources/views/livewire/settings/sessions.blade.php @@ -35,9 +35,11 @@ {{ __('sessions.logout_others') }} - - {{ __('sessions.logout_all') }} - + @can('manage-users') + + {{ __('sessions.logout_all') }} + + @endcan
diff --git a/tests/Feature/HoneypotTest.php b/tests/Feature/HoneypotTest.php index 6b11199..6abea76 100644 --- a/tests/Feature/HoneypotTest.php +++ b/tests/Feature/HoneypotTest.php @@ -4,6 +4,9 @@ namespace Tests\Feature; use App\Models\AuditEvent; use App\Models\BannedIp; +use App\Models\Setting; +use App\Models\User; +use App\Services\BruteforceGuard; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Tests\TestCase; @@ -15,10 +18,26 @@ class HoneypotTest extends TestCase /** A non-exempt (public) client IP — loopback/private ranges are exempt from banNow. */ private const ATTACKER_IP = '203.0.113.99'; + /** Per-install canary values registered for the tests (config default is now empty). */ + private const CANARY_APP = 'base64:T3sTC4n4ryAppK3yAAAAAAAAAAAAAAAAAAAAAAAAA0='; + + private const CANARY_DB = 'T3sT-Pr0d-DB-Pass-9f2a7c11'; + + private const CANARY_API = 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84'; + protected function setUp(): void { parent::setUp(); Cache::flush(); + + // Register per-install canaries for detection (config ships EMPTY so nothing guessable leaks). + config([ + 'clusev.honeypot.canaries' => [ + 'app_key' => self::CANARY_APP, + 'db_password' => self::CANARY_DB, + 'api_key' => self::CANARY_API, + ], + ]); } /** Simulate a request from a specific client IP via REMOTE_ADDR (trustProxies is prod-gated). */ @@ -52,26 +71,82 @@ class HoneypotTest extends TestCase $this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists()); } - public function test_honeytoken_replay_is_blocked_banned_and_audited(): void + public function test_git_config_decoy_serves_fake_git_config_and_bans(): void { - $canary = config('clusev.honeypot.canaries.db_password'); + $response = $this->fromIp('GET', '/.git/config', self::ATTACKER_IP); - // A normal POST web route carrying the leaked canary in a form field trips the middleware - // (broadcasting/auth is in the web group, so the appended DetectHoneytoken runs). The 403 - // fires before the route's own handler, proving the tripwire, not the endpoint, responded. - $response = $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['password' => $canary]); + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $response->assertSee('[remote "origin"]', false); + $response->assertSee('[core]', false); + + $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists()); + $this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists()); + } + + public function test_honeytoken_replay_in_post_body_api_key_is_blocked_banned_and_audited(): void + { + $canary = config('clusev.honeypot.canaries.api_key'); + + // An unauthenticated POST web route carrying the leaked canary in the api_key BODY field trips + // the middleware (broadcasting/auth is in the web group, so the appended DetectHoneytoken runs). + // The 403 fires before the route's own handler, proving the tripwire — not the endpoint — responded. + $response = $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['api_key' => $canary]); $response->assertStatus(403); $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists()); $this->assertTrue( AuditEvent::where('action', 'security.honeytoken_used') - ->where('target', 'db_password') + ->where('target', 'api_key') ->where('ip', self::ATTACKER_IP) ->exists() ); } + public function test_login_post_with_password_equal_to_canary_does_not_ban(): void + { + // `password` is no longer scanned — a login carrying a value that happens to equal a canary + // (e.g. a reflected value) must NOT trip the honeytoken tripwire. + $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['password' => self::CANARY_DB]); + + $this->assertDatabaseCount('banned_ips', 0); + $this->assertFalse(AuditEvent::where('action', 'security.honeytoken_used')->exists()); + } + + public function test_get_query_string_with_canary_token_does_not_ban(): void + { + // The query string is never scanned — a reflected GET decoy link ?token= must NOT ban + // the victim who clicks it. + $this->fromIp('GET', '/login?token='.self::CANARY_API, self::ATTACKER_IP); + + $this->assertDatabaseCount('banned_ips', 0); + $this->assertFalse(AuditEvent::where('action', 'security.honeytoken_used')->exists()); + } + + public function test_authenticated_user_hitting_decoy_gets_404_and_is_not_banned(): void + { + $user = User::factory()->create(['must_change_password' => false]); + + $response = $this->actingAs($user)->fromIp('GET', '/wp-login.php', self::ATTACKER_IP); + + $response->assertStatus(404); + $this->assertDatabaseCount('banned_ips', 0); + $this->assertFalse(AuditEvent::where('action', 'security.honeypot_hit')->exists()); + } + + public function test_ban_now_fires_even_when_bruteforce_protection_is_disabled(): void + { + // Disabling the brute-force login toggle must NOT silently disable honeypot bans: banNow no + // longer consults enabled(). The honeypot flag alone gates whether the trap runs. + Setting::put('bruteforce_enabled', '0'); + + $banned = app(BruteforceGuard::class)->banNow(self::ATTACKER_IP, 'honeytoken'); + + $this->assertTrue($banned); + $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists()); + } + public function test_disabled_honeypot_returns_404_without_ban_or_audit(): void { config(['clusev.honeypot.enabled' => false]); diff --git a/tests/Feature/RbacSettingsGateTest.php b/tests/Feature/RbacSettingsGateTest.php new file mode 100644 index 0000000..295ad3b --- /dev/null +++ b/tests/Feature/RbacSettingsGateTest.php @@ -0,0 +1,255 @@ +create(['must_change_password' => false]); + } + + private function operator(): User + { + return User::factory()->operator()->create(['must_change_password' => false]); + } + + private function viewer(): User + { + return User::factory()->viewer()->create(['must_change_password' => false]); + } + + // ── manage-panel: LoginProtection (mount + save + unban + unbanAll + whitelistMyIp) ── + + public function test_operator_cannot_mount_login_protection(): void + { + Livewire::actingAs($this->operator()) + ->test(LoginProtection::class) + ->assertForbidden(); + } + + public function test_viewer_cannot_mount_login_protection(): void + { + Livewire::actingAs($this->viewer()) + ->test(LoginProtection::class) + ->assertForbidden(); + } + + public function test_admin_can_mount_login_protection(): void + { + Livewire::actingAs($this->admin()) + ->test(LoginProtection::class) + ->assertOk(); + } + + public function test_operator_cannot_save_login_protection(): void + { + // Boot as admin so the snapshot is valid, then swap to an operator and call the mutation — + // the save() method-level guard must still fire 403 (models a demotion / crafted update). + $component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk(); + + $this->actingAs($this->operator()); + + $component->set('maxretry', 5)->call('save')->assertForbidden(); + } + + public function test_viewer_cannot_call_whitelist_my_ip(): void + { + $component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk(); + + $this->actingAs($this->viewer()); + + $component->call('whitelistMyIp')->assertForbidden(); + } + + public function test_operator_cannot_unban(): void + { + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + + $component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk(); + + $this->actingAs($this->operator()); + + $component->call('unban', 'any-token')->assertForbidden(); + $this->assertDatabaseCount('banned_ips', 1); // guard fired before the ban was touched + } + + public function test_viewer_cannot_unban_all(): void + { + BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]); + + $component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk(); + + $this->actingAs($this->viewer()); + + $component->call('unbanAll', 'any-token')->assertForbidden(); + $this->assertDatabaseCount('banned_ips', 1); + } + + public function test_admin_can_save_login_protection(): void + { + Livewire::actingAs($this->admin()) + ->test(LoginProtection::class) + ->set('maxretry', 7) + ->call('save') + ->assertHasNoErrors(); // allowed through the guard + } + + // ── manage-panel: Email (mount + save) ────────────────────────────────────── + + public function test_operator_cannot_mount_email(): void + { + Livewire::actingAs($this->operator()) + ->test(Email::class) + ->assertForbidden(); + } + + public function test_viewer_cannot_mount_email(): void + { + Livewire::actingAs($this->viewer()) + ->test(Email::class) + ->assertForbidden(); + } + + public function test_operator_cannot_save_email(): void + { + $component = Livewire::actingAs($this->admin())->test(Email::class)->assertOk(); + + $this->actingAs($this->operator()); + + $component + ->set('mail_host', 'smtp.example.com') + ->set('mail_from_address', 'noreply@example.com') + ->set('mail_from_name', 'Clusev') + ->call('save') + ->assertForbidden(); + } + + public function test_admin_can_save_email(): void + { + Livewire::actingAs($this->admin()) + ->test(Email::class) + ->set('mail_host', 'smtp.example.com') + ->set('mail_from_address', 'noreply@example.com') + ->set('mail_from_name', 'Clusev') + ->call('save') + ->assertHasNoErrors(); + } + + // ── manage-users: Sessions::logoutAll (destructive GLOBAL logout) ───────────── + + public function test_operator_cannot_logout_all(): void + { + $component = Livewire::actingAs($this->admin())->test(Sessions::class)->assertOk(); + + $this->actingAs($this->operator()); + + $component->call('logoutAll', 'any-token')->assertForbidden(); + } + + public function test_viewer_cannot_logout_all(): void + { + $component = Livewire::actingAs($this->admin())->test(Sessions::class)->assertOk(); + + $this->actingAs($this->viewer()); + + $component->call('logoutAll', 'any-token')->assertForbidden(); + } + + public function test_admin_can_logout_all(): void + { + $admin = $this->admin(); + + // The global logout truncates every session — mock it so the test session survives. + $sessions = Mockery::mock(SessionService::class)->makePartial(); + $sessions->shouldReceive('logoutEveryone')->once(); + $sessions->shouldReceive('forUser')->andReturn([]); + $this->app->instance(SessionService::class, $sessions); + + $this->actingAs($admin); + $token = ConfirmToken::issue('sessionsLogoutAll', [], 'session.logout_all', $admin->email); + ConfirmToken::confirm($token); + + Livewire::actingAs($admin) + ->test(Sessions::class) + ->call('logoutAll', $token) + ->assertRedirect(route('login')); // allowed through the guard → redirect to login + } + + public function test_non_admin_self_session_revoke_is_not_forbidden(): void + { + // A non-admin managing their OWN sessions (per-session self-revoke) is NOT gated. Opening the + // "sign out other devices" confirm must not 403 for an operator. + Livewire::actingAs($this->operator()) + ->test(Sessions::class) + ->call('confirmLogoutOthers') + ->assertOk(); + } + + // ── manage-panel: Release\Index (flag ON, but role still enforced) ─────────── + + public function test_operator_cannot_mount_release_even_with_flag_on(): void + { + config()->set('clusev.release_controls', true); + + Livewire::actingAs($this->operator()) + ->test(ReleaseIndex::class) + ->assertForbidden(); + } + + public function test_viewer_cannot_mount_release_even_with_flag_on(): void + { + config()->set('clusev.release_controls', true); + + Livewire::actingAs($this->viewer()) + ->test(ReleaseIndex::class) + ->assertForbidden(); + } + + public function test_admin_can_mount_release_with_flag_on(): void + { + config()->set('clusev.release_controls', true); + + Livewire::actingAs($this->admin()) + ->test(ReleaseIndex::class) + ->assertOk(); + } +}