Files
felhom-controller/controller/internal/web/r983_r976_test.go
T

453 lines
20 KiB
Go

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(`<form method="post" action="/logout" class="logout-form"><input type="hidden" name="_csrf" value="([0-9a-f]+)">`).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*<input type="hidden" name="_ft" value="([^"]+)">`).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")
}
}