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
parent
e58d1a4b07
commit
e602320c9d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue