fix(rbac,honeypot): gate settings/release surfaces + per-install canaries, no reflected/auth-user honeypot bans

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 02:16:37 +02:00
parent e58d1a4b07
commit e602320c9d
14 changed files with 462 additions and 42 deletions

View File

@ -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,

View File

@ -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'

View File

@ -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;
}

View File

@ -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) {

View File

@ -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);

View File

@ -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) {

View File

@ -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) {

View File

@ -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;
}

View File

@ -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',

View File

@ -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

View File

@ -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
<div class="space-y-5">

View File

@ -35,9 +35,11 @@
<x-modal-trigger variant="danger-soft" class="shrink-0" action="confirmLogoutOthers">
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('sessions.logout_others') }}
</x-modal-trigger>
<x-modal-trigger variant="danger" class="shrink-0" action="confirmLogoutAll">
<x-icon name="power" class="h-3.5 w-3.5" /> {{ __('sessions.logout_all') }}
</x-modal-trigger>
@can('manage-users')
<x-modal-trigger variant="danger" class="shrink-0" action="confirmLogoutAll">
<x-icon name="power" class="h-3.5 w-3.5" /> {{ __('sessions.logout_all') }}
</x-modal-trigger>
@endcan
</div>
</div>
</div>

View File

@ -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=<canary> 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]);

View File

@ -0,0 +1,255 @@
<?php
namespace Tests\Feature;
use App\Livewire\Release\Index as ReleaseIndex;
use App\Livewire\Settings\Email;
use App\Livewire\Settings\LoginProtection;
use App\Livewire\Settings\Sessions;
use App\Models\BannedIp;
use App\Models\User;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* RBAC guards on the SETTINGS + RELEASE admin surfaces. The RBAC foundation (Role enum, Gates
* manage-panel/manage-users) is committed separately; this proves the abort_unless(...) guards are
* wired at every dangerous entry point:
* - manage-panel gates LoginProtection (brute-force config / whitelist / ban clearing), Email (SMTP
* config) and the whole Release page + its actions;
* - manage-users gates the destructive GLOBAL logout (Sessions::logoutAll) while per-session
* self-revoke stays open to everyone.
* Admin (factory default) may act; operator + viewer are refused with 403.
*/
class RbacSettingsGateTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->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();
}
}