check()) { abort(404); } $ip = (string) $request->ip(); // An active (non-safe) method is a real attack attempt; a safe read (GET/HEAD/OPTIONS) is merely // viewing a decoy. Only an attempt bans, so simply opening a fake page never locks anyone out of // the real panel (an operator testing the honeypot, or a mistaken click, stays free). // // Use getRealMethod() — the RAW REQUEST_METHOD — NOT isMethod()/getMethod(): those honor the // `_method` body/query field and the X-HTTP-Method-Override header, so an attacker could send a // real POST spoofed as `_method=PUT` to make isMethod('post') false and dodge the instant-ban // entirely (the decoys are Route::any, so the override still routes here). Ban any non-safe verb. $isAttempt = ! in_array($request->getRealMethod(), ['GET', 'HEAD', 'OPTIONS'], true); // BAN FIRST, and independently of the audit. The instant-ban IS the protection; it must never be // skipped because a later step throws on hostile input. Guarded on its own so that even a ban // failure (e.g. a momentary DB outage) still serves the deception below. // // We deliberately do NOT try to spare a "cross-site forged" POST by inspecting the Origin header: // the header is attacker-controlled, so any Origin-based skip is a trivial UNIVERSAL ban-evasion // (a scanner just sends a foreign Origin) — strictly worse than the narrow reflected-griefing it // would prevent. Honeypot bans are time-limited and unban-able from the Threats page. if ($isAttempt) { try { app(BruteforceGuard::class)->banNow($ip, 'honeypot'); } catch (\Throwable $e) { report($e); } } // Audit the probe (best-effort, rate-capped). Fully isolated: a logging failure must not affect // the ban above or the response — an unhandled throw here would otherwise surface a Laravel 500 // that UNMASKS the fake. try { $this->auditProbe($request, $ip, $isAttempt); } catch (\Throwable $e) { report($e); } return $this->deceive($request); } /** Record the probe as an audit event, rate-capped per source IP (see AUDIT_CAP_PER_HOUR). */ private function auditProbe(Request $request, string $ip, bool $isAttempt): void { // Bound audit growth per source IP WITHOUT a check-then-act race: `add` seeds the counter with a // 1-hour TTL on the first hit only, then the atomic `increment` both counts and returns the new // total (Redis INCR keeps the TTL). Concurrent hits can at most overshoot by the concurrency // count — fine for a DoS bound. Past the cap we drop the redundant row (the ban already happened). $capKey = 'honeypot:audit:'.md5($ip); Cache::add($capKey, 0, now()->addHour()); if (Cache::increment($capKey) > self::AUDIT_CAP_PER_HOUR) { return; } // Attacker-controlled strings are scrubbed to valid UTF-8 before they reach the array-cast // `meta` column: a raw invalid byte (e.g. a lone 0x80 in the password field or User-Agent) // would make json_encode throw JsonEncodingException on the cast. mb_scrub replaces ill-formed // sequences with the Unicode substitute char. $clean = static fn (?string $v, int $n = 190): string => Str::limit(mb_scrub((string) $v), $n); $meta = [ 'ua' => $clean($request->userAgent()), 'method' => $request->method(), 'query' => $clean($request->getQueryString()), ]; // Capture the credentials a prober typed into the fake login — their guesses, which are threat // intel (a legitimate user never reaches a decoy). The field names span the impersonated apps: // WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the generic form. $user = $request->input('log') ?? $request->input('pma_username') ?? $request->input('username'); $pass = $request->input('pwd') ?? $request->input('pma_password') ?? $request->input('password'); if (is_string($user) && $user !== '') { $meta['tried_user'] = $clean($user, 120); } if (is_string($pass) && $pass !== '') { $meta['tried_pass'] = $clean($pass, 120); } AuditEvent::create([ 'user_id' => null, 'actor' => 'system', 'action' => $isAttempt ? 'security.honeypot_login' : 'security.honeypot_hit', 'target' => $clean($request->path()), 'ip' => $ip, 'meta' => $meta, ]); } /** 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 ($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']); } 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 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='.$appKey, '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='.$dbPassword, '', 'API_KEY='.$apiKey, 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE', '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' Log In ‹ Blog — WordPress

Powered by WordPress

← Go to Blog

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

Welcome to phpMyAdmin

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

Sign in to continue

Authorized personnel only

HTML; } }