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.
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Guest launcher share (v0.165.0). These tests drive the pure core (token match, guest-app mapping),
|
||||
// the standalone templates, and the real routes through the production mux composition (fullMux =
|
||||
// RequireAuth + CsrfProtect + ServeHTTP, exactly as main.go wires it). Each security item carries a
|
||||
// companion red-proof recorded in REPORT.
|
||||
|
||||
// shareTestServer builds a CLAIMED box (admin password set → authEnabled, claim gate off) with a
|
||||
// SessionSecret, so the /s/ pre-auth pass-through and the admin-auth gate on /launcher/share/* are
|
||||
// both exercised as they run in production.
|
||||
func shareTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Customer.ID = "c1"
|
||||
cfg.Customer.Name = "Teszt"
|
||||
cfg.Customer.Domain = "demo-felhom.eu"
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
cfg.Web.SessionSecret = "test-session-secret-share"
|
||||
ph, _ := bcrypt.GenerateFromPassword([]byte("admin-pw-123456"), bcrypt.MinCost)
|
||||
cfg.Web.PasswordHash = string(ph)
|
||||
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
mgr, err := stacks.NewManager(cfg, lg)
|
||||
if err != nil {
|
||||
t.Fatalf("stacks: %v", err)
|
||||
}
|
||||
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "9.9.9-test",
|
||||
sessions: map[string]*session{},
|
||||
loginAttempts: map[string]*loginAttempt{},
|
||||
shareAttempts: map[string]*loginAttempt{}}
|
||||
s.loadTemplates()
|
||||
return s
|
||||
}
|
||||
|
||||
const testShareToken = "TESTTOKEN0AAAAAAAAAAAAAAAAAA"
|
||||
|
||||
// ── Group B core: constant-time token match (Scenario B) ──────────────────────────────────────────
|
||||
// COMPANION red-proof (REPORT): replace subtle.ConstantTimeCompare with strings.HasPrefix(presented,
|
||||
// stored) → the superstring case ("abcd" vs stored "abc") is accepted and this test FAILS.
|
||||
func TestShareTokenMatches(t *testing.T) {
|
||||
if !shareTokenMatches("abc", "abc") {
|
||||
t.Error("exact match must pass")
|
||||
}
|
||||
if shareTokenMatches("", "") || shareTokenMatches("", "anything") {
|
||||
t.Error("an empty stored token must never match (sharing disabled)")
|
||||
}
|
||||
if shareTokenMatches("abc", "ab") {
|
||||
t.Error("a prefix must not match")
|
||||
}
|
||||
if shareTokenMatches("abc", "abcd") {
|
||||
t.Error("a superstring must not match")
|
||||
}
|
||||
if shareTokenMatches("abc", "abx") {
|
||||
t.Error("a different token must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewShareToken_EntropyAndCharset(t *testing.T) {
|
||||
a, err := newShareToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := newShareToken()
|
||||
if a == b {
|
||||
t.Error("two tokens collided — not random")
|
||||
}
|
||||
if len(a) != 27 { // 20 bytes → base64.RawURLEncoding = 27 chars
|
||||
t.Errorf("token length = %d, want 27 (160 bits, no padding)", len(a))
|
||||
}
|
||||
if strings.ContainsAny(a, "+/=") {
|
||||
t.Errorf("token %q contains non-URL-safe chars", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group A: guest happy path — headers triple, tiles, no admin chrome (Scenario A) ───────────────
|
||||
// COMPANION red-proof (REPORT): render the guest page through the admin layout template → the
|
||||
// no-"nav-links"/no-version absence assertions FAIL.
|
||||
func TestShareGuest_HeadersTilesNoAdminChrome(t *testing.T) {
|
||||
s := shareTestServer(t)
|
||||
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeStack(t, s.cfg.Paths.StacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true)
|
||||
_ = s.stackMgr.ScanStacks()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /s/<token> = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
|
||||
t.Errorf("X-Robots-Tag = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" {
|
||||
t.Errorf("Referrer-Policy = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "Receptek") {
|
||||
t.Error("guest page must render the openable app tile")
|
||||
}
|
||||
for _, chrome := range []string{`class="sidebar"`, "nav-links", "/logout", "9.9.9-test", "alert-banner"} {
|
||||
if strings.Contains(body, chrome) {
|
||||
t.Errorf("guest page leaked admin chrome: %q", chrome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group B: wrong / disabled / empty token → byte-identical to the mux default 404 (Scenario B) ──
|
||||
func TestShareGuest_WrongTokenIs404LikeDefault(t *testing.T) {
|
||||
s := shareTestServer(t)
|
||||
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Reference: the mux default case. /s/ bypasses auth, so both reach ServeHTTP's switch directly;
|
||||
// calling ServeHTTP is the apples-to-apples comparison against the default 404 branch.
|
||||
ref := httptest.NewRecorder()
|
||||
s.ServeHTTP(ref, httptest.NewRequest(http.MethodGet, "/no-such-route-xyz", nil))
|
||||
wantCode, wantBody := ref.Code, ref.Body.String()
|
||||
|
||||
cases := []string{"/s/WRONGTOKEN", "/s/"}
|
||||
for _, p := range cases {
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
||||
if rr.Code != wantCode || rr.Body.String() != wantBody {
|
||||
t.Errorf("GET %s = %d %q; want default-404 %d %q", p, rr.Code, rr.Body.String(), wantCode, wantBody)
|
||||
}
|
||||
}
|
||||
// Disabled (empty stored token): the previously-valid token now 404s identically.
|
||||
if err := s.settings.SetLauncherShareToken(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
|
||||
if rr.Code != wantCode || rr.Body.String() != wantBody {
|
||||
t.Errorf("disabled-token GET = %d %q; want default-404 %d %q", rr.Code, rr.Body.String(), wantCode, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
// sharePOST posts the guest password form with a valid pre-auth HMAC CSRF pair (form field + cookie).
|
||||
func (s *Server) sharePOST(mux http.Handler, path, password string, extra ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
csrf := s.shareCSRFToken()
|
||||
form := url.Values{"_csrf": {csrf}, "password": {password}}
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf})
|
||||
for _, c := range extra {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func cookieNamed(rr *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Group C: optional password gate (Scenario C) ──────────────────────────────────────────────────
|
||||
// COMPANION red-proof (REPORT): drop passwordHash from shareCookieValue's HMAC input → the
|
||||
// "changing the password invalidates the cookie" assertion FAILS.
|
||||
func TestShareGuest_PasswordGate(t *testing.T) {
|
||||
s := shareTestServer(t)
|
||||
mux := s.fullMux()
|
||||
s.settings.SetLauncherShareToken(testShareToken)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost)
|
||||
s.settings.SetLauncherSharePasswordHash(string(hash))
|
||||
path := "/s/" + testShareToken
|
||||
|
||||
// GET with no cookie → the password gate, not the launcher.
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Ez az oldal jelszóval védett") {
|
||||
t.Fatalf("expected password gate, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// 5 wrong attempts → "Hibás jelszó"; the 6th within the window → rate-limited.
|
||||
for i := 0; i < 5; i++ {
|
||||
rr := s.sharePOST(mux, path, "wrong")
|
||||
if !strings.Contains(rr.Body.String(), "Hibás jelszó") {
|
||||
t.Fatalf("wrong attempt %d: want 'Hibás jelszó', got: %s", i+1, rr.Body.String())
|
||||
}
|
||||
}
|
||||
rr = s.sharePOST(mux, path, "wrong")
|
||||
if !strings.Contains(rr.Body.String(), "Túl sok sikertelen") {
|
||||
t.Fatalf("6th attempt must be rate-limited, got: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
// New IP, correct password → 303 + a signed gate cookie is set.
|
||||
s2 := shareTestServer(t)
|
||||
mux2 := s2.fullMux()
|
||||
s2.settings.SetLauncherShareToken(testShareToken)
|
||||
s2.settings.SetLauncherSharePasswordHash(string(hash))
|
||||
rr = s2.sharePOST(mux2, path, "guest-secret")
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("correct password: want 303, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
gate := cookieNamed(rr, shareCookieName)
|
||||
if gate == nil || gate.Value == "" {
|
||||
t.Fatal("correct password must set the signed gate cookie")
|
||||
}
|
||||
|
||||
// The cookie lets a subsequent GET through directly (no gate).
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(gate)
|
||||
rr = httptest.NewRecorder()
|
||||
mux2.ServeHTTP(rr, req)
|
||||
if strings.Contains(rr.Body.String(), "jelszóval védett") {
|
||||
t.Error("a valid gate cookie must skip the password page")
|
||||
}
|
||||
|
||||
// Changing the password invalidates the outstanding cookie (it binds the hash).
|
||||
newHash, _ := bcrypt.GenerateFromPassword([]byte("new-secret"), bcrypt.MinCost)
|
||||
s2.settings.SetLauncherSharePasswordHash(string(newHash))
|
||||
req = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(gate)
|
||||
rr = httptest.NewRecorder()
|
||||
mux2.ServeHTTP(rr, req)
|
||||
if !strings.Contains(rr.Body.String(), "jelszóval védett") {
|
||||
t.Error("changing the password must invalidate the old gate cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group D: rotation + disable (Scenario D) ──────────────────────────────────────────────────────
|
||||
func TestShareGuest_RotateAndDisable(t *testing.T) {
|
||||
s := shareTestServer(t)
|
||||
s.settings.SetLauncherShareToken(testShareToken)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
s.settings.SetLauncherSharePasswordHash(string(hash))
|
||||
oldGate := &http.Cookie{Name: shareCookieName, Value: s.shareCookieValue(testShareToken, string(hash))}
|
||||
|
||||
// Rotate: mint a new token; the old one 404s, the old gate cookie no longer passes.
|
||||
newTok, _ := newShareToken()
|
||||
s.settings.SetLauncherShareToken(newTok)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("old token after rotation: want 404, got %d", rr.Code)
|
||||
}
|
||||
// New token + old cookie → the cookie is bound to the OLD token, so the gate re-prompts.
|
||||
req := httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil)
|
||||
req.AddCookie(oldGate)
|
||||
rr = httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
if !strings.Contains(rr.Body.String(), "jelszóval védett") {
|
||||
t.Error("rotation must invalidate the old gate cookie")
|
||||
}
|
||||
|
||||
// Disable: every /s/ path 404s.
|
||||
s.settings.SetLauncherShareToken("")
|
||||
rr = httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("after disable: want 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group E: guest state labels (Scenario E) ──────────────────────────────────────────────────────
|
||||
func TestBuildGuestApps_Labels(t *testing.T) {
|
||||
apps := []LauncherApp{
|
||||
{Name: "a", DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning},
|
||||
{Name: "b", DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped},
|
||||
{Name: "c", DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited},
|
||||
{Name: "d", DisplayName: "Delta", Slug: "d", Subdomain: "d", State: stacks.StateDegraded},
|
||||
}
|
||||
g := buildGuestApps(apps, "demo-felhom.eu")
|
||||
if !g[0].Clickable || g[0].Href != "https://a.demo-felhom.eu" {
|
||||
t.Errorf("running app must be clickable with a public href, got %+v", g[0])
|
||||
}
|
||||
if g[1].Clickable || g[1].Label != "A tulajdonos leállította" {
|
||||
t.Errorf("stopped app: want greyed 'A tulajdonos leállította', got %+v", g[1])
|
||||
}
|
||||
if g[2].Clickable || g[2].Label != "Átmenetileg nem elérhető" {
|
||||
t.Errorf("exited app: want 'Átmenetileg nem elérhető', got %+v", g[2])
|
||||
}
|
||||
if g[3].Clickable || g[3].Label != "Átmenetileg nem elérhető" {
|
||||
t.Errorf("degraded app: want 'Átmenetileg nem elérhető', got %+v", g[3])
|
||||
}
|
||||
}
|
||||
|
||||
// The rendered guest page shows the calm labels and NEVER the internal state vocabulary.
|
||||
func TestShareGuestTemplate_LabelsNoInternalWords(t *testing.T) {
|
||||
g := buildGuestApps([]LauncherApp{
|
||||
{DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning},
|
||||
{DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped},
|
||||
{DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited},
|
||||
}, "demo-felhom.eu")
|
||||
html := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": g})
|
||||
if !strings.Contains(html, "A tulajdonos leállította") || !strings.Contains(html, "Átmenetileg nem elérhető") {
|
||||
t.Error("guest labels missing from rendered page")
|
||||
}
|
||||
for _, word := range []string{"stopped", "exited", "degraded", "unhealthy"} {
|
||||
if strings.Contains(html, word) {
|
||||
t.Errorf("internal state word %q leaked to the guest page", word)
|
||||
}
|
||||
}
|
||||
// Empty state.
|
||||
empty := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": []GuestLauncherApp{}})
|
||||
if !strings.Contains(empty, "Jelenleg nincs elérhető alkalmazás.") {
|
||||
t.Error("empty guest launcher must show the calm empty-state copy")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group F: claim gate supreme + admin surfaces stay admin (Scenario F) ──────────────────────────
|
||||
func TestShare_ClaimGateInterceptsGuestPage(t *testing.T) {
|
||||
s, _, _ := claimTestServer(t) // unclaimed: claim code, no password → claimGateActive
|
||||
s.settings.SetLauncherShareToken(testShareToken)
|
||||
rr := httptest.NewRecorder()
|
||||
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
|
||||
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" {
|
||||
t.Errorf("on an unclaimed box /s/<token> must hit the claim gate: got %d loc=%q", rr.Code, rr.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShare_AdminSurfacesRequireAuthAndCSRF(t *testing.T) {
|
||||
s := shareTestServer(t)
|
||||
mux := s.fullMux()
|
||||
|
||||
// Unauthenticated QR + share POSTs → login redirect.
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/launcher/share/qr.png"},
|
||||
{http.MethodPost, "/launcher/share/enable"},
|
||||
{http.MethodPost, "/launcher/share/rotate"},
|
||||
{http.MethodPost, "/launcher/share/disable"},
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
|
||||
if rr.Code != http.StatusFound || !strings.HasPrefix(rr.Header().Get("Location"), "/login") {
|
||||
t.Errorf("%s %s unauthenticated: want login redirect, got %d loc=%q", tc.method, tc.path, rr.Code, rr.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated but CSRF-missing POST → 403.
|
||||
sessTok := s.createSession()
|
||||
req := httptest.NewRequest(http.MethodPost, "/launcher/share/enable", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessTok})
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("CSRF-missing admin POST: want 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group G: the token never reaches the logs (Scenario G) ────────────────────────────────────────
|
||||
// COMPANION red-proof (REPORT): revert the /s/ redaction in ServeHTTP (log the raw path) → the
|
||||
// "token absent from logs" assertion FAILS.
|
||||
func TestShareGuest_TokenNeverLogged(t *testing.T) {
|
||||
s := shareTestServer(t)
|
||||
var buf bytes.Buffer
|
||||
s.logger = log.New(&buf, "", 0)
|
||||
s.cfg.Logging.Level = "debug"
|
||||
s.settings.SetLauncherShareToken(testShareToken)
|
||||
|
||||
// A valid GET (debug ServeHTTP line) and a wrong-token 404 must both keep the token out of logs.
|
||||
for _, p := range []string{"/s/" + testShareToken, "/s/WRONGSECRET123"} {
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
||||
}
|
||||
logs := buf.String()
|
||||
if strings.Contains(logs, testShareToken) || strings.Contains(logs, "WRONGSECRET123") {
|
||||
t.Errorf("a share token leaked into the logs:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "/s/<redacted>") {
|
||||
t.Errorf("expected a redacted /s/<redacted> path in the debug log, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user