package web import ( "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" "net" "net/http" "strings" "sync" "time" ) // authTokenCookieName is the name of the cookie carrying the per-start auth token. const authTokenCookieName = "mpv_manager_token" // contentSecurityPolicy keeps executable code same-origin and blocks inline // script elements/event attributes. Alpine and htmx currently compile their // declarative expressions, so unsafe-eval remains narrowly required until the // shared frontend adopts their CSP-compatible runtime. const contentSecurityPolicy = "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; media-src 'none'; worker-src 'none'; manifest-src 'self'" // Rate limiting for sensitive endpoints (e.g. /api/keyring/auth) const ( rateLimitMaxAttempts = 5 rateLimitWindow = 60 * time.Second ) func securityMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", contentSecurityPolicy) next.ServeHTTP(w, r) }) } // generateAuthToken generates a cryptographically random 32-byte hex token. // A new token is generated on every server start and gates all /api/* requests. func generateAuthToken() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic(fmt.Sprintf("failed to generate auth token: %v", err)) } return hex.EncodeToString(b) } // authMiddleware guards /api/* routes with the per-start auth token and sets // the token cookie on page/static GET responses. // // For every request it first enforces a local Host header (DNS re-binding // protection). For /api/* requests it then enforces, in order: // 1. OPTIONS requests are rejected (the same-origin UI never preflights). // 2. Origin check: a present Origin header must exactly match the request's // scheme://Host. // 3. The auth token cookie must match the server token (constant-time compare). func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !isAllowedLocalHost(r.Host) { if strings.HasPrefix(r.URL.Path, "/api/") { sendJSONError(w, http.StatusForbidden, "Forbidden host") } else { http.Error(w, "Forbidden host", http.StatusForbidden) } return } if strings.HasPrefix(r.URL.Path, "/api/") { if r.Method == http.MethodOptions { sendJSONError(w, http.StatusForbidden, "Forbidden") return } if origin := r.Header.Get("Origin"); origin != "" { scheme := "http" if r.TLS != nil { scheme = "https" } if origin != scheme+"://"+r.Host { sendJSONError(w, http.StatusForbidden, "Origin mismatch") return } } cookie, err := r.Cookie(authTokenCookieName) if err != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(s.authToken)) != 1 { sendJSONError(w, http.StatusUnauthorized, "Unauthorized") return } next.ServeHTTP(w, r) return } // Page/static GET handlers always (re)set the auth token cookie. if r.Method == http.MethodGet || r.Method == http.MethodHead { http.SetCookie(w, &http.Cookie{ Name: authTokenCookieName, Value: s.authToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, }) } next.ServeHTTP(w, r) }) } // isAllowedLocalHost reports whether a request Host header refers to the local // machine. The port and IPv6 brackets are stripped before comparing. func isAllowedLocalHost(hostport string) bool { host := hostport if h, _, err := net.SplitHostPort(hostport); err == nil { host = h } else { // No port present; strip IPv6 brackets if any host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") } return isLoopbackHost(host) } // rateLimiter is a simple in-memory sliding-window rate limiter. type rateLimiter struct { mu sync.Mutex attempts []time.Time } // allow records an attempt and reports whether it is within the limit of // max attempts per window. func (l *rateLimiter) allow(max int, window time.Duration) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() cutoff := now.Add(-window) kept := l.attempts[:0] for _, t := range l.attempts { if t.After(cutoff) { kept = append(kept, t) } } l.attempts = kept if len(l.attempts) >= max { return false } l.attempts = append(l.attempts, now) return true }