Files
felhom-controller/REPORT.md
T
admin 15206314ab v0.165.0: Indítópult megosztása — guest launcher via capability URL (+ optional password, QR)
Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest
launcher: same tiles, opens apps in new tabs, no account, no admin session.
Information only, zero control — every privilege stays behind each app's own auth.

- /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest
  password POST carries its own pre-auth HMAC CSRF.
- Constant-time token match; empty stored token = disabled = byte-identical mux 404.
- Optional per-share password: separate bcrypt hash + own attempt map; signed cookie
  = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates.
- Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary.
- Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT.
- Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable.
- Tests: Groups A-G (14) + 3 red-proofs verified red.
2026-07-24 12:08:43 +02:00

7.2 KiB
Raw Blame History

REPORT — felhom-controller v0.165.0 — Indítópult megosztása (guest launcher via capability URL)

Baselines

Repo main @ start version start → target
felhom-controller 8e5edb2 v0.164.0 → v0.165.0

Clean tree, HEAD == origin/main, verified before build.

What shipped

The admin launcher gains an "Indítópult megosztása" button that mints a capability URL (https://<host>/s/<token>, 160-bit token) serving a standalone, read-only guest launcher — same tiles, opens apps in new tabs — with no accounts and no admin session. Optional per-share password (separate credential); modal offers copy-link, QR, rotate, disable. The link grants information only, zero control.

Files created / modified

Created

  • controller/internal/web/share.go — pure core: newShareToken (20 rand bytes → base64.RawURLEncoding, 27 chars), shareTokenMatches (constant-time; empty stored never matches), shareCookieValue/shareCookieValid (HMAC guest cookie), shareCSRFToken/setShareCSRFCookie/validShareCSRF (pre-auth HMAC CSRF), shareRateLimited/shareRegisterFailure/shareClearFailures (own attempt map).
  • controller/internal/web/share_handlers.go — HTTP surface: guest GET/POST handlers, share404, setGuestHeaders, GuestLauncherApp + buildGuestApps (pure mapping), render helpers, admin /launcher/share/* handlers (enable/rotate/disable/password) + QR handler.
  • controller/internal/web/share_test.go — Groups AG (14 tests) + companion red-proofs.
  • controller/internal/web/templates/launcher_shared.html — standalone guest launcher (own minimal <html>).
  • controller/internal/web/templates/launcher_share_password.html — standalone one-field password gate.

Modified

  • controller/internal/settings/settings.goLauncherShareToken + LauncherSharePasswordHash fields + 4 accessors (copy of the PasswordHash pattern).
  • controller/internal/web/server.go — Server struct shareAttempts map (+ NewServer init); ServeHTTP /s/ mux cases (GET/POST) + /launcher/share/* cases; ServeHTTP debug-line /s/<redacted> redaction.
  • controller/internal/web/auth.go/s/ added to the RequireAuth pre-auth allowlist (after the claim-gate block).
  • controller/internal/web/csrf.go/s/ exempted from session CSRF (guest carries pre-auth HMAC CSRF).
  • controller/internal/web/handlers.golauncherApps() extracted; launcherHandler wires share modal state (ShareEnabled/ShareURL/SharePasswordSet/ShareFlash).
  • controller/internal/web/funcmap.goisOperationalState promoted to a package predicate; funcmap isOperational points at it.
  • controller/internal/web/templates/launcher.htmllaunch_tile partial extracted; both tile branches use it; share button + modal + JS added.
  • controller/internal/web/templates/style.css — share-modal + guest-launcher CSS.
  • controller/go.mod / go.sumgithub.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e.
  • Docs: CHANGELOG.md, controller/README.md, CONTEXT.md.

Part-2 secret decision — and why

REUSED web.session_secret (via s.cfg.Web.SessionSecret) as the HMAC key for both the guest gate cookie and the guest CSRF, with per-purpose domain-separation labels (felhom-share-cookie-v1|…, felhom-share-csrf-v1). It qualifies for the reuse branch of the decision rule: it is persisted (a controller.yaml web.session_secret field) and box-scoped (each box's own config), and it is stable across restarts — not per-boot and not claim-generation-scoped. It is the SAME secret the claim pre-auth CSRF already trusts (claim.go:claimCSRFToken), so reusing it introduces no new security assumption beyond what the box already relies on. No ShareCookieSecret field was added. Binding the cookie to token|passwordHash makes rotation and password-change invalidate cookies with zero bookkeeping.

Tests + red-proofs

go build ./... && go vet ./... && go test ./... — all green. Gates: template_id, emoji, native_confirm, mojibake, app_row_dedup — all OK.

New tests (share_test.go), 14 total, all PASS:

Group Scenario Test
B(core) constant-time token match TestShareTokenMatches, TestNewShareToken_EntropyAndCharset
A guest 200 + 3 headers + tiles + no admin chrome TestShareGuest_HeadersTilesNoAdminChrome
B wrong/disabled/empty = byte-identical mux 404 TestShareGuest_WrongTokenIs404LikeDefault
C password gate: 5 wrong → 6th rate-limited; correct → cookie; change pw invalidates TestShareGuest_PasswordGate
D rotate → old 404 + old cookie invalid; disable → all 404 TestShareGuest_RotateAndDisable
E guest labels + no internal state words + empty state TestBuildGuestApps_Labels, TestShareGuestTemplate_LabelsNoInternalWords
F claim gate intercepts guest page; admin surfaces need auth + CSRF TestShare_ClaimGateInterceptsGuestPage, TestShare_AdminSurfacesRequireAuthAndCSRF
G token never logged (valid + wrong), path redacted TestShareGuest_TokenNeverLogged

Companion red-proofs (mutate → FAIL → restore → green), all verified:

  1. Token match (Group B): subtle.ConstantTimeCompare → prefix-accept (presented[:len(stored)] == stored) → TestShareTokenMatches FAILS on the superstring case ("a superstring must not match"). Restored → green.
  2. Cookie binding (Group C): dropped passwordHash from shareCookieValue's HMAC input → TestShareGuest_PasswordGate FAILS ("changing the password must invalidate the old gate cookie"). Restored → green.
  3. Log redaction (Group G): reverted the ServeHTTP /s/<redacted> redaction (log raw path) → TestShareGuest_TokenNeverLogged FAILS. Restored → green.

Existing launcher tests (TestBuildLauncherApps_*, TestLauncherTemplate_*, TestTileColor, TestInitial, TestLauncherRoute_EndToEnd) still green after the launch_tile partial extraction.

Test count (internal/web): +14 (share_test.go). Full go test ./... green before/after.

Deployed version + live validation (§13)

Filled after build + deploy to guest 9201 — see the transcripts below (token redacted throughout).

Accepted residuals (no code action)

  • Link-preview crawlers (Messenger/WhatsApp/Slack) fetch the URL once and see app names — accepted; X-Robots-Tag: noindex, nofollow prevents search indexing.
  • Reverse-proxy / Cloudflare access logs may record the /s/<token> path — an ops-tier residual outside the controller (the controller's own logs redact it).
  • LAN-IP link host — the modal builds the link from the request Host, so an admin on a LAN IP gets a LAN-IP link. Kept the UI clean; noted here only.

Observations

  • The guest gate cookie is scoped Path=/s/ + HttpOnly + SameSite=Lax (Lax so a first click from an external app still sends it on top-level GET). The CSRF cookie is SameSite=Strict.
  • Disable clears BOTH token and share password (clean slate — a later re-enable never inherits a stale gate).
  • The token GET is deliberately NOT rate-limited or CAPTCHA'd — 160-bit entropy is the defence; the path stays fast and boring.