php-admin/src/Mod/Hub/Controllers/TeapotController.php
Clotho 23b3339b0b
Some checks failed
CI / PHP 8.2 (pull_request) Failing after 1s
CI / PHP 8.3 (pull_request) Failing after 1s
CI / PHP 8.4 (pull_request) Failing after 1s
CI / Assets (pull_request) Failing after 1s
security: validate JSON metadata fields to prevent mass assignment
Add mutators to Service and HoneypotHit models that enforce size and
structure limits on JSON fields (metadata, headers). Service.setMeta()
now validates key format. TeapotController pre-filters header count
before passing to the model.

Fixes #14

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:06:18 +00:00

163 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Controllers;
use Core\Bouncer\BlocklistService;
use Core\Headers\DetectLocation;
use Core\Mod\Hub\Models\HoneypotHit;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
/**
* Honeypot endpoint that returns 418 I'm a Teapot.
*
* This endpoint is listed as disallowed in robots.txt. Any request to it
* indicates a crawler that doesn't respect robots.txt, which is often
* malicious or at least poorly behaved.
*/
class TeapotController
{
public function __invoke(Request $request): Response
{
// Log the hit
$userAgent = $request->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' => substr($request->header('Referer', ''), 0, 2000),
'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 = in_array($ip, ['127.0.0.1', '::1'], true);
$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,
]);
}
/**
* Remove sensitive headers and enforce size limits before storing.
*/
protected function sanitizeHeaders(array $headers): array
{
$sensitive = ['cookie', 'authorization', 'x-csrf-token', 'x-xsrf-token'];
foreach ($sensitive as $key) {
unset($headers[$key]);
}
// Enforce header count limit before passing to the model
if (count($headers) > HoneypotHit::HEADERS_MAX_COUNT) {
$headers = array_slice($headers, 0, HoneypotHit::HEADERS_MAX_COUNT, true);
}
return $headers;
}
/**
* The teapot response body.
*/
protected function teapotBody(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>418 I'm a Teapot</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem;
}
.teapot {
font-size: 8rem;
margin-bottom: 1rem;
animation: wobble 2s ease-in-out infinite;
}
@keyframes wobble {
0%, 100% { transform: rotate(-5deg); }
50% { transform: rotate(5deg); }
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
p {
font-size: 1.25rem;
opacity: 0.9;
max-width: 500px;
}
.rfc {
margin-top: 2rem;
font-size: 0.875rem;
opacity: 0.7;
}
a {
color: inherit;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="teapot">🫖</div>
<h1>418 I'm a Teapot</h1>
<p>The server refuses to brew coffee because it is, permanently, a teapot.</p>
<p class="rfc">
<a href="https://www.rfc-editor.org/rfc/rfc2324" target="_blank" rel="noopener">RFC 2324</a> &middot;
<a href="https://www.rfc-editor.org/rfc/rfc7168" target="_blank" rel="noopener">RFC 7168</a>
</p>
</body>
</html>
HTML;
}
}