v0.325.0: share + escrow limiters reserve first; family sessions as fingerprints; samba renames apply; path guard widened; gates in a worktree
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
R-1111 reserveAttempt is the one limiter body (share password, escrow re-auth, dashboard sign-in). R-1112 family.json holds fingerprints; old files are converted at load. R-1113 the household's pass reuses its family session. R-1109 the samba compose carries the smb.conf hash. R-1110 TestNoBareDrivePathToPathHelpers. R-1114 shared_scripts.py resolves the felhom.eu sibling from a worktree. Each red-proved. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
7d41c2973c
commit
94f44931dd
28 files changed
+945
-190
No files matched your search
@@ -0,0 +1,310 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
||||
)
|
||||
|
||||
// R-1111 (the share-password and escrow re-auth limiters reserve the try before the slow compare), R-1112 (family
|
||||
// sessions are stored as fingerprints) and R-1113 (the household's family session is reused, not minted on every pass).
|
||||
// Each test drives the handler the browser reaches.
|
||||
|
||||
// slowWrongCompare makes s's password compare wait until `tries` callers have arrived (or a short deadline) and then
|
||||
// answer "wrong": with check-then-count every try reaches it; with the try reserved first only loginMaxAttempts do.
|
||||
// It returns the counter of tries that reached the compare.
|
||||
func slowWrongCompare(s *Server, tries int32) *int32 {
|
||||
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")
|
||||
}
|
||||
return &calls
|
||||
}
|
||||
|
||||
// inParallel runs f(i) for i in [0,n) released at once, and waits for all.
|
||||
func inParallel(n int, f func(i int)) {
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
f(i)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ── R-1111: parallel share-password tries are counted ────────────────────────────────────────────────────────
|
||||
|
||||
func TestR1111_ParallelSharePasswordsHitTheLimit(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
|
||||
const tries = 20
|
||||
calls := slowWrongCompare(s, tries)
|
||||
var locked int32
|
||||
inParallel(tries, func(int) {
|
||||
if strings.Contains(s.sharePOST(mux, path, "wrong").Body.String(), "Túl sok sikertelen") {
|
||||
atomic.AddInt32(&locked, 1)
|
||||
}
|
||||
})
|
||||
if got := atomic.LoadInt32(calls); got > loginMaxAttempts {
|
||||
t.Fatalf("%d parallel wrong share passwords reached the compare — the limit is %d per visitor", got, loginMaxAttempts)
|
||||
}
|
||||
if got := atomic.LoadInt32(&locked); got != tries-loginMaxAttempts {
|
||||
t.Fatalf("%d tries were told they were locked, want %d", got, tries-loginMaxAttempts)
|
||||
}
|
||||
// the share's counter is its own: the household's dashboard counter is untouched
|
||||
if len(s.loginAttempts) != 0 {
|
||||
t.Fatalf("the share password counted on the dashboard's counter: %v", s.loginAttempts)
|
||||
}
|
||||
// another visitor with the right password still gets in, and a right password clears that visitor's count
|
||||
s.passwordCompare = nil
|
||||
csrf := s.shareCSRFToken()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(url.Values{"_csrf": {csrf}, "password": {"guest-secret"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf})
|
||||
req.RemoteAddr = "203.0.113.77:4000"
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("another visitor with the right share password: %d", rr.Code)
|
||||
}
|
||||
if s.shareAttempts["203.0.113.77"] != nil {
|
||||
t.Fatal("a right share password must clear the visitor's count")
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-1111: parallel escrow re-auth tries are counted ────────────────────────────────────────────────────────
|
||||
|
||||
func TestR1111_ParallelEscrowReauthsHitTheLimit(t *testing.T) {
|
||||
s := rateLimitTestServer(t)
|
||||
const tries = 20
|
||||
calls := slowWrongCompare(s, tries)
|
||||
var wrong, locked int32
|
||||
inParallel(tries, func(i int) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/escrow/start", strings.NewReader(url.Values{"password": {"wrong"}}.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.RemoteAddr = fmt.Sprintf("198.51.100.9:%d", 6000+i)
|
||||
w := httptest.NewRecorder()
|
||||
s.escrowStartAPIHandler(w, r)
|
||||
switch w.Code {
|
||||
case http.StatusUnauthorized:
|
||||
atomic.AddInt32(&wrong, 1)
|
||||
case http.StatusTooManyRequests:
|
||||
atomic.AddInt32(&locked, 1)
|
||||
}
|
||||
})
|
||||
if got := atomic.LoadInt32(calls); got > loginMaxAttempts {
|
||||
t.Fatalf("%d parallel wrong re-auth passwords reached the compare — the limit is %d per visitor", got, loginMaxAttempts)
|
||||
}
|
||||
if wrong+locked != tries {
|
||||
t.Fatalf("every try must be answered as wrong or locked: %d + %d of %d", wrong, locked, tries)
|
||||
}
|
||||
// the re-auth counts on the login counter: the same visitor's dashboard sign-in is locked too
|
||||
s.passwordCompare = nil
|
||||
if body := doLogin(s, "198.51.100.9:7000", "", "correct-pass").Body.String(); !strings.Contains(body, "Túl sok hibás próbálkozás") {
|
||||
t.Fatalf("the re-auth tries must count on the login counter; the sign-in was not locked: %s", ex(body))
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-1112: family.json holds no value a request can use as a family session ────────────────────────────────
|
||||
|
||||
// familyFileStrings is every string in family.json (keys and values, at any depth).
|
||||
func familyFileStrings(t *testing.T, s *Server) []string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(s.cfg.Paths.DataDir, "family.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
var walk func(any)
|
||||
walk = func(v any) {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
out = append(out, x)
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
walk(e)
|
||||
}
|
||||
case map[string]any:
|
||||
for k, e := range x {
|
||||
out = append(out, k)
|
||||
walk(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(v)
|
||||
return out
|
||||
}
|
||||
|
||||
// familyStartMints reports whether /__family/start mints a pass for a browser holding cookie as its family session.
|
||||
func familyStartMints(s *Server, cookie string) bool {
|
||||
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {"https://fapp.example.hu/"}}.Encode(), nil)
|
||||
r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: cookie})
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeFamilyStart(w, r)
|
||||
return strings.Contains(w.Header().Get("Location"), familyCallbackURI)
|
||||
}
|
||||
|
||||
func assertFileHoldsNoSession(t *testing.T, s *Server, cookie string) {
|
||||
t.Helper()
|
||||
strs := familyFileStrings(t, s)
|
||||
if len(strs) < 5 {
|
||||
t.Fatalf("family.json holds too little to judge: %d strings", len(strs))
|
||||
}
|
||||
for _, v := range strs {
|
||||
if v == cookie {
|
||||
t.Fatal("family.json holds the family session's cookie value itself")
|
||||
}
|
||||
if familyStartMints(s, v) {
|
||||
t.Fatalf("a string read from family.json (len %d) minted a pass when presented as the family session", len(v))
|
||||
}
|
||||
}
|
||||
if !familyStartMints(s, cookie) {
|
||||
t.Fatal("the browser's own family session must still mint a pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR1112_FamilyFileHoldsNoUsableSession(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
pw, _ := st.Add("anna")
|
||||
sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie)
|
||||
if sess == nil {
|
||||
t.Fatal("no family session after the sign-in")
|
||||
}
|
||||
assertFileHoldsNoSession(t, s, sess.Value)
|
||||
// and after a restart (the store read back from the file) the cookie still holds
|
||||
st2, err := family.Open(s.cfg.Paths.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := st2.Valid(sess.Value); !ok {
|
||||
t.Fatal("after a reopen the member's family session must still hold")
|
||||
}
|
||||
}
|
||||
|
||||
// A family.json written before R-1112 (sessions stored as their cookie values) is converted on load and rewritten: the
|
||||
// members stay signed in, and the values leave the disk.
|
||||
func TestR1112_OldFamilyFileIsConvertedOnLoad(t *testing.T) {
|
||||
s, _ := familyHarness(t)
|
||||
h, _ := bcrypt.GenerateFromPassword([]byte("anna-pass"), bcrypt.MinCost)
|
||||
member := "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718" // 24 random bytes in hex: the old session id shape
|
||||
household := "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978"
|
||||
exp := time.Now().Add(20 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
old := `{"members":[{"name":"anna","hash":"` + string(h) + `","gen":1,"created":"2026-10-01T00:00:00Z"}],` +
|
||||
`"sessions":[{"id":"` + member + `","member":"anna","gen":1,"exp":"` + exp + `"},` +
|
||||
`{"id":"` + household + `","member":"","gen":0,"exp":"` + exp + `"}]}`
|
||||
path := filepath.Join(s.cfg.Paths.DataDir, "family.json")
|
||||
if err := os.WriteFile(path, []byte(old), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := family.Open(s.cfg.Paths.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.SetCost(bcrypt.MinCost)
|
||||
s.familyStoreOverride = st
|
||||
b, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(b), member) || strings.Contains(string(b), household) {
|
||||
t.Fatal("the old session values are still in family.json after the load")
|
||||
}
|
||||
assertFileHoldsNoSession(t, s, member)
|
||||
// the member's existing sign-in goes on opening the app
|
||||
app := famPass(t, s, &http.Cookie{Name: familySessionCookie, Value: member})
|
||||
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK {
|
||||
t.Fatalf("the converted session's app cookie must pass: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-1113: household passes reuse one family session ─────────────────────────────────────────────────────────
|
||||
|
||||
func familyFileSessions(t *testing.T, s *Server) []map[string]any {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(s.cfg.Paths.DataDir, "family.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
Sessions []map[string]any `json:"sessions"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Sessions
|
||||
}
|
||||
|
||||
func TestR1113_HouseholdPassesLeaveOneSession(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
now := time.Now()
|
||||
st.SetClock(func() time.Time { return now })
|
||||
dash := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}
|
||||
var apps []*http.Cookie
|
||||
for i := 0; i < 6; i++ {
|
||||
apps = append(apps, famPass(t, s, dash))
|
||||
}
|
||||
if got := familyFileSessions(t, s); len(got) != 1 {
|
||||
t.Fatalf("6 household passes left %d family sessions in family.json, want 1", len(got))
|
||||
}
|
||||
for i, app := range apps {
|
||||
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK {
|
||||
t.Fatalf("pass %d: the household's app cookie must pass: %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
// past half its life the session is no longer handed out: a pass starts a fresh one, and the old one keeps the
|
||||
// app cookies already minted from it until it expires — the list holds two at most
|
||||
now = now.Add(family.SessionLife/2 + time.Hour)
|
||||
famPass(t, s, dash)
|
||||
famPass(t, s, dash)
|
||||
if got := familyFileSessions(t, s); len(got) != 2 {
|
||||
t.Fatalf("past half-life: %d household sessions, want 2", len(got))
|
||||
}
|
||||
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", apps[0]); w.Code != http.StatusOK {
|
||||
t.Fatalf("an app cookie minted from the older session must pass until it expires: %d", w.Code)
|
||||
}
|
||||
// once the older one has expired only the newer is live (an expired row leaves the file at the next save)
|
||||
now = now.Add(family.SessionLife / 2)
|
||||
famPass(t, s, dash)
|
||||
live := 0
|
||||
for _, se := range familyFileSessions(t, s) {
|
||||
if exp, err := time.Parse(time.RFC3339Nano, fmt.Sprint(se["exp"])); err == nil && exp.After(now) {
|
||||
live++
|
||||
}
|
||||
}
|
||||
if live != 1 {
|
||||
t.Fatalf("after the older one expired: %d live household sessions, want 1", live)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user