check()) { return $next($request); } $canaries = config('clusev.honeypot.canaries', []); if ($canaries === []) { return $next($request); } // A small, bounded set of candidate values — request BODY only (form-encoded via post() AND a // JSON body via json()), never the query string (which a reflected link could carry to ban a // victim), no recursion into nested arrays. $candidates = []; foreach (self::CANDIDATE_KEYS as $key) { foreach ([$request->post($key), $request->json($key)] as $value) { 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(); // Ban first, then audit — each guarded independently (mirrors HoneypotController::trap). A write // failure (DB hiccup, un-run migration) must never turn a honeytoken trip into a 500 that leaks // this is a real app, nor let a failing audit skip the ban. No per-IP audit cap is needed here: // BlockBannedIp runs BEFORE this middleware, so a banned IP is 403'd upstream and never reaches // trip() again — the honeytoken audit cannot be looped. try { $this->guard->banNow($ip, 'honeytoken'); } catch (\Throwable $e) { report($e); } try { // Bound audit growth per source IP WITHOUT a race (add seeds a 1h TTL once, atomic increment // counts + returns the new total). An EXEMPT IP is never banned (banNow no-ops), so // BlockBannedIp cannot throttle its repeat trips; and a briefly-stale ban-check cache can let // a few through before the ban takes hold. Past the cap we drop the row — the ban + 403 still // happen. Mirrors HoneypotController::auditProbe. $capKey = 'honeytoken:audit:'.md5($ip); Cache::add($capKey, 0, now()->addHour()); if (Cache::increment($capKey) <= self::AUDIT_CAP_PER_HOUR) { AuditEvent::create([ 'user_id' => null, 'actor' => 'system', 'action' => 'security.honeytoken_used', 'target' => $canaryName, 'ip' => $ip, // path() is derived from the (attacker-controlled) URL — scrub to valid UTF-8 so a // percent-decoded invalid byte can't make the array-cast meta column's json_encode throw. 'meta' => ['path' => Str::limit(mb_scrub($request->path()), 190)], ]); } } catch (\Throwable $e) { report($e); } return response()->view('errors.blocked', [], 403); } }