977665d8c0
gates / gates (push) Successful in 27s
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request. - internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop; priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1). - internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family); sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches. RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove. Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
376 lines
16 KiB
Go
376 lines
16 KiB
Go
package web
|
|
|
|
import (
|
|
"html"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// v0.287.0 (`09` §3 decisions 63/64, R-780) — the family gate's answerer and its sign-in pages, driven through the
|
|
// handlers traefik and the browser reach (ServeFamilyGateAuth, ServeFamilyStart/Login/Logout) and through the
|
|
// dashboard's own RequireAuth. Docker is a stub on PATH.
|
|
|
|
func familyHarness(t *testing.T) (*Server, *family.Store) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "bin")
|
|
for _, d := range []string{bin, filepath.Join(dir, "data"), filepath.Join(dir, "stacks", "fapp"), filepath.Join(dir, "stacks", "gapp")} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", bin)
|
|
write := func(app, name, body string) {
|
|
if err := os.WriteFile(filepath.Join(dir, "stacks", app, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("fapp", "docker-compose.yml", "services:\n fapp:\n image: busybox\n")
|
|
write("fapp", ".felhom.yml", "display_name: Family App\nslug: fapp\nfamily_gate: true\n")
|
|
write("fapp", "app.yaml", "deployed: true\nfamily_gate:\n since: \"2026-10-02T00:00:00Z\"\n hosts: [fapp.example.hu]\n")
|
|
write("gapp", "docker-compose.yml", "services:\n gapp:\n image: busybox\n")
|
|
write("gapp", ".felhom.yml", "display_name: Gated App\nslug: gapp\nsetup_gate: true\n")
|
|
write("gapp", "app.yaml", "deployed: true\nsetup_gate:\n state: closed\n since: \"2026-09-29T00:00:00Z\"\n hosts: [gapp.example.hu]\n")
|
|
lg := log.New(io.Discard, "", 0)
|
|
cfg := config.Default()
|
|
cfg.Customer.Domain = "example.hu"
|
|
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
|
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
|
h, _ := bcrypt.GenerateFromPassword([]byte("dashboard-pass"), bcrypt.MinCost)
|
|
cfg.Web.PasswordHash = string(h)
|
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mgr, err := stacks.NewManager(cfg, lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mgr.ScanStacks(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := family.Open(cfg.Paths.DataDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st.SetCost(bcrypt.MinCost)
|
|
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", sessions: map[string]*session{},
|
|
loginAttempts: map[string]*loginAttempt{}, familyStoreOverride: st}
|
|
s.loadTemplates()
|
|
return s, st
|
|
}
|
|
|
|
func famAsk(s *Server, host, method, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, nil)
|
|
r.Header.Set("X-Forwarded-Host", host)
|
|
r.Header.Set("X-Forwarded-Method", method)
|
|
r.Header.Set("X-Forwarded-Uri", uri)
|
|
r.Header.Set("Accept", accept)
|
|
for _, c := range cookies {
|
|
r.AddCookie(c)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.ServeFamilyGateAuth(w, r)
|
|
return w
|
|
}
|
|
|
|
func famCookie(w *httptest.ResponseRecorder, name string) *http.Cookie {
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == name {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// famLogin posts the sign-in form as visitor `remote` (a direct peer — clientIP is the peer).
|
|
func famLogin(s *Server, remote, name, pw, rd string) *httptest.ResponseRecorder {
|
|
form := url.Values{"_ft": {s.familyFormToken()}, "name": {name}, "password": {pw}, "rd": {rd}}
|
|
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.RemoteAddr = remote + ":5000"
|
|
w := httptest.NewRecorder()
|
|
s.ServeFamilyLogin(w, r)
|
|
return w
|
|
}
|
|
|
|
// famPass walks a signed-in browser (its family session cookie) through start → callback → app cookie.
|
|
func famPass(t *testing.T, s *Server, sess *http.Cookie) *http.Cookie {
|
|
t.Helper()
|
|
rd := "https://fapp.example.hu/books"
|
|
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
|
|
r.AddCookie(sess)
|
|
w := httptest.NewRecorder()
|
|
s.ServeFamilyStart(w, r)
|
|
loc := w.Header().Get("Location")
|
|
if w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://fapp.example.hu"+familyCallbackURI+"?t=") {
|
|
t.Fatalf("start with a family session: %d %q", w.Code, loc)
|
|
}
|
|
u, _ := url.Parse(loc)
|
|
cb := famAsk(s, "fapp.example.hu", "GET", u.RequestURI(), "text/html")
|
|
app := famCookie(cb, familyAppCookie)
|
|
if cb.Code != http.StatusFound || cb.Header().Get("Location") != rd || app == nil {
|
|
t.Fatalf("callback: %d %q cookie %v", cb.Code, cb.Header().Get("Location"), app)
|
|
}
|
|
return app
|
|
}
|
|
|
|
// Exit item 1: a stranger reaches nothing — a browser is sent to the family sign-in, anything else is refused.
|
|
func TestFamilyGate_StrangerReachesNothing(t *testing.T) {
|
|
s, _ := familyHarness(t)
|
|
w := famAsk(s, "fapp.example.hu", "GET", "/", "text/html")
|
|
if w.Code != http.StatusFound || !strings.HasPrefix(w.Header().Get("Location"), "https://felhom.example.hu"+familyStartPath+"?rd=") {
|
|
t.Fatalf("a browser must be sent to the family sign-in: %d %q", w.Code, w.Header().Get("Location"))
|
|
}
|
|
for _, m := range []string{"GET", "POST"} {
|
|
if w := famAsk(s, "fapp.example.hu", m, "/api/x", "application/json"); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("%s API without a pass: %d", m, w.Code)
|
|
}
|
|
}
|
|
if w := famAsk(s, "other.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden {
|
|
t.Fatalf("a host no family app owns must be refused: %d", w.Code)
|
|
}
|
|
forged := &http.Cookie{Name: familyAppCookie, Value: "abc." + "99999999999" + ".deadbeef"}
|
|
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", forged); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("a forged app cookie must be refused: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// Exit item 2 + rule 1: a member's OWN login opens the app; the family session never opens the dashboard and the
|
|
// family pages never set the dashboard cookie.
|
|
// COMPANION RED-PROOF: make RequireAuth accept felhom_family → the dashboard assertion fails.
|
|
func TestFamilyGate_MemberPassesButNeverTheDashboard(t *testing.T) {
|
|
s, st := familyHarness(t)
|
|
pw, _ := st.Add("anna")
|
|
w := famLogin(s, "203.0.113.10", "anna", pw, "https://fapp.example.hu/books")
|
|
sess := famCookie(w, familySessionCookie)
|
|
if w.Code != http.StatusFound || sess == nil || sess.Path != familyCookiePath || sess.MaxAge < 7*24*3600 {
|
|
t.Fatalf("sign-in: %d cookie %+v", w.Code, sess)
|
|
}
|
|
if famCookie(w, sessionCookieName) != nil {
|
|
t.Fatal("the family sign-in must never set the dashboard cookie")
|
|
}
|
|
app := famPass(t, s, sess)
|
|
if app.MaxAge < 7*24*3600 || app.Domain != "" {
|
|
t.Fatalf("the app cookie must last days and be host-only: %+v", app)
|
|
}
|
|
if w := famAsk(s, "fapp.example.hu", "GET", "/books", "text/html", app); w.Code != http.StatusOK {
|
|
t.Fatalf("the member's app cookie must pass: %d", w.Code)
|
|
}
|
|
// the same cookies at the dashboard: refused
|
|
h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) }))
|
|
for _, p := range []string{"/launcher", "/api/stacks", "/settings/security"} {
|
|
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil)
|
|
r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: sess.Value})
|
|
r.AddCookie(&http.Cookie{Name: familyAppCookie, Value: app.Value})
|
|
rw := httptest.NewRecorder()
|
|
h.ServeHTTP(rw, r)
|
|
if rw.Code == 299 {
|
|
t.Fatalf("a family cookie opened the dashboard at %s", p)
|
|
}
|
|
}
|
|
// a cookie for another app host does not pass
|
|
if w := famAsk(s, "fapp2.example.hu", "GET", "/", "application/json", app); w.Code == http.StatusOK {
|
|
t.Fatal("an app cookie must not pass another host")
|
|
}
|
|
}
|
|
|
|
// Rule 3 + exit item 2's logout: a reset, a removal and a logout each end access on the NEXT request.
|
|
func TestFamilyGate_ResetRemoveLogoutEndAccessAtOnce(t *testing.T) {
|
|
s, st := familyHarness(t)
|
|
pw, _ := st.Add("anna")
|
|
login := func() *http.Cookie {
|
|
w := famLogin(s, "203.0.113.10", "anna", pw, "")
|
|
return famCookie(w, familySessionCookie)
|
|
}
|
|
ok := func(app *http.Cookie) bool {
|
|
return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK
|
|
}
|
|
sess := login()
|
|
app := famPass(t, s, sess)
|
|
pw, _ = st.Reset("anna")
|
|
if ok(app) {
|
|
t.Fatal("a reset password must end the member's app access at once")
|
|
}
|
|
sess = login()
|
|
app = famPass(t, s, sess)
|
|
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath, nil)
|
|
r.AddCookie(sess)
|
|
s.ServeFamilyLogout(httptest.NewRecorder(), r)
|
|
if ok(app) {
|
|
t.Fatal("logout must end the app access minted from that session")
|
|
}
|
|
sess = login()
|
|
app = famPass(t, s, sess)
|
|
st.Remove("anna")
|
|
if ok(app) {
|
|
t.Fatal("a removed member must lose access at once")
|
|
}
|
|
}
|
|
|
|
// Rule 2: the sign-in is counted per VISITOR and per NAME — a stranger locks only himself, and a name under attack is
|
|
// locked for minutes, never the household.
|
|
func TestFamilyGate_LockPerVisitorAndPerName(t *testing.T) {
|
|
s, st := familyHarness(t)
|
|
now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
|
s.gateClock = func() time.Time { return now }
|
|
pa, _ := st.Add("anna")
|
|
pb, _ := st.Add("bela")
|
|
for i := 0; i < familyVisitorMax; i++ {
|
|
famLogin(s, "198.51.100.66", "anna", "wrong", "")
|
|
}
|
|
if w := famLogin(s, "198.51.100.66", "anna", pa, ""); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("the stranger's own address must be locked even with the right password: %d", w.Code)
|
|
}
|
|
if w := famLogin(s, "203.0.113.10", "anna", pa, ""); w.Code != http.StatusOK && w.Code != http.StatusFound {
|
|
t.Fatalf("anna from her own address must get in at once: %d", w.Code)
|
|
}
|
|
// a name under a spread attack (many addresses)
|
|
for i := 0; i < familyNameMax; i++ {
|
|
famLogin(s, "198.51.100."+string(rune('a'+i)), "bela", "wrong", "")
|
|
}
|
|
if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("a name under a spread attack must be locked: %d", w.Code)
|
|
}
|
|
if w := famLogin(s, "203.0.113.20", "anna", pa, ""); w.Code == http.StatusTooManyRequests {
|
|
t.Fatal("another member must not be locked by bela's attack")
|
|
}
|
|
now = now.Add(familyNameWindow + time.Second)
|
|
if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code == http.StatusTooManyRequests {
|
|
t.Fatal("the name lock must pass after its window (minutes, not forever)")
|
|
}
|
|
}
|
|
|
|
// The household's dashboard session vouches (decision 46's rule) — as a household session in the family store.
|
|
func TestFamilyGate_HouseholdPassesWithItsDashboardSession(t *testing.T) {
|
|
s, _ := familyHarness(t)
|
|
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: sessionCookieName, Value: newTestSession(s)})
|
|
w := httptest.NewRecorder()
|
|
s.ServeFamilyStart(w, r)
|
|
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), familyCallbackURI) {
|
|
t.Fatalf("the household must pass: %d %q", w.Code, w.Header().Get("Location"))
|
|
}
|
|
// no session at all: the sign-in page, never a token
|
|
r2 := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
|
|
w2 := httptest.NewRecorder()
|
|
s.ServeFamilyStart(w2, r2)
|
|
if w2.Code != http.StatusOK || strings.Contains(w2.Header().Get("Location"), "t=") || !strings.Contains(w2.Body.String(), `name="password"`) {
|
|
t.Fatalf("no session → the sign-in page: %d", w2.Code)
|
|
}
|
|
}
|
|
|
|
// A token is one-use, bound to its host, and refused once its session ended.
|
|
func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) {
|
|
s, st := familyHarness(t)
|
|
st.Add("anna")
|
|
sid, _ := st.NewSession("anna")
|
|
tok := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/")
|
|
if _, _, err := s.takeFamilyToken(tok, "other.example.hu"); err == nil {
|
|
t.Fatal("a token for another host must be refused")
|
|
}
|
|
if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err != nil {
|
|
t.Fatalf("first use: %v", err)
|
|
}
|
|
if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err == nil {
|
|
t.Fatal("a token must be one-use")
|
|
}
|
|
tok2 := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/")
|
|
st.EndSession(sid)
|
|
if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok2, "text/html"); w.Code != http.StatusForbidden {
|
|
t.Fatalf("a token whose session ended must be refused: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// Rule 4: the family gate leaves the setup gate as it was — the setup-gated app answers the setup gate's handler, the
|
|
// family handler knows nothing of it.
|
|
func TestFamilyGate_SetupGateUntouched(t *testing.T) {
|
|
s, _ := familyHarness(t)
|
|
if w := famAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden {
|
|
t.Fatalf("the family handler must not answer for a setup-gated app: %d", w.Code)
|
|
}
|
|
if w := gateAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusFound {
|
|
t.Fatalf("the setup gate must still send a browser to its start: %d", w.Code)
|
|
}
|
|
if w := gateAsk(s, "fapp.example.hu", "GET", "/", "application/json"); w.Code != http.StatusForbidden {
|
|
t.Fatalf("the setup gate must not answer for a family app: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// The family sign-in's messages follow the reader (it has no session, so the language cookie decides).
|
|
func TestFamilyGate_MessagesFollowTheReader(t *testing.T) {
|
|
s, st := familyHarness(t)
|
|
st.Add("anna")
|
|
for _, c := range []struct{ lang, want, not string }{{"en", "Wrong name or password.", "Hibás név"}, {"hu", "Hibás név vagy jelszó.", "Wrong name"}} {
|
|
form := url.Values{"_ft": {s.familyFormToken()}, "name": {"anna"}, "password": {"x"}}
|
|
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.RemoteAddr = "192.168.0.7:1"
|
|
r.AddCookie(&http.Cookie{Name: langCookieName, Value: c.lang})
|
|
w := httptest.NewRecorder()
|
|
s.ServeFamilyLogin(w, r)
|
|
body := html.UnescapeString(w.Body.String())
|
|
if !strings.Contains(body, c.want) || strings.Contains(body, c.not) {
|
|
t.Errorf("%s: want %q not %q", c.lang, c.want, c.not)
|
|
}
|
|
}
|
|
// an expired or forged form token is refused before any password check
|
|
form := url.Values{"_ft": {"1.deadbeef"}, "name": {"anna"}, "password": {"x"}}
|
|
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.ServeFamilyLogin(w, r)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("a forged form token: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// The dashboard card's acts: the password is in the answer ONCE (JSON), never in the page.
|
|
func TestFamilyGate_CardActsAndNoPasswordInThePage(t *testing.T) {
|
|
s, st := familyHarness(t)
|
|
act := func(a, name string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodPost, "/family/members/"+a, strings.NewReader(url.Values{"name": {name}}.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.familyMemberActionHandler(w, r, a)
|
|
return w
|
|
}
|
|
w := act("add", "anna")
|
|
m := regexp.MustCompile(`"password":"([a-z0-9-]{19})"`).FindStringSubmatch(w.Body.String())
|
|
if w.Code != http.StatusOK || m == nil || !st.Verify("anna", m[1]) || w.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("add: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if w := act("add", "anna"); w.Code != http.StatusConflict {
|
|
t.Fatalf("duplicate: %d", w.Code)
|
|
}
|
|
if w := act("add", "Not Valid"); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad name: %d", w.Code)
|
|
}
|
|
if w := act("remove", "anna"); w.Code != http.StatusOK || strings.Contains(w.Body.String(), "password") {
|
|
t.Fatalf("remove: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if w := act("reset", "anna"); w.Code != http.StatusNotFound {
|
|
t.Fatalf("reset of a removed member: %d", w.Code)
|
|
}
|
|
}
|