userAgent(); $botName = HoneypotHit::detectBot($userAgent); $path = $request->path(); $severity = HoneypotHit::severityForPath($path); $ip = $request->ip(); // Rate limit honeypot logging to prevent DoS via log flooding. // Each IP gets limited to N log entries per time window. $rateLimitKey = 'honeypot:log:'.$ip; $maxAttempts = (int) config('core.bouncer.honeypot.rate_limit_max', 10); $decaySeconds = (int) config('core.bouncer.honeypot.rate_limit_window', 60); if (! RateLimiter::tooManyAttempts($rateLimitKey, $maxAttempts)) { RateLimiter::hit($rateLimitKey, $decaySeconds); // Optional services - use app() since route skips web middleware $geoIp = app(DetectLocation::class); HoneypotHit::create([ 'ip_address' => $ip, 'user_agent' => substr($userAgent ?? '', 0, 1000), 'referer' => $this->sanitizeReferer($request->header('Referer', '')), 'path' => $path, 'method' => $request->method(), 'headers' => $this->sanitizeHeaders($request->headers->all()), 'country' => $geoIp?->getCountryCode($ip), 'city' => $geoIp?->getCity($ip), 'is_bot' => $botName !== null, 'bot_name' => $botName, 'severity' => $severity, ]); } // Auto-block critical hits (active probing) if enabled in config. // Skip localhost in dev to avoid blocking yourself. $autoBlockEnabled = config('core.bouncer.honeypot.auto_block_critical', true); $isLocalhost = $this->isPrivateIp($ip); $isCritical = $severity === HoneypotHit::getSeverityCritical(); if ($autoBlockEnabled && $isCritical && ! $isLocalhost) { app(BlocklistService::class)->block($ip, 'honeypot_critical'); } // Return the 418 I'm a teapot response return response($this->teapotBody(), 418, [ 'Content-Type' => 'text/html; charset=utf-8', 'X-Powered-By' => 'Earl Grey', 'X-Severity' => $severity, ]); } /** * Validate and truncate the referer header. */ protected function sanitizeReferer(string $referer): string { if ($referer === '') { return ''; } if (filter_var($referer, FILTER_VALIDATE_URL) === false) { return 'invalid-url'; } return substr($referer, 0, 2000); } /** * Whitelist headers useful for bot detection before storing. */ protected function sanitizeHeaders(array $headers): array { $allowed = [ 'user-agent', 'accept', 'accept-language', 'accept-encoding', 'referer', 'origin', 'x-requested-with', 'x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'x-client-ip', ]; return array_intersect_key($headers, array_flip($allowed)); } /** * Check whether an IP address is private or reserved. */ protected function isPrivateIp(string $ip): bool { // Normalise IPv4-mapped IPv6 addresses $ip = preg_replace('/^::ffff:/i', '', $ip); return filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false; } /** * The teapot response body. */ protected function teapotBody(): string { return <<<'HTML' 418 I'm a Teapot
🫖

418 I'm a Teapot

The server refuses to brew coffee because it is, permanently, a teapot.

RFC 2324 · RFC 7168

HTML; } }