feat(honeypot): decoy routes + instant-ban + deceptive responses + honeytokens
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
61b8419106
commit
c8e1e07a43
|
|
@ -0,0 +1,185 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\AuditEvent;
|
||||||
|
use App\Services\BruteforceGuard;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Honeypot deception layer. Handles requests to decoy paths (routes/web.php, above the guest
|
||||||
|
* group) that no legitimate client ever visits — /.env, /wp-login.php, /phpmyadmin, … A hit is
|
||||||
|
* proof of a hostile probe, so we: audit it, instant-ban the source IP (BruteforceGuard::banNow,
|
||||||
|
* exempt/loopback are skipped), and return a small, self-contained DECEPTIVE body so the attacker
|
||||||
|
* keeps chasing the bait instead of noticing a real panel behind it. Attacker-facing strings are
|
||||||
|
* intentionally NOT localized (they impersonate generic third-party software).
|
||||||
|
*/
|
||||||
|
class HoneypotController extends Controller
|
||||||
|
{
|
||||||
|
public function trap(Request $request): Response
|
||||||
|
{
|
||||||
|
// Feature off → behave exactly like an ordinary 404 for these paths (no ban, no audit).
|
||||||
|
if (! config('clusev.honeypot.enabled')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = (string) $request->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'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en-US">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Log In ‹ Blog — WordPress</title>
|
||||||
|
</head>
|
||||||
|
<body class="login">
|
||||||
|
<div id="login">
|
||||||
|
<h1><a href="https://wordpress.org/">Powered by WordPress</a></h1>
|
||||||
|
<form name="loginform" id="loginform" action="wp-login.php" method="post">
|
||||||
|
<p><label for="user_login">Username or Email Address</label>
|
||||||
|
<input type="text" name="log" id="user_login" class="input" value="" size="20"></p>
|
||||||
|
<p><label for="user_pass">Password</label>
|
||||||
|
<input type="password" name="pwd" id="user_pass" class="input" value="" size="20"></p>
|
||||||
|
<p class="forgetmenot"><label for="rememberme"><input name="rememberme" type="checkbox" id="rememberme" value="forever"> Remember Me</label></p>
|
||||||
|
<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary" value="Log In"></p>
|
||||||
|
</form>
|
||||||
|
<p id="nav"><a href="wp-login.php?action=lostpassword">Lost your password?</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fakePhpMyAdmin(): string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" dir="ltr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>phpMyAdmin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a href="index.php" class="logo">phpMyAdmin</a>
|
||||||
|
<h1>Welcome to phpMyAdmin</h1>
|
||||||
|
<form method="post" action="index.php" name="login_form" class="login">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Log in</legend>
|
||||||
|
<div class="item">
|
||||||
|
<label for="input_username">Username:</label>
|
||||||
|
<input type="text" name="pma_username" id="input_username" value="" size="24" class="textfield">
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<label for="input_password">Password:</label>
|
||||||
|
<input type="password" name="pma_password" id="input_password" value="" size="24" class="textfield">
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<label for="select_server">Server Choice:</label>
|
||||||
|
<select name="server" id="select_server"><option value="1">localhost</option></select>
|
||||||
|
</div>
|
||||||
|
<input type="submit" name="submit" value="Go" class="button">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fakeGenericLogin(): string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Sign in to continue</h1>
|
||||||
|
<form method="post" action="login">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" id="username" autocomplete="username">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" id="password" autocomplete="current-password">
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\AuditEvent;
|
||||||
|
use App\Services\BruteforceGuard;
|
||||||
|
use Closure;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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'];
|
||||||
|
|
||||||
|
public function __construct(private BruteforceGuard $guard) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! config('clusev.honeypot.enabled')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$canaries = (array) config('clusev.honeypot.canaries');
|
||||||
|
if ($canaries === []) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small, bounded set of candidate values — no recursion into nested arrays.
|
||||||
|
$candidates = [];
|
||||||
|
foreach (self::CANDIDATE_KEYS as $key) {
|
||||||
|
$value = $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ class AuditEvent extends Model
|
||||||
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
|
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
|
||||||
'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed',
|
'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed',
|
||||||
'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed',
|
'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed',
|
||||||
|
'security.honeypot_hit', 'security.honeytoken_used',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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
|
* gates on enabled() before calling this, so disabling the feature stops enforcement while
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\BlockBannedIp;
|
use App\Http\Middleware\BlockBannedIp;
|
||||||
|
use App\Http\Middleware\DetectHoneytoken;
|
||||||
use App\Http\Middleware\PanelScheme;
|
use App\Http\Middleware\PanelScheme;
|
||||||
use App\Http\Middleware\SecurityHeaders;
|
use App\Http\Middleware\SecurityHeaders;
|
||||||
use App\Http\Middleware\SetLocale;
|
use App\Http\Middleware\SetLocale;
|
||||||
|
|
@ -45,7 +46,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
// database session driver makes those sessions enumerable/revocable in Settings.
|
// database session driver makes those sessions enumerable/revocable in Settings.
|
||||||
$middleware->web(
|
$middleware->web(
|
||||||
prepend: [PanelScheme::class],
|
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
|
// The terminal sidecar POSTs to the internal resolve endpoint from the private network with
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,18 @@ return [
|
||||||
// generates it alongside the other secrets; the host reads the SAME value from .env.
|
// generates it alongside the other secrets; the host reads the SAME value from .env.
|
||||||
'update_hmac_key' => env('UPDATE_HMAC_KEY', ''),
|
'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',
|
'license' => 'AGPL-3.0',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,13 @@ http {
|
||||||
fastcgi_read_timeout 120;
|
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
|
# deny dotfiles except .well-known
|
||||||
location ~ /\.(?!well-known).* {
|
location ~ /\.(?!well-known).* {
|
||||||
deny all;
|
deny all;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ return [
|
||||||
'password.change' => 'Passwort geändert',
|
'password.change' => 'Passwort geändert',
|
||||||
'password.reset' => 'Passwort zurückgesetzt',
|
'password.reset' => 'Passwort zurückgesetzt',
|
||||||
'profile.update' => 'Profil aktualisiert',
|
'profile.update' => 'Profil aktualisiert',
|
||||||
|
'security.honeypot_hit' => 'Honeypot-Treffer',
|
||||||
|
'security.honeytoken_used' => 'Honeytoken benutzt',
|
||||||
'server.create' => 'Server angelegt',
|
'server.create' => 'Server angelegt',
|
||||||
'server.credential_updated' => 'SSH-Zugangsdaten aktualisiert',
|
'server.credential_updated' => 'SSH-Zugangsdaten aktualisiert',
|
||||||
'ssh_key.add' => 'SSH-Schlüssel hinzugefügt',
|
'ssh_key.add' => 'SSH-Schlüssel hinzugefügt',
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ return [
|
||||||
'password.change' => 'Password changed',
|
'password.change' => 'Password changed',
|
||||||
'password.reset' => 'Password reset',
|
'password.reset' => 'Password reset',
|
||||||
'profile.update' => 'Profile updated',
|
'profile.update' => 'Profile updated',
|
||||||
|
'security.honeypot_hit' => 'Honeypot hit',
|
||||||
|
'security.honeytoken_used' => 'Honeytoken used',
|
||||||
'server.create' => 'Server added',
|
'server.create' => 'Server added',
|
||||||
'server.credential_updated' => 'SSH credentials updated',
|
'server.credential_updated' => 'SSH credentials updated',
|
||||||
'ssh_key.add' => 'SSH key added',
|
'ssh_key.add' => 'SSH key added',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\HoneypotController;
|
||||||
use App\Http\Middleware\EnsureSecurityOnboarded;
|
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||||
use App\Http\Middleware\SetLocale;
|
use App\Http\Middleware\SetLocale;
|
||||||
use App\Http\Middleware\VerifyTerminalSidecarSecret;
|
use App\Http\Middleware\VerifyTerminalSidecarSecret;
|
||||||
|
|
@ -119,6 +120,31 @@ Route::post('/_internal/terminal/resolve', function (Request $request) {
|
||||||
]);
|
]);
|
||||||
})->middleware(VerifyTerminalSidecarSecret::class)->name('internal.terminal.resolve');
|
})->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::middleware('guest')->group(function () {
|
||||||
Route::get('/login', Auth\Login::class)->name('login');
|
Route::get('/login', Auth\Login::class)->name('login');
|
||||||
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
|
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\AuditEvent;
|
||||||
|
use App\Models\BannedIp;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class HoneypotTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/** A non-exempt (public) client IP — loopback/private ranges are exempt from banNow. */
|
||||||
|
private const ATTACKER_IP = '203.0.113.99';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate a request from a specific client IP via REMOTE_ADDR (trustProxies is prod-gated). */
|
||||||
|
private function fromIp(string $method, string $uri, string $ip, array $data = [])
|
||||||
|
{
|
||||||
|
return $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue