96 lines
3.5 KiB
PHP
96 lines
3.5 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 per-install canary secret VALUES that no real
|
|
* system secret ever equals (config('clusev.honeypot.canaries')). If an UNAUTHENTICATED attacker who
|
|
* scraped the decoy .env replays one of those values — as a request-BODY credential field, a bearer
|
|
* token, or an Authorization header — it is undeniable proof they took the bait, so we instant-ban + 403.
|
|
*
|
|
* Deliberately CHEAP and FALSE-POSITIVE-FREE: it only trips for anonymous requests (a logged-in user
|
|
* is never a honeytoken replayer, and this stops a reflected decoy link banning a real victim), it
|
|
* scans only a fixed handful of well-known credential inputs from the request BODY (never the query
|
|
* string, never `password`) plus the bearer token / Authorization header — never a recursive walk.
|
|
* A normal request 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 BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */
|
|
private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key'];
|
|
|
|
public function __construct(private BruteforceGuard $guard) {}
|
|
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
if (! config('clusev.honeypot.enabled')) {
|
|
return $next($request);
|
|
}
|
|
|
|
// Only trap UNAUTHENTICATED attackers. A logged-in user is never a honeytoken replayer, and
|
|
// this stops a reflected decoy link from banning an authenticated victim.
|
|
if (auth()->check()) {
|
|
return $next($request);
|
|
}
|
|
|
|
$canaries = config('clusev.honeypot.canaries', []);
|
|
if ($canaries === []) {
|
|
return $next($request);
|
|
}
|
|
|
|
// A small, bounded set of candidate values — request BODY only (post()), no query string,
|
|
// no recursion into nested arrays.
|
|
$candidates = [];
|
|
foreach (self::CANDIDATE_KEYS as $key) {
|
|
$value = $request->post($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);
|
|
}
|
|
}
|