Skip to content

Instantly share code, notes, and snippets.

@cheesegrits
Last active September 7, 2026 11:23
Show Gist options
  • Select an option

  • Save cheesegrits/e9651bc7c98e58e5eecc2bc6beee090e to your computer and use it in GitHub Desktop.

Select an option

Save cheesegrits/e9651bc7c98e58e5eecc2bc6beee090e to your computer and use it in GitHub Desktop.
Gate + camera middleware for Livewire update traffic — logging/attribution for automated Livewire probing (Filament 5 / Livewire 4). Not a vulnerability writeup; the framework held. Full story in the file header.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/*
* Gate + camera for Livewire update traffic.
*
* WHY THIS EXISTS — the story, from several unrelated production
* Filament apps (Livewire 4 / Filament 5) whose operators compared
* notes:
*
* All of them showed occasional bursts of errors like these, 5-30
* within a few seconds, then hours of silence:
*
* Cannot update locked property: [userUndertakingMultiFactorAuthentication]
* Cannot update locked property: [discoveredSchemaNames]
* Filament\Notifications\Collection...fromLivewire(): Argument #1
* ($notification) must be of type array, int given
*
* The obvious reading is a user with a stale browser tab re-posting
* old page state after a deploy — the requests even carry a valid
* session and CSRF token, which looks like a real person. With this
* middleware's logging in place, the bursts turned out to be AUTOMATED
* PROBING: a headless browser (cloud-hosted, rotating IPs, fake Chrome
* user agents) loads the login page — which hands it a perfectly valid
* session, CSRF token, and Livewire snapshot, the same place your
* users get theirs — then tampers with the snapshot: writes to
* #[Locked] properties, posts wrong-typed values, and so on. Every app
* compared showed the same tooling against the public login-page
* components (the login form and the notifications component). A
* second, cruder variant replays captured requests on a daily schedule
* but forgets the X-Livewire header, which makes it trivial to reject.
*
* NOTHING GOT THROUGH, ANYWHERE. Livewire's own protections (locked
* properties, snapshot checksums, CSRF) rejected every attempt. This
* is not a vulnerability writeup — the framework held. It is a
* VISIBILITY writeup: three things made this traffic surprisingly hard
* to see, and they are the three lessons baked into the code below.
*
* Two jobs, both scoped to Livewire's update endpoints:
*
* (1) THE GATE: requests missing the basic shape of a real Livewire
* client (no X-Livewire header, scripted user agent, non-JSON body,
* implausibly small payload) are 404'd before the kernel does any
* real work, with one INFO line recording who sent them.
*
* (2) THE CAMERA: a request that gets PAST the gate and then fails —
* an exception thrown below this frame, or a response rendered as
* 419 or 5xx — logs one WARNING carrying the caller's identity
* (ip, user agent, path, component name, status). Laravel's
* exception log records the stack trace but never the caller, so
* without this frame a burst of Livewire failures is unattributable.
*
* THE THREE LESSONS, baked in below:
*
* - Livewire v4 serves REAL updates from an APP_KEY-hashed prefix
* (livewire-<hash>/update). The bare livewire/update path is only
* ever hit by scanners aiming at the well-known v3 URL. Match both,
* or the camera watches a road nobody drives. (If you monitor or
* firewall only the literal path, so does your monitoring.)
*
* - Livewire renders its whole "bad update" rejection class as HTTP
* 419, never 500: locked-property violations via the exception's own
* render() method, and wrong-type updates via report-then-abort(419).
* So anything keyed on server errors — alerting, "log 5xx" middleware
* — never fires, while the exceptions still get report()ed, filling
* the log with stack traces that carry no IP, no user agent, nothing
* to attribute. The cost of photographing 419s is that a legitimate
* user posting from an expired session photographs too — the status
* field in the context lets log review tell those apart: scattered
* singletons from residential addresses are users; tight bursts from
* datacenter addresses against your public components are not.
*
* - If production runs LOG_LEVEL above info (common: error), this
* middleware's entire output is silently dropped. Give it its own
* channel with a fixed level, e.g. in config/logging.php:
*
* 'security' => [
* 'driver' => 'single',
* 'path' => storage_path('logs/security.log'),
* 'level' => 'info',
* 'replace_placeholders' => true,
* ],
*
* Field discipline: the component NAME is extracted from the payload,
* and nothing else — the payload body is where the bulk and the
* personally identifiable data live. Never log it.
*
* Register as global middleware in bootstrap/app.php:
*
* ->withMiddleware(function (Middleware $middleware) {
* $middleware->append(LogLivewireAttacks::class);
* })
*/
class LogLivewireAttacks
{
/**
* @return mixed
*
* @throws Throwable
*/
public function handle(Request $request, Closure $next)
{
if (! $request->is('livewire/update', 'livewire-*/update')) {
return $next($request);
}
$suspicious = false;
if ($request->hasHeader('X-Livewire') === false) {
$suspicious = true;
}
$ua = strtolower($request->userAgent() ?? '');
if (str_contains($ua, 'python-requests')) {
$suspicious = true;
}
if ($request->header('Content-Type') !== 'application/json') {
$suspicious = true;
}
if ((int) $request->header('Content-Length', 0) < 300) {
$suspicious = true;
}
if ($suspicious) {
Log::channel('security')->info('Livewire update aborted as suspicious', [
'host' => $request->getHost(),
'ip' => $request->ip(),
'content_type' => $request->header('Content-Type'),
'length' => $request->header('Content-Length'),
'has_session' => $request->hasSession(),
'user_agent' => $request->userAgent(),
'path' => $request->path(),
]);
abort(404);
}
// Both failure paths deliberately: the pipeline may convert an
// exception to a response below this frame, and the capture must
// not depend on which side of that boundary it lands.
try {
$response = $next($request);
} catch (Throwable $e) {
$this->logFailedUpdate($request, $e);
throw $e;
}
if ($response instanceof Response
&& ($response->getStatusCode() >= 500 || $response->getStatusCode() === 419)) {
$this->logFailedUpdate($request, status: $response->getStatusCode());
}
return $response;
}
private function logFailedUpdate(Request $request, ?Throwable $e = null, ?int $status = null): void
{
Log::channel('security')->warning('Livewire update failed', [
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'path' => $request->path(),
'status' => $status,
'component' => $this->componentName($request),
'exception' => $e ? $e::class : null,
'message' => $e?->getMessage(),
]);
}
/**
* The component name from the update payload — and nothing else from
* it. The snapshot arrives as a JSON string inside the JSON body, and
* probe payloads are garbage by definition, so every step null-safes.
*/
private function componentName(Request $request): ?string
{
try {
$snapshot = $request->json('components.0.snapshot');
if (! is_string($snapshot)) {
return null;
}
$decoded = json_decode($snapshot, true);
$name = $decoded['memo']['name'] ?? null;
return is_string($name) ? $name : null;
} catch (Throwable) {
return null;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment