Last active
September 7, 2026 11:23
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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