94f44931dd
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
128 lines
5.6 KiB
Go
128 lines
5.6 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// Guest launcher share (v0.165.0). The "Indítópult megosztása" capability link: the admin mints a
|
|
// ≥160-bit random token; https://<host>/s/<token> then serves a standalone, read-only guest launcher
|
|
// with NO account and NO admin session. The link grants INFORMATION ONLY — app names + public URLs;
|
|
// every privilege stays behind each app's own auth and the controller admin password.
|
|
//
|
|
// SECURITY MODEL:
|
|
// - The token IS the secret. 160 bits of entropy is the whole defence for the token GET — the path
|
|
// is never rate-limited or CAPTCHA'd, and the value is never logged (redacted as /s/<redacted>).
|
|
// - Constant-time comparison only (shareTokenMatches); an empty stored token matches nothing, so
|
|
// "sharing disabled" and "wrong token" are indistinguishable from any unknown route (Scenario B).
|
|
// - The OPTIONAL per-share password is a SEPARATE credential (its own bcrypt hash, its own attempt
|
|
// map). Passing it once mints a signed cookie bound to token|passwordHash, so rotating the token
|
|
// OR changing the password invalidates every outstanding cookie with zero bookkeeping.
|
|
|
|
const (
|
|
shareCookieName = "felhom_share" // the signed guest gate cookie (password shares only)
|
|
shareCSRFCookie = "felhom_share_csrf" // pre-auth HMAC CSRF for the guest password POST
|
|
shareCookieMaxAge = 30 * 24 * time.Hour
|
|
)
|
|
|
|
// newShareToken returns a fresh ≥160-bit capability token: 20 random bytes (160 bits) as
|
|
// base64.RawURLEncoding (27 URL-safe chars, no padding).
|
|
func newShareToken() (string, error) {
|
|
b := make([]byte, 20)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// shareTokenMatches reports whether the presented token equals the stored one, in constant time. An
|
|
// empty stored token never matches (sharing disabled ⇒ every /s/ path is a 404). ConstantTimeCompare
|
|
// returns 0 for differing lengths, so no length oracle leaks.
|
|
func shareTokenMatches(stored, presented string) bool {
|
|
if stored == "" {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
|
|
}
|
|
|
|
// shareCookieValue is the signed guest gate cookie: HMAC-SHA256 over token|passwordHash, keyed with
|
|
// the box's persisted, box-scoped web.session_secret (the SAME secret the claim pre-auth CSRF already
|
|
// trusts — reusing it introduces no new assumption; it is stable across restarts, not per-boot and
|
|
// not claim-generation-scoped, so it fits the reuse branch of the Part-2 decision rule). Binding the
|
|
// share-password hash into the MAC means a password change invalidates the cookie; binding the token
|
|
// means a rotation does too.
|
|
func (s *Server) shareCookieValue(token, passwordHash string) string {
|
|
mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret))
|
|
mac.Write([]byte("felhom-share-cookie-v1|" + token + "|" + passwordHash))
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// shareCookieValid reports whether the request carries a valid gate cookie for this token+hash.
|
|
func (s *Server) shareCookieValid(r *http.Request, token, passwordHash string) bool {
|
|
c, err := r.Cookie(shareCookieName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
want := s.shareCookieValue(token, passwordHash)
|
|
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1
|
|
}
|
|
|
|
// ── pre-auth CSRF for the guest password form (mirrors the claim pre-auth HMAC pattern: the guest
|
|
// has no session, so the session-CSRF path cannot apply) ─────────────────────────────────────────
|
|
|
|
func (s *Server) shareCSRFToken() string {
|
|
mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret))
|
|
mac.Write([]byte("felhom-share-csrf-v1"))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (s *Server) setShareCSRFCookie(w http.ResponseWriter, r *http.Request) string {
|
|
tok := s.shareCSRFToken()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: shareCSRFCookie,
|
|
Value: tok,
|
|
Path: "/s/",
|
|
HttpOnly: false, // read back only by the form on the same page; SameSite blocks cross-site
|
|
SameSite: http.SameSiteStrictMode,
|
|
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
|
MaxAge: int(shareCookieMaxAge.Seconds()),
|
|
})
|
|
return tok
|
|
}
|
|
|
|
func (s *Server) validShareCSRF(r *http.Request) bool {
|
|
want := s.shareCSRFToken()
|
|
if subtle.ConstantTimeCompare([]byte(r.FormValue(csrfFormField)), []byte(want)) != 1 {
|
|
return false
|
|
}
|
|
c, err := r.Cookie(shareCSRFCookie)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
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 and the same
|
|
// reserveAttempt body as the admin login — the counter is never shared) ──────────────────────────
|
|
|
|
// 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.
|
|
func (s *Server) shareClearFailures(ip string) {
|
|
s.shareAttemptMu.Lock()
|
|
defer s.shareAttemptMu.Unlock()
|
|
delete(s.shareAttempts, ip)
|
|
}
|