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:///s/ 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/). // - 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 as the // admin login, copied — 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() } // 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) }