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:
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -218,20 +219,28 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it
|
||||
// may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a
|
||||
// check that none of them has counted yet. A window that has passed starts clean.
|
||||
// check that none of them has counted yet. A window that has passed starts clean. The escrow wizard's re-auth uses it
|
||||
// too (the same counter: a wrong password is a wrong password, wherever it was typed).
|
||||
func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
|
||||
s.loginAttemptMu.Lock()
|
||||
defer s.loginAttemptMu.Unlock()
|
||||
if s.loginAttempts == nil {
|
||||
s.loginAttempts = map[string]*loginAttempt{}
|
||||
return reserveAttempt(&s.loginAttemptMu, &s.loginAttempts, ip)
|
||||
}
|
||||
|
||||
// reserveAttempt is the one body of the 5-per-minute password limiters: check and count under mu, in one step. Each
|
||||
// limiter keeps its OWN map (the share password's is not the dashboard's, so a guest and the owner never share a
|
||||
// counter); the map is allocated lazily, so struct-literal test servers that skip NewServer still work.
|
||||
func reserveAttempt(mu *sync.Mutex, attempts *map[string]*loginAttempt, key string) (int, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if *attempts == nil {
|
||||
*attempts = map[string]*loginAttempt{}
|
||||
}
|
||||
a := s.loginAttempts[ip]
|
||||
a := (*attempts)[key]
|
||||
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
|
||||
a = nil
|
||||
}
|
||||
if a == nil {
|
||||
a = &loginAttempt{}
|
||||
s.loginAttempts[ip] = a
|
||||
(*attempts)[key] = a
|
||||
}
|
||||
if a.count >= loginMaxAttempts {
|
||||
return a.count, false
|
||||
@@ -241,7 +250,8 @@ func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
|
||||
return a.count, true
|
||||
}
|
||||
|
||||
// comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable).
|
||||
// comparePassword is bcrypt's compare for the dashboard sign-in, the escrow re-auth and the share password;
|
||||
// passwordCompare replaces it in tests (a slow compare makes a race measurable).
|
||||
func (s *Server) comparePassword(hash, pw []byte) error {
|
||||
if s.passwordCompare != nil {
|
||||
return s.passwordCompare(hash, pw)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Controller-driven escrow ceremony wizard (v0.127.0, agent ≥ v0.88.0; mechanics validated by
|
||||
@@ -185,14 +184,15 @@ func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ip := rateKey(r)
|
||||
if s.escrowRateLimited(ip) {
|
||||
// R-1111: the try is reserved on the login counter BEFORE the slow compare (the R-983 shape); a right password
|
||||
// clears it below. Pinned by TestR1111_ParallelEscrowReauthsHitTheLimit.
|
||||
if _, ok := s.reserveLoginAttempt(ip); !ok {
|
||||
s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip)
|
||||
escrowJSON(w, http.StatusTooManyRequests, nil, s.msg(r, "api.escrow.too_many_attempts"))
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))); err != nil {
|
||||
if err := s.comparePassword([]byte(hash), []byte(r.FormValue("password"))); err != nil {
|
||||
s.logger.Printf("[WARN] [web] escrow start: failed re-auth from %s", r.RemoteAddr)
|
||||
s.recordEscrowAuthFailure(ip)
|
||||
escrowJSON(w, http.StatusUnauthorized, nil, "Hibás jelszó.")
|
||||
return
|
||||
}
|
||||
@@ -313,33 +313,8 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = code
|
||||
}
|
||||
|
||||
// escrowRateLimited / recordEscrowAuthFailure / clearAuthFailures ride the SAME per-IP counter as
|
||||
// the login form (loginAttempts, loginMaxAttempts, loginWindowDuration) — a wrong wizard password
|
||||
// is a wrong password, wherever it was typed.
|
||||
func (s *Server) escrowRateLimited(ip string) bool {
|
||||
s.loginAttemptMu.Lock()
|
||||
defer s.loginAttemptMu.Unlock()
|
||||
attempt := s.loginAttempts[ip]
|
||||
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
|
||||
delete(s.loginAttempts, ip)
|
||||
return false
|
||||
}
|
||||
return attempt != nil && attempt.count >= loginMaxAttempts
|
||||
}
|
||||
|
||||
func (s *Server) recordEscrowAuthFailure(ip string) {
|
||||
s.loginAttemptMu.Lock()
|
||||
defer s.loginAttemptMu.Unlock()
|
||||
if s.loginAttempts == nil {
|
||||
s.loginAttempts = map[string]*loginAttempt{}
|
||||
}
|
||||
if s.loginAttempts[ip] == nil {
|
||||
s.loginAttempts[ip] = &loginAttempt{}
|
||||
}
|
||||
s.loginAttempts[ip].count++
|
||||
s.loginAttempts[ip].lastFail = time.Now()
|
||||
}
|
||||
|
||||
// clearAuthFailures clears the login counter after a right re-auth password — the counter reserveLoginAttempt counts
|
||||
// on, shared with the login form (a wrong wizard password is a wrong password, wherever it was typed).
|
||||
func (s *Server) clearAuthFailures(ip string) {
|
||||
s.loginAttemptMu.Lock()
|
||||
defer s.loginAttemptMu.Unlock()
|
||||
|
||||
@@ -158,8 +158,10 @@ func (s *Server) familyRDHost(rd string) (string, bool) {
|
||||
// family-gated app's backend receives its cookies, and a session id lifted from one app, set as the family session
|
||||
// cookie on the dashboard host, minted passes into EVERY family app for 30 days. A derived token cannot be turned back
|
||||
// into the session, and it opens only its own host. Pinned by TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp.
|
||||
func (s *Server) familyAppToken(host, sid string) string {
|
||||
return s.gateMAC("famapp", host, sid)
|
||||
// R-1112: it is derived from the session's FINGERPRINT (fp) — all the store keeps — so app cookies minted before
|
||||
// R-1112 (derived from the value) are refused once and the browser passes through the sign-in start again.
|
||||
func (s *Server) familyAppToken(host, fp string) string {
|
||||
return s.gateMAC("famapp", host, fp)
|
||||
}
|
||||
|
||||
// familySessionForAppToken finds the session an app token was derived from (still valid, as Store.Valid judges it).
|
||||
@@ -167,8 +169,8 @@ func (s *Server) familySessionForAppToken(host, at string) (family.Session, bool
|
||||
if at == "" {
|
||||
return family.Session{}, false
|
||||
}
|
||||
return s.familyStore().Find(func(id string) bool {
|
||||
return hmac.Equal([]byte(s.familyAppToken(host, id)), []byte(at))
|
||||
return s.familyStore().Find(func(fp string) bool {
|
||||
return hmac.Equal([]byte(s.familyAppToken(host, fp)), []byte(at))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,10 +183,10 @@ type familyToken struct {
|
||||
MAC string `json:"m"`
|
||||
}
|
||||
|
||||
func (s *Server) mintFamilyToken(host, sid, rd string) string {
|
||||
func (s *Server) mintFamilyToken(host, fp, rd string) string {
|
||||
nb := make([]byte, 12)
|
||||
_, _ = rand.Read(nb)
|
||||
t := familyToken{Host: host, App: s.familyAppToken(host, sid), Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
|
||||
t := familyToken{Host: host, App: s.familyAppToken(host, fp), Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
|
||||
t.MAC = s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
|
||||
b, _ := json.Marshal(t)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
@@ -334,16 +336,18 @@ func (s *Server) ServeFamilyStart(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
se, ok := s.familySessionFromCookie(r)
|
||||
if !ok && s.hasSession(r) {
|
||||
// The household's own dashboard session vouches (decision 46's rule) — a household session in the family store,
|
||||
// never the dashboard session itself.
|
||||
// The household's own dashboard session vouches (decision 46's rule) — the household's session in the family
|
||||
// store, never the dashboard session itself. R-1113: its live one, not a new one on every pass.
|
||||
if st := s.familyStore(); st != nil {
|
||||
if sid, err := st.NewSession(""); err == nil {
|
||||
se, ok = family.Session{ID: sid}, true
|
||||
if hs, err := st.HouseholdSession(); err == nil {
|
||||
se, ok = hs, true
|
||||
} else {
|
||||
s.logger.Printf("[ERROR] [web] family start: the household's family session could not be saved: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.ID, rd)}}.Encode(), http.StatusFound)
|
||||
http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.FP, rd)}}.Encode(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderFamilyPage(w, r, rd, "", http.StatusOK)
|
||||
@@ -409,9 +413,9 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK)
|
||||
}
|
||||
|
||||
// familyLogoutToken is the sign-out form's token, bound to the family session it ends (R-983, v0.324.0). The sign-in
|
||||
// form's token (familyFormToken) is the same for every visitor, so a stranger could fetch one and use it; this one
|
||||
// names the session, which another page never sees.
|
||||
// familyLogoutToken is the sign-out form's token, bound to the family session it ends (R-983, v0.324.0) — to the
|
||||
// cookie value the browser holds (sid). The sign-in form's token (familyFormToken) is the same for every visitor, so a
|
||||
// stranger could fetch one and use it; this one names the session, which another page never sees.
|
||||
func (s *Server) familyLogoutToken(sid string) string {
|
||||
return s.gateMAC("famlogout", sid)
|
||||
}
|
||||
@@ -448,9 +452,10 @@ func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, no
|
||||
}
|
||||
}
|
||||
}
|
||||
if se, ok := s.familySessionFromCookie(r); ok {
|
||||
if _, ok := s.familySessionFromCookie(r); ok {
|
||||
c, _ := r.Cookie(familySessionCookie) // present: familySessionFromCookie found it
|
||||
data["SignedIn"] = true
|
||||
data["LogoutToken"] = s.familyLogoutToken(se.ID)
|
||||
data["LogoutToken"] = s.familyLogoutToken(c.Value)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
@@ -287,7 +287,7 @@ 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/")
|
||||
tok := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(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")
|
||||
}
|
||||
@@ -297,7 +297,11 @@ func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) {
|
||||
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/")
|
||||
tok2 := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(sid), "https://fapp.example.hu/")
|
||||
tok3 := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(sid), "https://fapp.example.hu/")
|
||||
if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok3, "text/html"); w.Code != http.StatusFound {
|
||||
t.Fatalf("a token whose session holds must pass: %d", w.Code)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -3427,11 +3427,13 @@ func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
|
||||
}
|
||||
// Try to get data size from the storage subdirectory. F-S2: the app's real appdata dir name is
|
||||
// NOT always the stack name (paperless-ngx writes appdata/paperless) — sum the resolved dir(s).
|
||||
// Here hddPath == storagePath (the drive's in-guest mount is the namespace root, Model A).
|
||||
// storagePath is a DRIVE path; through the resolver (R-1110). On an enrolled drive that is the path
|
||||
// itself (Model A), on the system-data fallback it adds felhom-data — the only correct root either way.
|
||||
nsRoot := appbackup.NamespaceRootFor(storagePath, s.cfg.Paths.SystemDataPath)
|
||||
var total int64
|
||||
var any bool
|
||||
for _, name := range s.stackMgr.ResolveAppDataDirNames(stack.Name) {
|
||||
d := backup.AppDataDir(storagePath, name)
|
||||
d := backup.AppDataDir(nsRoot, name)
|
||||
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||
total += dirSizeBytesWalk(d)
|
||||
any = true
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
||||
)
|
||||
|
||||
// R-983 (security review 2026-10-12, six remaining items) and R-976 (family and gate sessions), v0.324.0. Each test
|
||||
@@ -348,8 +350,8 @@ func TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp(t *testing.T) {
|
||||
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) {
|
||||
tok := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(sess.Value), rd)
|
||||
if dec, _ := base64.RawURLEncoding.DecodeString(tok); strings.Contains(string(dec), sess.Value) || strings.Contains(string(dec), family.Fingerprint(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
|
||||
|
||||
@@ -74,8 +74,8 @@ type Server struct {
|
||||
sessionsMu sync.RWMutex
|
||||
loginAttempts map[string]*loginAttempt
|
||||
loginAttemptMu sync.Mutex
|
||||
// passwordCompare replaces bcrypt's compare at the dashboard sign-in in tests (R-983: a slow compare makes the
|
||||
// parallel-try race measurable). nil → bcrypt.
|
||||
// passwordCompare replaces bcrypt's compare at the dashboard sign-in, the escrow re-auth and the share password in
|
||||
// tests (R-983, R-1111: a slow compare makes the parallel-try race measurable). nil → bcrypt.
|
||||
passwordCompare func(hash, pw []byte) error
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
@@ -108,35 +108,15 @@ func (s *Server) validShareCSRF(r *http.Request) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1
|
||||
}
|
||||
|
||||
// ── the share-password brute-force limiter (own per-IP map; same 5-attempts / 1-min window as the
|
||||
// admin login, copied — never shared) ─────────────────────────────────────────────────────────────
|
||||
// ── the share-password brute-force limiter (own per-IP map; same 5-attempts / 1-min window and the same
|
||||
// reserveAttempt body as the admin login — the counter is never shared) ──────────────────────────
|
||||
|
||||
// shareRateLimited reports whether this IP is currently blocked (>= loginMaxAttempts within the
|
||||
// window). An expired window resets the counter first, so a fresh burst starts clean.
|
||||
func (s *Server) shareRateLimited(ip string) bool {
|
||||
s.shareAttemptMu.Lock()
|
||||
defer s.shareAttemptMu.Unlock()
|
||||
a := s.shareAttempts[ip]
|
||||
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
|
||||
delete(s.shareAttempts, ip)
|
||||
a = nil
|
||||
}
|
||||
return a != nil && a.count >= loginMaxAttempts
|
||||
}
|
||||
|
||||
// shareRegisterFailure bumps this IP's failed-attempt counter (lazily allocating the map so
|
||||
// struct-literal test servers that skip NewServer still work).
|
||||
func (s *Server) shareRegisterFailure(ip string) {
|
||||
s.shareAttemptMu.Lock()
|
||||
defer s.shareAttemptMu.Unlock()
|
||||
if s.shareAttempts == nil {
|
||||
s.shareAttempts = make(map[string]*loginAttempt)
|
||||
}
|
||||
if s.shareAttempts[ip] == nil {
|
||||
s.shareAttempts[ip] = &loginAttempt{}
|
||||
}
|
||||
s.shareAttempts[ip].count++
|
||||
s.shareAttempts[ip].lastFail = time.Now()
|
||||
// shareReserve counts one share-password try for this visitor BEFORE the password is compared and reports whether it
|
||||
// may go ahead — check and count in one locked step (reserveAttempt, R-1111), so parallel tries cannot all pass a
|
||||
// check that none of them has counted yet. A right password clears the count (shareClearFailures).
|
||||
func (s *Server) shareReserve(ip string) bool {
|
||||
_, ok := reserveAttempt(&s.shareAttemptMu, &s.shareAttempts, ip)
|
||||
return ok
|
||||
}
|
||||
|
||||
// shareClearFailures clears this IP's counter after a successful gate pass.
|
||||
|
||||
@@ -114,13 +114,14 @@ func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
ip := rateKey(r)
|
||||
if s.shareRateLimited(ip) {
|
||||
// R-1111: the try is reserved BEFORE the slow compare (the R-983 shape). Before, it was counted only after a
|
||||
// wrong password, so N parallel tries all passed the check. Pinned by TestR1111_ParallelSharePasswordsHitTheLimit.
|
||||
if !s.shareReserve(ip) {
|
||||
s.logger.Printf("[WARN] [web] share password rate limited for %s", ip)
|
||||
s.renderSharePasswordPage(w, r, s.msg(r, "api.share.too_many_attempts"))
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil {
|
||||
s.shareRegisterFailure(ip)
|
||||
if s.comparePassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil {
|
||||
s.renderSharePasswordPage(w, r, s.msg(r, "flash.share.wrong_password")) // R-577: in the visitor's language
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user