clusev/app/Http/Middleware/DetectHoneytoken.php

87 lines
3.0 KiB
PHP

<?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);
}
}