v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s

This commit is contained in:
2026-10-16 22:46:00 +02:00
parent 00a0d91976
commit f75b3aa778
212 changed files with 2414 additions and 398 deletions
+51 -2
View File
@@ -397,7 +397,7 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
}
}
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
s.endEverySession("claim/reset") // reset: kill old sessions — dashboard, family, gate passes (R-976); first-claim: none exist
action := "claimed"
if wasReset {
@@ -428,10 +428,59 @@ func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Reques
s.handleClaimPage(w, r, s.msg(r, "claim.msg.invalid_form"), "")
return
}
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
// R-983 (v0.324.0): a request reaches the hub only inside the box's own limit. Each call mints a fresh code that
// replaces the one the household may be typing, and mails the household — so without a limit a stranger could do
// both at will. The answer stays the same neutral page either way (it never revealed the outcome).
if ok, why := s.allowResetRequest(rateKey(r)); !ok {
s.logger.Printf("[WARN] [web] claim: a reset-code request was not passed to the hub (%s) — visitor %s", why, rateKey(r))
} else if s.resetRequestFn != nil {
s.resetRequestFn()
} else {
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
}
s.handleClaimPage(w, r, "", s.msg(r, "claim.msg.code_sent"))
}
// The reset-code request's limit (R-983): one hub call per visitor per resetReqVisitorEvery, and at most
// resetReqGlobalMax for the whole box in any resetReqGlobalWindow. The worst a stranger can do is hold the box's
// budget for one window — the household's code already sent stays valid meanwhile, because nothing replaces it.
const (
resetReqVisitorEvery = time.Minute
resetReqGlobalMax = 3
resetReqGlobalWindow = 15 * time.Minute
)
// allowResetRequest decides and counts in one step (under claimMu), so parallel requests cannot all pass.
func (s *Server) allowResetRequest(visitor string) (bool, string) {
s.claimMu.Lock()
defer s.claimMu.Unlock()
now := s.claimNow()
if s.resetReqVisitor == nil {
s.resetReqVisitor = map[string]time.Time{}
}
for k, at := range s.resetReqVisitor {
if now.Sub(at) >= resetReqVisitorEvery {
delete(s.resetReqVisitor, k)
}
}
keep := s.resetReqGlobal[:0]
for _, at := range s.resetReqGlobal {
if now.Sub(at) < resetReqGlobalWindow {
keep = append(keep, at)
}
}
s.resetReqGlobal = keep
if _, seen := s.resetReqVisitor[visitor]; seen {
return false, "this visitor asked less than a minute ago"
}
if len(s.resetReqGlobal) >= resetReqGlobalMax {
return false, "the box's limit for this quarter hour is used"
}
s.resetReqVisitor[visitor] = now
s.resetReqGlobal = append(s.resetReqGlobal, now)
return true, ""
}
// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key.
// v0.123.0 (take-two F-15): a hub ≥0.52.0 returns the freshly rotated code state in the response;
// it is applied through the SAME generation-guarded consumer as the report ACK (ClaimSync), so the