diff --git a/app/Http/Controllers/HoneypotController.php b/app/Http/Controllers/HoneypotController.php new file mode 100644 index 0000000..382568d --- /dev/null +++ b/app/Http/Controllers/HoneypotController.php @@ -0,0 +1,185 @@ +ip(); + + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'security.honeypot_hit', + 'target' => Str::limit($request->path(), 190), + 'ip' => $ip, + 'meta' => [ + 'ua' => Str::limit((string) $request->userAgent(), 190), + 'method' => $request->method(), + 'query' => Str::limit((string) $request->getQueryString(), 190), + ], + ]); + + app(BruteforceGuard::class)->banNow($ip, 'honeypot'); + + return $this->deceive($request); + } + + /** Pick a convincing fake response for the probed path. */ + private function deceive(Request $request): Response + { + $path = strtolower($request->path()); + + if ($path === '.env') { + return response($this->fakeDotenv(), 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']); + } + + if (str_starts_with($path, 'phpmyadmin')) { + return response($this->fakePhpMyAdmin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + return response($this->fakeGenericLogin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + /** Plausible-looking dotenv seeded with the three config canaries (the bait). */ + private function fakeDotenv(): string + { + $c = (array) config('clusev.honeypot.canaries'); + + return implode("\n", [ + 'APP_NAME=Application', + 'APP_ENV=production', + 'APP_KEY='.($c['app_key'] ?? ''), + 'APP_DEBUG=false', + 'APP_URL=https://app.internal.example.com', + '', + 'DB_CONNECTION=mysql', + 'DB_HOST=10.20.0.14', + 'DB_PORT=3306', + 'DB_DATABASE=app_production', + 'DB_USERNAME=app_prod', + 'DB_PASSWORD='.($c['db_password'] ?? ''), + '', + 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE', + 'AWS_SECRET_ACCESS_KEY='.($c['aws_secret'] ?? ''), + 'AWS_DEFAULT_REGION=eu-central-1', + '', + ]); + } + + private function fakeWordPress(): string + { + return <<<'HTML' + + + + + +Log In ‹ Blog — WordPress + + +
+

Powered by WordPress

+
+

+

+

+

+

+

+
+ +
+ + +HTML; + } + + private function fakePhpMyAdmin(): string + { + return <<<'HTML' + + + + + +phpMyAdmin + + +
+ +

Welcome to phpMyAdmin

+
+
+Log in +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +HTML; + } + + private function fakeGenericLogin(): string + { + return <<<'HTML' + + + + + +Sign in + + +
+

Sign in to continue

+
+ + + + + +
+
+ + +HTML; + } +} diff --git a/app/Http/Middleware/DetectHoneytoken.php b/app/Http/Middleware/DetectHoneytoken.php new file mode 100644 index 0000000..106661e --- /dev/null +++ b/app/Http/Middleware/DetectHoneytoken.php @@ -0,0 +1,86 @@ +input($key); + if (is_string($value) && $value !== '') { + $candidates[] = $value; + } + } + if (($bearer = $request->bearerToken()) !== null && $bearer !== '') { + $candidates[] = $bearer; + } + if (($auth = $request->header('Authorization')) !== null && $auth !== '') { + $candidates[] = $auth; + } + + foreach ($canaries as $name => $canaryValue) { + if (! is_string($canaryValue) || $canaryValue === '') { + continue; + } + foreach ($candidates as $candidate) { + if (hash_equals($canaryValue, $candidate)) { + return $this->trip($request, (string) $name); + } + } + } + + return $next($request); + } + + private function trip(Request $request, string $canaryName): Response + { + $ip = (string) $request->ip(); + + $this->guard->banNow($ip, 'honeytoken'); + + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'security.honeytoken_used', + 'target' => $canaryName, + 'ip' => $ip, + 'meta' => ['path' => $request->path()], + ]); + + return response()->view('errors.blocked', [], 403); + } +} diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 2106188..532ecee 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -37,6 +37,7 @@ class AuditEvent extends Model 'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned', 'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed', 'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed', + 'security.honeypot_hit', 'security.honeytoken_used', ]; /** diff --git a/app/Services/BruteforceGuard.php b/app/Services/BruteforceGuard.php index e4d13fb..b24f404 100644 --- a/app/Services/BruteforceGuard.php +++ b/app/Services/BruteforceGuard.php @@ -118,6 +118,49 @@ 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). + */ + public function banNow(?string $ip, string $reason): bool + { + if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { + return false; + } + + $canonical = $this->canonical($ip); + + $now = now(); + BannedIp::upsert( + [[ + 'ip' => $canonical, + 'banned_until' => $now->copy()->addMinutes($this->bantime()), + 'reason' => $reason, + 'attempts' => $this->maxretry(), + 'created_at' => $now, + 'updated_at' => $now, + ]], + ['ip'], + ['banned_until', 'reason', 'attempts', 'updated_at'], + ); + Cache::forget('bruteforce:banned:'.$canonical); + + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.ip_banned', + 'target' => $canonical, + 'ip' => $canonical, + 'meta' => ['reason' => $reason], + ]); + + return true; + } + /** * Whether $ip has an active ban. Does NOT check enabled() — the BlockBannedIp middleware * gates on enabled() before calling this, so disabling the feature stops enforcement while diff --git a/bootstrap/app.php b/bootstrap/app.php index 13debc7..f44a404 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ web( prepend: [PanelScheme::class], - append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class], + append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class, DetectHoneytoken::class], ); // The terminal sidecar POSTs to the internal resolve endpoint from the private network with diff --git a/config/clusev.php b/config/clusev.php index f90d19e..a8a14d8 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -55,5 +55,18 @@ return [ // generates it alongside the other secrets; the host reads the SAME value from .env. 'update_hmac_key' => env('UPDATE_HMAC_KEY', ''), + 'honeypot' => [ + // 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', + ], + ], + 'license' => 'AGPL-3.0', ]; diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 96fa7bd..8d0d685 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -80,6 +80,13 @@ http { fastcgi_read_timeout 120; } + # Honeypot dotfile decoys: route /.env and /.git/config to Laravel (HoneypotController) + # instead of letting the dotfile-deny below 403 them. Exact-match locations take priority + # over the regex deny in nginx; placed above for clarity. Same try_files → index.php path + # as the main `location /`. All OTHER dotfiles stay denied. + location = /.env { try_files $uri /index.php?$query_string; } + location = /.git/config { try_files $uri /index.php?$query_string; } + # deny dotfiles except .well-known location ~ /\.(?!well-known).* { deny all; diff --git a/lang/de/audit.php b/lang/de/audit.php index 8e33f62..4712e3d 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -60,6 +60,8 @@ return [ 'password.change' => 'Passwort geändert', 'password.reset' => 'Passwort zurückgesetzt', 'profile.update' => 'Profil aktualisiert', + 'security.honeypot_hit' => 'Honeypot-Treffer', + 'security.honeytoken_used' => 'Honeytoken benutzt', 'server.create' => 'Server angelegt', 'server.credential_updated' => 'SSH-Zugangsdaten aktualisiert', 'ssh_key.add' => 'SSH-Schlüssel hinzugefügt', diff --git a/lang/en/audit.php b/lang/en/audit.php index 4705774..b10b8b8 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -60,6 +60,8 @@ return [ 'password.change' => 'Password changed', 'password.reset' => 'Password reset', 'profile.update' => 'Profile updated', + 'security.honeypot_hit' => 'Honeypot hit', + 'security.honeytoken_used' => 'Honeytoken used', 'server.create' => 'Server added', 'server.credential_updated' => 'SSH credentials updated', 'ssh_key.add' => 'SSH key added', diff --git a/routes/web.php b/routes/web.php index 8ac1895..3de7104 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ middleware(VerifyTerminalSidecarSecret::class)->name('internal.terminal.resolve'); +// ── Honeypot decoy paths ──────────────────────────────────────────────────────────────────── +// Well-known scanner/exploit targets that NO legitimate client ever visits. Every hit is a hostile +// probe → HoneypotController::trap logs it, instant-bans the source IP, and serves a convincing fake +// body (BruteforceGuard::banNow no-ops for exempt/loopback IPs). Route::any so probes on any verb +// (GET/POST/HEAD) are caught. These paths were checked against the real top-level routes above +// (/locale, /_caddy/ask, /version.json, /update-status.json, /_internal/*) and the auth groups +// below (/login, /dashboard, …) — none collide. The two dotfile decoys (/.env, /.git/config) are +// additionally allowed through the nginx dotfile-deny guard (docker/nginx/nginx.conf). +Route::any('/.env', [HoneypotController::class, 'trap'])->name('honeypot.env'); +Route::any('/.git/config', [HoneypotController::class, 'trap'])->name('honeypot.git'); +Route::any('/wp-login.php', [HoneypotController::class, 'trap'])->name('honeypot.wp-login'); +Route::any('/wp-admin', [HoneypotController::class, 'trap'])->name('honeypot.wp-admin'); +Route::any('/wp-admin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.wp-admin.path'); +Route::any('/xmlrpc.php', [HoneypotController::class, 'trap'])->name('honeypot.xmlrpc'); +Route::any('/phpmyadmin', [HoneypotController::class, 'trap'])->name('honeypot.phpmyadmin'); +Route::any('/phpmyadmin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.phpmyadmin.path'); +Route::any('/administrator', [HoneypotController::class, 'trap'])->name('honeypot.administrator'); +Route::any('/admin.php', [HoneypotController::class, 'trap'])->name('honeypot.admin-php'); +Route::any('/adminer.php', [HoneypotController::class, 'trap'])->name('honeypot.adminer'); +Route::any('/config.json', [HoneypotController::class, 'trap'])->name('honeypot.config-json'); +Route::any('/vendor/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.vendor'); +Route::any('/cgi-bin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.cgi-bin'); +Route::any('/solr/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.solr'); +Route::any('/actuator/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.actuator'); + Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); diff --git a/tests/Feature/HoneypotTest.php b/tests/Feature/HoneypotTest.php new file mode 100644 index 0000000..6b11199 --- /dev/null +++ b/tests/Feature/HoneypotTest.php @@ -0,0 +1,98 @@ +call($method, $uri, $data, [], [], ['REMOTE_ADDR' => $ip]); + } + + public function test_wp_login_decoy_serves_fake_wordpress_and_bans(): void + { + $response = $this->fromIp('GET', '/wp-login.php', self::ATTACKER_IP); + + $response->assertStatus(200); + $response->assertSee('wp-submit', false); + $response->assertSee('WordPress', false); + + $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists()); + $this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists()); + } + + public function test_env_decoy_leaks_canary_and_bans(): void + { + $canary = config('clusev.honeypot.canaries.app_key'); + + $response = $this->fromIp('GET', '/.env', self::ATTACKER_IP); + + $response->assertStatus(200); + $response->assertSee($canary, false); + + $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists()); + $this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists()); + } + + public function test_honeytoken_replay_is_blocked_banned_and_audited(): void + { + $canary = config('clusev.honeypot.canaries.db_password'); + + // 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(403); + + $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists()); + $this->assertTrue( + AuditEvent::where('action', 'security.honeytoken_used') + ->where('target', 'db_password') + ->where('ip', self::ATTACKER_IP) + ->exists() + ); + } + + public function test_disabled_honeypot_returns_404_without_ban_or_audit(): void + { + config(['clusev.honeypot.enabled' => false]); + + $response = $this->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_exempt_ip_gets_deception_but_is_not_banned(): void + { + // Default test client IP is 127.0.0.1 (loopback → exempt): deception still fires, ban does not. + $response = $this->get('/wp-login.php'); + + $response->assertStatus(200); + $response->assertSee('wp-submit', false); + + // The hit is still audited, but banNow no-ops for the exempt IP → no ban row. + $this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists()); + $this->assertDatabaseCount('banned_ips', 0); + } +}