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(`