From f5f903046de585cf6dd6b76659756b5aebe88b6f Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 08:34:07 +0200 Subject: [PATCH] fix(blade): garbled header on every signed-in page + private metrics channel (v0.4.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes/hardenings on top of v0.4.0: 1) Blade raw-block bug (R17). The topbar's @php block had a COMMENT containing the literal tokens @php/@endphp; Blade's non-greedy raw-block matcher took the @endphp in the comment as the block end and dumped the remainder ("$title ??= …") as plain text into
on every authenticated page (and left the page title empty). R12 missed it: the page still returned 200 with no console error and the comes from the Livewire component. Fixed the comment; added rule R17 (no directive tokens in Blade comments; block over inline @php; R12 must inspect the rendered DOM, not just status/console) to rules.md + CLAUDE.md. 2) Broadcast channels are now PRIVATE (security hardening — closes the follow-up flagged in v0.4.0). MetricsTicked rides a PrivateChannel authorized via /broadcasting/auth (withBroadcasting + routes/channels.php), so fleet metrics can no longer be subscribed to by anyone holding the bundled app key — including over a stale Caddy host after a domain change. The channel callback requires User::securityOnboarded() (rotated password + 2FA), mirroring the panel's EnsureSecurityOnboarded gate — authentication alone is not enough. Echo sends the CSRF token for the auth handshake. Convention documented (CLAUDE.md §3 / channels.php): every channel is private. Bump 0.4.0 -> 0.4.1; CHANGELOG. Verified: Pint clean; npm build; R12 all routes 200 + 0 console errors (both locales); topbar <header> rendered text clean (no @/{{ }}/$var/key leaks); private broadcast publishes to reverb:8080 and /broadcasting/auth + the onboarding-gated callback authorize correctly; Codex review clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 16 ++++++++++ CLAUDE.md | 11 +++++-- app/Events/MetricsTicked.php | 8 +++-- app/Models/User.php | 10 ++++++ bootstrap/app.php | 6 ++++ config/clusev.php | 2 +- resources/js/app.js | 9 +++++- resources/views/components/topbar.blade.php | 5 +-- routes/channels.php | 34 +++++++++++++++++++++ rules.md | 18 +++++++++++ 10 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 routes/channels.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c373ba..33d9ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.4.1] - 2026-06-14 + +### Behoben +- **Verstümmelter Header auf jeder eingeloggten Seite.** Ein Kommentar im `@php`-Block der + topbar enthielt die literalen Tokens `@php`/`@endphp`; Blades Raw-Block-Matcher nahm das + `@endphp` im Kommentar als Block-Ende und gab den Rest (inkl. `$title ??= …`) als Klartext im + `<header>` aus — der Seitentitel blieb zudem leer. Kommentar bereinigt; neue Regel **R17** + (keine Direktiv-Tokens in Blade-Kommentaren; gerenderte Ausgabe prüfen, nicht nur HTTP 200). + +### Sicherheit +- **Realtime-Kanäle sind jetzt privat.** Der Live-Metrik-Broadcast (`MetricsTicked`) läuft über + einen `PrivateChannel`, autorisiert über `/broadcasting/auth` (im `web`-Group → PanelScheme + + Session). Damit kann niemand mehr allein mit dem (öffentlich gebündelten) App-Key Infrastruktur- + Metriken abonnieren — auch nicht über einen alten Caddy-Host nach einem Domain-Wechsel. Neue + Konvention (CLAUDE.md §3 / `routes/channels.php`): **jeder** Broadcast-Kanal ist privat. + ## [0.4.0] - 2026-06-14 Die Panel-Domain ist wieder aus dem Dashboard änderbar — diesmal sicher: gespeichert wird in der Datenbank, angewendet beim Neustart, das Zertifikat holt Caddy automatisch. diff --git a/CLAUDE.md b/CLAUDE.md index c34bde8..596d566 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,11 @@ dashboard**, connecting **agentless over SSH**. The operator sees and steers the `Provisioner`) orchestrate them; queued long ops go in `app/Jobs/`. - **Realtime:** `MetricsPoller` → broadcast events (`app/Events/`, e.g. `MetricsTicked`) over **Reverb**; the Dashboard Livewire component receives them (Echo / `wire:stream`) and updates - the chart island. **Mock data first, real SSH after.** + the chart island. **Mock data first, real SSH after.** **Convention: every broadcast channel is + PRIVATE** (authenticated via `/broadcasting/auth`) — the app key is bundled into the JS and the + Reverb socket can answer on a stale Caddy host, so a public channel would leak fleet data. Declare + events with `PrivateChannel`/`PresenceChannel` and authorize in `routes/channels.php` (see the + header comment there); per-server channels also scope to records the user may see. - **Open-core:** keep the core clean; Pro features behind separate modules/flags from day one. --- @@ -153,6 +157,8 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups: - **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show` (kebab, mirrored path). Components in `app/Livewire/…`, views in `resources/views/livewire/…`. - **Pages:** full-page Livewire components as routes — no page controllers. (R1/R2) +- **Blade:** use block `@php … @endphp` (not inline `@php(...)`); never put the literal tokens + `@php`/`@endphp` in a comment/text — the raw-block matcher eats them. (R17) - **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5) - **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer PK (`getRouteKeyName(): 'uuid'`). (R11) @@ -254,6 +260,7 @@ docker compose -f docker-compose.prod.yml exec app php artisan migrate --force 9. **Reuse** the token set + Blade component kit (R10). 10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough; - build + 3 breakpoints; then `git status` for stray secrets, commit on the feature branch. + **inspect the rendered DOM** (not just status/console) for leaked `@`/`{{ }}`/`$var`/`group.key` + text — those return 200 too (R17); build + 3 breakpoints; then `git status` for stray secrets, commit. 11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and no security issues**. Fix + re-run until clean. The task is not done until Codex passes. diff --git a/app/Events/MetricsTicked.php b/app/Events/MetricsTicked.php index 29f34b1..db15d49 100644 --- a/app/Events/MetricsTicked.php +++ b/app/Events/MetricsTicked.php @@ -2,8 +2,8 @@ namespace App\Events; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; +use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -22,9 +22,11 @@ class MetricsTicked implements ShouldBroadcast public string $server = 'web-01', ) {} - public function broadcastOn(): Channel + public function broadcastOn(): PrivateChannel { - return new Channel('metrics'); + // PRIVATE: requires /broadcasting/auth (see routes/channels.php). Infrastructure + // metrics must not be subscribable by anyone holding the public app key. + return new PrivateChannel('metrics'); } public function broadcastAs(): string diff --git a/app/Models/User.php b/app/Models/User.php index 6d9720a..b38ca1c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -31,4 +31,14 @@ class User extends Authenticatable { return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); } + + /** + * Completed the security onboarding (rotated the seeded password + enrolled 2FA) — + * the same gate EnsureSecurityOnboarded enforces before the panel is reachable. + * Authorization (e.g. broadcast channels) must require this, not mere authentication. + */ + public function securityOnboarded(): bool + { + return ! $this->must_change_password && $this->hasTwoFactorEnabled(); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 242b6fa..f56377f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,12 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', health: '/up', ) + // Registers the `/broadcasting/auth` endpoint (under the `web` group, so PanelScheme + // guards it) and the private-channel authorization callbacks in routes/channels.php. + // Without this, private/presence channels can never be subscribed to. See R-note in + // routes/channels.php: every broadcast channel is PRIVATE (authenticated) — metrics are + // fleet infrastructure data and must not be reachable on a stale Caddy host via Reverb. + ->withBroadcasting(__DIR__.'/../routes/channels.php') ->withMiddleware(function (Middleware $middleware): void { // Trust Caddy's X-Forwarded-* ONLY in production, where the app has no host port // (docker-compose.prod.yml exposes app:80 on the internal net only) so Caddy is diff --git a/config/clusev.php b/config/clusev.php index 1996d49..ff49190 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -3,7 +3,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). The live build hash // is resolved from .git at runtime (see App\Livewire\Versions\Index). - 'version' => '0.4.0', + 'version' => '0.4.1', // Default user channel. Only 'stable' and 'beta' are ever offered to users. 'channel' => 'stable', diff --git a/resources/js/app.js b/resources/js/app.js index e35ed6e..546737d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -9,6 +9,9 @@ window.Pusher = Pusher; const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {}; const reverbScheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https'; const reverbPort = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (reverbScheme === 'https' ? 443 : 80)); +// Channels are PRIVATE: Echo POSTs to /broadcasting/auth (web group → PanelScheme + session) +// before subscribing, so we must send the CSRF token the layout exposes as <meta name="csrf-token">. +const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; window.Echo = new Echo({ broadcaster: 'reverb', key: rc.key ?? import.meta.env.VITE_REVERB_APP_KEY, @@ -17,6 +20,7 @@ window.Echo = new Echo({ wssPort: reverbPort, forceTLS: reverbScheme === 'https', enabledTransports: ['ws', 'wss'], + auth: { headers: { 'X-CSRF-TOKEN': csrfToken } }, }); document.addEventListener('alpine:init', () => { @@ -34,7 +38,8 @@ document.addEventListener('alpine:init', () => { this.connected = conn?.state === 'connected'; this._onState = (s) => { this.connected = s.current === 'connected'; }; conn?.bind('state_change', this._onState); - this._channel = window.Echo?.channel('metrics'); + // PRIVATE channel: authorized via /broadcasting/auth (see routes/channels.php). + this._channel = window.Echo?.private('metrics'); this._channel?.listen('.tick', (e) => { // only react to the server this chart is showing if (this.server && e.server && e.server !== this.server) return; @@ -44,6 +49,8 @@ document.addEventListener('alpine:init', () => { }, destroy() { + // Echo.leave() tears down the public, private- and presence- variants of the + // name, so this correctly unsubscribes the private-metrics channel. window.Echo?.leave('metrics'); window.Echo?.connector?.pusher?.connection?.unbind('state_change', this._onState); }, diff --git a/resources/views/components/topbar.blade.php b/resources/views/components/topbar.blade.php index 17cb6fe..87898ef 100644 --- a/resources/views/components/topbar.blade.php +++ b/resources/views/components/topbar.blade.php @@ -1,7 +1,8 @@ @props(['title' => null]) @php - // Block form (not inline @php(...)): an inline @php here would be swallowed by - // Blade's raw-block precompiler up to the @endphp further down this file. + // Default the title in a block (never an inline directive on this line, and never + // the literal directive tokens in a comment — Blade's raw-block matcher eats them; + // see rules.md R17). $title ??= __('shell.title_default'); @endphp <header class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-base/90 px-4 backdrop-blur sm:px-6 lg:px-8"> diff --git a/routes/channels.php b/routes/channels.php new file mode 100644 index 0000000..e7203bd --- /dev/null +++ b/routes/channels.php @@ -0,0 +1,34 @@ +<?php + +use App\Models\User; +use Illuminate\Support\Facades\Broadcast; + +/* +|-------------------------------------------------------------------------- +| Broadcast Channels — CONVENTION: every channel is PRIVATE +|-------------------------------------------------------------------------- +| +| Clusev is a control plane for a whole fleet; broadcast payloads (live CPU/MEM, +| service state, future per-server streams) are infrastructure data and must NEVER +| ride a PUBLIC channel. A public channel is subscribable by anyone who can reach the +| Reverb endpoint and knows the app key — and that key is bundled into the JS +| (VITE_REVERB_APP_KEY), so it is effectively public. With on-demand TLS the `/app/*` +| Reverb socket also answers on any host Caddy still holds a cert for (e.g. an old +| domain after a domain change), so a public channel would leak metrics on a host that +| PanelScheme otherwise refuses. +| +| RULE for new channels: declare every event with a PrivateChannel (or PresenceChannel) +| and register an authorization callback here. The `/broadcasting/auth` endpoint runs in +| the `web` group, so PanelScheme + the session guard gate it — a subscriber must be an +| authenticated panel user on the active domain. Per-server channels should additionally +| scope to records the user may see, e.g.: +| +| Broadcast::channel('server.{uuid}', fn (User $u, string $uuid) => +| Server::where('uuid', $uuid)->exists()); // tighten when RBAC lands +| +*/ + +// Fleet-wide metrics. Only a panel user who has completed security onboarding (rotated the +// seeded password + enrolled 2FA) may watch — mere authentication is NOT enough; this mirrors +// the EnsureSecurityOnboarded gate on panel routes (metrics are not per-user). +Broadcast::channel('metrics', fn (User $user) => $user->securityOnboarded()); diff --git a/rules.md b/rules.md index 744f640..39c8ef5 100644 --- a/rules.md +++ b/rules.md @@ -499,6 +499,24 @@ Adding a string in only one language, or omitting a key from `en`/`de`, is a rul --- +## R17 — Blade: block `@php`; never the literal directive tokens in a comment + +Blade's raw-block precompiler matches `@php … @endphp` **non-greedily on the FIRST `@endphp`**. + +- **Never** write the literal tokens `@php`/`@endphp` (or `@verbatim`/`@endverbatim`) inside a + Blade **comment or text** in a file that also has a real `@php` block — the matcher takes the + token in your comment as the block end and dumps the remainder as raw text. (This shipped a + garbled `<header>` on every signed-in page.) To mention a directive, break it (`@‌php`) or word + it ("the php directive"). +- Use the **block** `@php … @endphp`, not **inline** `@php(...)`: inline silently mis-compiles + when the expression nests parens/calls (e.g. `@php($x ??= __('a.b'))`) and collides with any + later block in the same file. +- **Verify the rendered DOM, not just HTTP 200** (extends R12): leaked template text and missing + translations both return 200 with no console error. R12 must inspect the actual visible markup + (`<header>`/body) for stray `@`, `{{ }}`, `$var`, or `group.key` literals. + +--- + ## Appendix — Secret hygiene The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in