v0.165.0: Indítópult megosztása — guest launcher via capability URL (+ optional password, QR)

Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest
launcher: same tiles, opens apps in new tabs, no account, no admin session.
Information only, zero control — every privilege stays behind each app's own auth.

- /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest
  password POST carries its own pre-auth HMAC CSRF.
- Constant-time token match; empty stored token = disabled = byte-identical mux 404.
- Optional per-share password: separate bcrypt hash + own attempt map; signed cookie
  = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates.
- Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary.
- Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT.
- Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable.
- Tests: Groups A-G (14) + 3 red-proofs verified red.
This commit is contained in:
2026-07-24 12:08:43 +02:00
parent 8e5edb2865
commit 15206314ab
19 changed files with 1341 additions and 115 deletions
+147
View File
@@ -0,0 +1,147 @@
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 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)
}