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
gates / gates (push) Successful in 44s
This commit is contained in:
@@ -39,8 +39,10 @@ import (
|
||||
// every request).
|
||||
//
|
||||
// THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets
|
||||
// it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next
|
||||
// request on every app. Pinned by internal/web/family_gate_test.go.
|
||||
// it. An app cookie carries a token derived from a store session for that one app host (familyAppToken, R-976), so a
|
||||
// removed member, a reset password, a logout or a dashboard password change ends access at the next request on every
|
||||
// app, and what an app's backend sees cannot be turned into a pass for another app. Pinned by
|
||||
// internal/web/family_gate_test.go and r983_r976_test.go.
|
||||
|
||||
const (
|
||||
familySessionCookie = "felhom_family"
|
||||
@@ -91,8 +93,10 @@ func (s *Server) familyStore() *family.Store {
|
||||
return s.fam.store
|
||||
}
|
||||
|
||||
// familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both.
|
||||
func (s *Server) familyLocked(visitor, name string) bool {
|
||||
// familyReserve checks whether this visitor or this name is out of tries and, when neither is, counts this try
|
||||
// against both — one step under one lock (R-983, v0.324.0), so parallel tries cannot all pass a check that none of
|
||||
// them has counted yet. Windows slide; a success clears both (familyCleared). It reports whether the try may go ahead.
|
||||
func (s *Server) familyReserve(visitor, name string) bool {
|
||||
s.familyStore()
|
||||
now := s.gateNow()
|
||||
s.fam.mu.Lock()
|
||||
@@ -116,17 +120,14 @@ func (s *Server) familyLocked(visitor, name string) bool {
|
||||
if name != "" {
|
||||
n = prune(s.fam.name, name, familyNameWindow)
|
||||
}
|
||||
return v >= familyVisitorMax || n >= familyNameMax
|
||||
}
|
||||
|
||||
func (s *Server) familyFailed(visitor, name string) {
|
||||
now := s.gateNow()
|
||||
s.fam.mu.Lock()
|
||||
defer s.fam.mu.Unlock()
|
||||
if v >= familyVisitorMax || n >= familyNameMax {
|
||||
return false
|
||||
}
|
||||
s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now)
|
||||
if name != "" {
|
||||
s.fam.name[name] = append(s.fam.name[name], now)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) familyCleared(visitor, name string) {
|
||||
@@ -152,9 +153,28 @@ func (s *Server) familyRDHost(rd string) (string, bool) {
|
||||
return host, true
|
||||
}
|
||||
|
||||
// familyAppToken is what an app host ever sees of a family session (R-976, v0.324.0): a token DERIVED from the
|
||||
// session for that one host, never the session itself. Before, the app cookie carried the session id in clear; every
|
||||
// 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)
|
||||
}
|
||||
|
||||
// familySessionForAppToken finds the session an app token was derived from (still valid, as Store.Valid judges it).
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
type familyToken struct {
|
||||
Host string `json:"h"`
|
||||
Sid string `json:"s"`
|
||||
App string `json:"a"` // the per-app token (familyAppToken), never the session id
|
||||
Exp int64 `json:"e"`
|
||||
Nonce string `json:"n"`
|
||||
RD string `json:"r"`
|
||||
@@ -164,14 +184,14 @@ type familyToken struct {
|
||||
func (s *Server) mintFamilyToken(host, sid, rd string) string {
|
||||
nb := make([]byte, 12)
|
||||
_, _ = rand.Read(nb)
|
||||
t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
|
||||
t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
|
||||
t := familyToken{Host: host, App: s.familyAppToken(host, sid), 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)
|
||||
}
|
||||
|
||||
// takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going.
|
||||
func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
|
||||
// takeFamilyToken checks a token for host, uses it up, and returns the per-app token and where the browser was going.
|
||||
func (s *Server) takeFamilyToken(raw, host string) (at, rd string, err error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", "", errors.New("malformed")
|
||||
@@ -180,7 +200,7 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
|
||||
if json.Unmarshal(b, &t) != nil {
|
||||
return "", "", errors.New("malformed")
|
||||
}
|
||||
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
|
||||
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
|
||||
return "", "", errors.New("bad signature")
|
||||
}
|
||||
if t.Host != host {
|
||||
@@ -202,11 +222,13 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
|
||||
return "", "", errors.New("already used")
|
||||
}
|
||||
s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
|
||||
return t.Sid, t.RD, nil
|
||||
return t.App, t.RD, nil
|
||||
}
|
||||
|
||||
// familyAppCookieSession: "<sid>.<unix expiry>.<mac over host+sid+expiry>" → the session, if the cookie is genuine for
|
||||
// this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here).
|
||||
// familyAppCookieSession: "<app token>.<unix expiry>.<mac over host+token+expiry>" → the session, if the cookie is
|
||||
// genuine for this host, unexpired, and the store still holds the session the token was derived from (a removed
|
||||
// member / reset / logout / dashboard password change fails here). A cookie of the old "<session id>.…" shape is
|
||||
// refused: the browser goes through the sign-in start once more and gets the new shape.
|
||||
func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) {
|
||||
c, err := r.Cookie(familyAppCookie)
|
||||
if err != nil {
|
||||
@@ -223,7 +245,7 @@ func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Se
|
||||
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) {
|
||||
return family.Session{}, false
|
||||
}
|
||||
return s.familyStore().Valid(parts[0])
|
||||
return s.familySessionForAppToken(host, parts[0])
|
||||
}
|
||||
|
||||
func familyRefuse(w http.ResponseWriter, code int) {
|
||||
@@ -256,9 +278,9 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI {
|
||||
sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
|
||||
at, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
|
||||
if err == nil {
|
||||
if _, ok := s.familyStore().Valid(sid); !ok {
|
||||
if _, ok := s.familySessionForAppToken(host, at); !ok {
|
||||
err = errors.New("the session ended")
|
||||
}
|
||||
}
|
||||
@@ -269,7 +291,7 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/",
|
||||
Name: familyAppCookie, Value: at + "." + exp + "." + s.gateMAC("famcookie", host, at, exp), Path: "/",
|
||||
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r))
|
||||
@@ -356,14 +378,16 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
||||
visitor := rateKey(r)
|
||||
if s.familyLocked(visitor, name) {
|
||||
// R-983 (v0.324.0): the try is counted BEFORE the password is checked, in the same step as the lock check, so
|
||||
// parallel tries cannot all pass a check none of them has counted yet. A right password clears both counts.
|
||||
// Pinned by TestR983_ParallelFamilySignInsHitTheLimit.
|
||||
if !s.familyReserve(visitor, name) {
|
||||
s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name)
|
||||
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
st := s.familyStore()
|
||||
if st == nil || !st.Verify(name, r.FormValue("password")) {
|
||||
s.familyFailed(visitor, name)
|
||||
s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor)
|
||||
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized)
|
||||
return
|
||||
@@ -385,12 +409,30 @@ 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.
|
||||
func (s *Server) familyLogoutToken(sid string) string {
|
||||
return s.gateMAC("famlogout", sid)
|
||||
}
|
||||
|
||||
// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it.
|
||||
// R-983 (v0.324.0): only a POST from the sign-out form (familygate.html), carrying familyLogoutToken. Anything else —
|
||||
// a GET, or a form another page built — changes nothing and shows the family page. Pinned by
|
||||
// TestR983_FamilyLogoutNeedsItsToken.
|
||||
func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(familySessionCookie); err == nil {
|
||||
if err := s.familyStore().EndSession(c.Value); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
|
||||
}
|
||||
c, err := r.Cookie(familySessionCookie)
|
||||
if r.Method != http.MethodPost || err != nil {
|
||||
s.renderFamilyPage(w, r, "", "", http.StatusOK)
|
||||
return
|
||||
}
|
||||
if !hmac.Equal([]byte(r.FormValue("_ft")), []byte(s.familyLogoutToken(c.Value))) {
|
||||
s.logger.Printf("[WARN] [web] family sign-out refused: the form's token does not match the session — visitor %s", clientIP(r))
|
||||
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := s.familyStore().EndSession(c.Value); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true})
|
||||
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK)
|
||||
@@ -406,8 +448,9 @@ func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, no
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := s.familySessionFromCookie(r); ok {
|
||||
if se, ok := s.familySessionFromCookie(r); ok {
|
||||
data["SignedIn"] = true
|
||||
data["LogoutToken"] = s.familyLogoutToken(se.ID)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
Reference in New Issue
Block a user