package web import ( "encoding/base64" "errors" "fmt" "html" "net/http" "net/http/httptest" "net/url" "regexp" "strconv" "strings" "sync" "sync/atomic" "testing" "time" "golang.org/x/crypto/bcrypt" ) // R-983 (security review 2026-10-12, six remaining items) and R-976 (family and gate sessions), v0.324.0. Each test // drives the handler the browser or traefik reaches. Red-proofs are recorded in the REPORT of the release. // ── R-983 item 1: after sign-in the browser stays on the box ───────────────────────────────────────────────── func TestR983_LoginNextStaysOnTheBox(t *testing.T) { s := rateLimitTestServer(t) login := func(next string) string { form := url.Values{"password": {"correct-pass"}, "next": {next}} r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = "203.0.113.7:4000" w := httptest.NewRecorder() s.handleLogin(w, r) if w.Code != http.StatusFound { t.Fatalf("next=%q: sign-in answered %d, want 302", next, w.Code) } return w.Header().Get("Location") } for _, evil := range []string{"//evil.example/x", "/\\evil.example", "https://evil.example/", "/\\/evil.example"} { if got := login(evil); got != "/" { t.Errorf("next=%q sent the signed-in browser to %q — want /", evil, got) } } if got := login("/stacks?tab=1"); got != "/stacks?tab=1" { t.Errorf("a page on the box must still be the destination: got %q", got) } } // ── R-983 item 2: no page of the dashboard may be framed ───────────────────────────────────────────────────── func TestR983_DashboardRefusesFraming(t *testing.T) { s, _ := familyHarness(t) reached := false h := s.CatchAllMiddleware(s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true w.WriteHeader(http.StatusOK) }))) sess := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)} for _, p := range []string{"/launcher", "/login", familyLoginPath, "/settings/security"} { r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil) r.AddCookie(sess) w := httptest.NewRecorder() h.ServeHTTP(w, r) if got := w.Header().Get("X-Frame-Options"); got != "DENY" { t.Errorf("%s: X-Frame-Options %q, want DENY", p, got) } if got := w.Header().Get("Content-Security-Policy"); got != "frame-ancestors 'none'" { t.Errorf("%s: Content-Security-Policy %q, want frame-ancestors 'none'", p, got) } } if !reached { t.Fatal("the signed-in page was never reached — the test proved nothing about a real page") } // traefik's forwardAuth answers stay as they were: their headers must not ride onto an app's responses. r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, nil) r.Header.Set("X-Forwarded-Host", "fapp.example.hu") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Header().Get("X-Frame-Options") != "" || w.Header().Get("Content-Security-Policy") != "" { t.Error("the forwardAuth answer must carry no framing header") } } // ── R-983 item 3: parallel sign-in tries are counted ───────────────────────────────────────────────────────── // The compare is a seam that waits until every try has arrived (or a short deadline): with check-then-count all 20 // tries reach it; with the try reserved first only loginMaxAttempts do. func TestR983_ParallelLoginsHitTheLimit(t *testing.T) { s := rateLimitTestServer(t) const tries = 20 var calls int32 all := make(chan struct{}) var once sync.Once s.passwordCompare = func(hash, pw []byte) error { if atomic.AddInt32(&calls, 1) == tries { once.Do(func() { close(all) }) } select { case <-all: case <-time.After(400 * time.Millisecond): } return errors.New("mismatch") } var wg sync.WaitGroup start := make(chan struct{}) for i := 0; i < tries; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start doLogin(s, fmt.Sprintf("198.51.100.9:%d", 6000+i), "", "wrong") }(i) } close(start) wg.Wait() if got := atomic.LoadInt32(&calls); got > loginMaxAttempts { t.Fatalf("%d parallel wrong tries reached the password compare — the limit is %d per visitor", got, loginMaxAttempts) } // and the household's right password is still refused only for this visitor, not for everyone s.passwordCompare = nil if body := doLogin(s, "203.0.113.50:7000", "", "correct-pass"); body.Code != http.StatusFound { t.Fatalf("another visitor with the right password must get in: %d", body.Code) } } func TestR983_ParallelFamilySignInsHitTheLimit(t *testing.T) { s, st := familyHarness(t) st.SetCost(10) // a real compare takes tens of milliseconds, so the tries overlap if _, err := st.Add("anna"); err != nil { t.Fatal(err) } const tries = 20 var wrong, locked int32 var wg sync.WaitGroup start := make(chan struct{}) for i := 0; i < tries; i++ { wg.Add(1) go func() { defer wg.Done() <-start switch famLogin(s, "198.51.100.66", "anna", "wrong", "").Code { case http.StatusUnauthorized: atomic.AddInt32(&wrong, 1) case http.StatusTooManyRequests: atomic.AddInt32(&locked, 1) } }() } close(start) wg.Wait() if wrong > familyVisitorMax { t.Fatalf("%d parallel tries had their password checked — the limit is %d per visitor (locked: %d)", wrong, familyVisitorMax, locked) } if wrong+locked != tries { t.Fatalf("every try must be answered as wrong or locked: %d + %d of %d", wrong, locked, tries) } } // ── R-983 item 4: the settings page's minimum is the claim's ───────────────────────────────────────────────── func TestR983_SettingsPasswordMinimumIsTheClaims(t *testing.T) { if claimMinPassword != 12 { t.Fatalf("the claim's minimum moved to %d — this test's literals assume 12", claimMinPassword) } s, _ := familyHarness(t) tok := newTestSession(s) change := func(pw string) *httptest.ResponseRecorder { form := url.Values{"current_password": {"dashboard-pass"}, "new_password": {pw}, "confirm_password": {pw}} r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu/settings/password", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok}) w := httptest.NewRecorder() s.settingsPasswordHandler(w, r) return w } w := change("eleven-char") if w.Code == http.StatusFound { t.Fatal("an 11-character password was accepted — the claim requires 12") } if !strings.Contains(w.Body.String(), "legalább 12 karakter") { t.Errorf("the refusal must name the claim's minimum (Hungarian)") } if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("dashboard-pass")) != nil { t.Fatal("the refused change altered the password") } // the page's own form says the same minimum (both languages render it from one value) page := w.Body.String() if !strings.Contains(page, `minlength="12"`) || strings.Contains(page, `minlength="8"`) { t.Errorf("the password form must ask for at least 12 characters") } if got := s.msgLang("en", "settings.pw_too_short", claimMinPassword); !strings.Contains(got, "at least 12 characters") { t.Errorf("English refusal %q must name 12", got) } if w := change("twelve-chars"); w.Code != http.StatusFound { t.Fatalf("a 12-character password must be accepted: %d", w.Code) } } // ── R-983 item 5: reset-code requests are limited on the box ───────────────────────────────────────────────── func TestR983_ResetCodeRequestsAreLimited(t *testing.T) { s, _, _ := claimTestServer(t) now := time.Date(2026, 10, 16, 2, 0, 0, 0, time.UTC) s.claimClock = func() time.Time { return now } var calls int s.resetRequestFn = func() { calls++ } ask := func(visitor string) { form := url.Values{"_csrf": {s.claimCSRFToken()}} r := httptest.NewRequest(http.MethodPost, "/claim/request-new-code", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) r.RemoteAddr = visitor + ":5000" w := httptest.NewRecorder() s.handleClaimRequestNewCode(w, r) if w.Code != http.StatusOK { t.Fatalf("the answer must stay the neutral page: %d", w.Code) } } for i := 0; i < 3; i++ { ask("198.51.100.1") } if calls != 1 { t.Fatalf("3 requests in one minute from one visitor reached the hub %d times — want 1", calls) } now = now.Add(resetReqVisitorEvery + time.Second) ask("198.51.100.1") if calls != 2 { t.Fatalf("after a minute the same visitor must reach the hub again: %d calls", calls) } ask("198.51.100.2") ask("198.51.100.3") // the box's budget for the quarter hour is used here if calls != resetReqGlobalMax { t.Fatalf("calls %d, want %d", calls, resetReqGlobalMax) } ask("198.51.100.4") if calls != resetReqGlobalMax { t.Fatalf("a fourth visitor in the same quarter hour reached the hub: %d calls", calls) } now = now.Add(resetReqGlobalWindow) ask("198.51.100.4") if calls != resetReqGlobalMax+1 { t.Fatalf("after the window a request must reach the hub again (the household is not locked out for long): %d", calls) } } // ── R-983 item 6: logout cannot be triggered from another page ─────────────────────────────────────────────── func TestR983_LogoutNeedsCSRF(t *testing.T) { s, _ := familyHarness(t) tok := newTestSession(s) h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) })) out := func(method, csrf string) *httptest.ResponseRecorder { var body *strings.Reader if csrf != "" { body = strings.NewReader(url.Values{csrfFormField: {csrf}}.Encode()) } else { body = strings.NewReader("") } r := httptest.NewRequest(method, "https://felhom.example.hu/logout", body) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok}) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } if w := out(http.MethodPost, ""); w.Code != http.StatusForbidden || !s.isValidSession(tok) { t.Fatalf("a POST without the token: %d, session valid %v — want 403 and the session kept", w.Code, s.isValidSession(tok)) } if w := out(http.MethodPost, "not-the-token"); w.Code != http.StatusForbidden || !s.isValidSession(tok) { t.Fatalf("a POST with a wrong token: %d — want 403 and the session kept", w.Code) } if w := out(http.MethodGet, ""); w.Code != http.StatusFound || w.Header().Get("Location") != "/" || !s.isValidSession(tok) { t.Fatalf("a GET must change nothing: %d %q", w.Code, w.Header().Get("Location")) } // The dashboard's own control: a POST form carrying the session's token. r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu/settings/security", nil) r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok}) pw := httptest.NewRecorder() s.settingsSecurityPageHandler(pw, r) m := regexp.MustCompile(`
`).FindStringSubmatch(pw.Body.String()) if m == nil { t.Fatal("the dashboard's sign-out control must be a POST form carrying the session's token") } if w := out(http.MethodPost, m[1]); w.Code != http.StatusFound || w.Header().Get("Location") != "/login" || s.isValidSession(tok) { t.Fatalf("the dashboard's own sign-out: %d %q, session valid %v — want 302 /login and the session ended", w.Code, w.Header().Get("Location"), s.isValidSession(tok)) } } func TestR983_FamilyLogoutNeedsItsToken(t *testing.T) { s, st := familyHarness(t) pw, _ := st.Add("anna") sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie) app := famPass(t, s, sess) ok := func() bool { return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK } out := func(method, ft string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, "https://felhom.example.hu"+familyLogoutPath, strings.NewReader(url.Values{"_ft": {ft}}.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(sess) w := httptest.NewRecorder() s.ServeFamilyLogout(w, r) return w } out(http.MethodGet, "") out(http.MethodPost, "") out(http.MethodPost, s.familyFormToken()) // the sign-in form's token: anyone can fetch one if !ok() { t.Fatal("a GET, a tokenless POST or a POST with the sign-in form's token ended the family session") } // the page's own sign-out form carries the session's token, and that ends it r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyLoginPath, nil) r.AddCookie(sess) pg := httptest.NewRecorder() s.ServeFamilyLogin(pg, r) m := regexp.MustCompile(`action="/__family/logout">\s*`).FindStringSubmatch(pg.Body.String()) if m == nil { t.Fatal("the family page's sign-out form must carry its token") } if w := out(http.MethodPost, html.UnescapeString(m[1])); w.Code != http.StatusOK { t.Fatalf("the page's own sign-out: %d", w.Code) } if ok() { t.Fatal("the family page's own sign-out must end the session") } } // ── R-976 (b): a family app cookie cannot be lifted into another app ───────────────────────────────────────── func TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp(t *testing.T) { s, st := familyHarness(t) pw, _ := st.Add("anna") sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie) app := famPass(t, s, sess) if strings.Contains(app.Value, sess.Value) { t.Fatal("the app cookie carries the family session itself — an app's backend could lift it") } // what an app backend sees, set as the family session on the dashboard host, must mint nothing lifted := strings.SplitN(app.Value, ".", 2)[0] rd := "https://fapp.example.hu/" r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: lifted}) w := httptest.NewRecorder() s.ServeFamilyStart(w, r) if loc := w.Header().Get("Location"); strings.Contains(loc, familyCallbackURI) { t.Fatalf("a value lifted from an app cookie minted a pass: %q", loc) } // the sign-in token in flight carries no session either tok := s.mintFamilyToken("fapp.example.hu", sess.Value, rd) if dec, _ := base64.RawURLEncoding.DecodeString(tok); strings.Contains(string(dec), sess.Value) { t.Fatal("the sign-in token carries the family session id") } // a cookie of the old shape (the session id itself, correctly signed) is refused exp := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) old := &http.Cookie{Name: familyAppCookie, Value: sess.Value + "." + exp + "." + s.gateMAC("famcookie", "fapp.example.hu", sess.Value, exp)} if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", old); w.Code == http.StatusOK { t.Fatal("an app cookie carrying the session id must not pass any more") } // the genuine cookie still passes its own app if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK { t.Fatalf("the member's own app cookie must pass: %d", w.Code) } } // ── R-976 (a): a password change or reset ends every family and gate session ───────────────────────────────── // r976World signs anna in (family session + app cookie) and walks the household through the setup gate (gate pass). func r976World(t *testing.T, s *Server) (famSess, famApp, gatePass *http.Cookie) { t.Helper() pw, err := s.familyStore().Add("anna") if err != nil { t.Fatal(err) } famSess = famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie) famApp = famPass(t, s, famSess) dash := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)} w := gateStart(s, "https://gapp.example.hu/", dash) cb, _ := url.Parse(w.Header().Get("Location")) w = gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html") for _, c := range w.Result().Cookies() { if c.Name == gateCookieName { gatePass = c } } if gatePass == nil { t.Fatal("setup: no gate pass") } r976AllOpen(t, s, famSess, famApp, gatePass, true) return famSess, famApp, gatePass } func r976AllOpen(t *testing.T, s *Server, famSess, famApp, gatePass *http.Cookie, want bool) { t.Helper() appOK := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", famApp).Code == http.StatusOK gateOK := gateAsk(s, "gapp.example.hu", "GET", "/", "application/json", gatePass).Code == http.StatusOK r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {"https://fapp.example.hu/"}}.Encode(), nil) r.AddCookie(famSess) w := httptest.NewRecorder() s.ServeFamilyStart(w, r) sessOK := strings.Contains(w.Header().Get("Location"), familyCallbackURI) if appOK != want || gateOK != want || sessOK != want { t.Fatalf("family app cookie passes %v, setup gate pass passes %v, family session mints %v — want all %v", appOK, gateOK, sessOK, want) } } func TestR976_PasswordChangeEndsFamilyAndGateSessions(t *testing.T) { s, st := familyHarness(t) famSess, famApp, gatePass := r976World(t, s) form := url.Values{"current_password": {"dashboard-pass"}, "new_password": {"a-new-password-12"}, "confirm_password": {"a-new-password-12"}} r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu/settings/password", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.settingsPasswordHandler(w, r) if w.Code != http.StatusFound { t.Fatalf("the password change: %d", w.Code) } r976AllOpen(t, s, famSess, famApp, gatePass, false) // the members stay; anna signs in again with her own password if names := st.Names(); len(names) != 1 || names[0] != "anna" { t.Fatalf("the family list must be kept: %v", names) } } func TestR976_ClaimResetEndsFamilyAndGateSessions(t *testing.T) { s, _ := familyHarness(t) s.cfg.Web.SessionSecret = "test-session-secret-abcdef" code := "alma-korte-szilva" hash, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.MinCost) if err := s.settings.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil { t.Fatal(err) } famSess, famApp, gatePass := r976World(t, s) form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-new-password-12"}, "confirm_password": {"a-new-password-12"}} r := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) w := httptest.NewRecorder() s.handleClaimSubmit(w, r) if w.Code != http.StatusFound || w.Header().Get("Location") != "/" { t.Fatalf("the reset: %d %q", w.Code, w.Header().Get("Location")) } r976AllOpen(t, s, famSess, famApp, gatePass, false) // a restart must not bring the old passes back: the rotated key is what is on disk s2 := &Server{cfg: s.cfg, settings: s.settings, stackMgr: s.stackMgr, logger: s.logger, version: "test", sessions: map[string]*session{}, familyStoreOverride: s.familyStoreOverride} s2.loadTemplates() if w := gateAsk(s2, "gapp.example.hu", "GET", "/", "application/json", gatePass); w.Code == http.StatusOK { t.Fatal("after a restart the old gate pass opened the app again") } }