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.
7.2 KiB
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 A–G (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.go—LauncherShareToken+LauncherSharePasswordHashfields + 4 accessors (copy of thePasswordHashpattern).controller/internal/web/server.go— Server structshareAttemptsmap (+ 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.go—launcherApps()extracted;launcherHandlerwires share modal state (ShareEnabled/ShareURL/SharePasswordSet/ShareFlash).controller/internal/web/funcmap.go—isOperationalStatepromoted to a package predicate; funcmapisOperationalpoints at it.controller/internal/web/templates/launcher.html—launch_tilepartial 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.sum—github.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:
- Token match (Group B):
subtle.ConstantTimeCompare→ prefix-accept (presented[:len(stored)] == stored) →TestShareTokenMatchesFAILS on the superstring case ("a superstring must not match"). Restored → green. - Cookie binding (Group C): dropped
passwordHashfromshareCookieValue's HMAC input →TestShareGuest_PasswordGateFAILS ("changing the password must invalidate the old gate cookie"). Restored → green. - Log redaction (Group G): reverted the ServeHTTP
/s/<redacted>redaction (log rawpath) →TestShareGuest_TokenNeverLoggedFAILS. 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, nofollowprevents 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 isSameSite=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.