diff --git a/internal/worker/middleware.go b/internal/worker/middleware.go index 4c6fee2..27ac0d7 100644 --- a/internal/worker/middleware.go +++ b/internal/worker/middleware.go @@ -4,6 +4,7 @@ package worker import ( "context" "crypto/rand" + "crypto/subtle" "encoding/hex" "fmt" "net/http" @@ -121,6 +122,22 @@ func NewTokenAuth(enabled bool) (*TokenAuth, error) { return ta, nil } +// NewTokenAuthWithToken creates a TokenAuth using an explicitly supplied token. +// It is used to enforce a caller-provided token (e.g. from an environment +// variable) so the value is known to clients, unlike the random token produced +// by NewTokenAuth. Authentication is enabled only when token is non-empty. +func NewTokenAuthWithToken(token string) *TokenAuth { + return &TokenAuth{ + enabled: token != "", + token: token, + ExemptPaths: map[string]bool{ + "/health": true, + "/api/health": true, + "/api/ready": true, + }, + } +} + // Token returns the authentication token. // Returns empty string if authentication is disabled. func (ta *TokenAuth) Token() string { @@ -161,7 +178,8 @@ func (ta *TokenAuth) Middleware(next http.Handler) http.Handler { } } - if providedToken != token { + // Constant-time comparison to avoid leaking the token via timing. + if subtle.ConstantTimeCompare([]byte(providedToken), []byte(token)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/internal/worker/service.go b/internal/worker/service.go index 875b0d7..a9a11ee 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -156,7 +156,6 @@ type Service struct { updater *update.Updater rateLimiter *PerClientRateLimiter expensiveOpLimiter *ExpensiveOperationLimiter - contextCache sync.Map version string recentQueriesBuf [maxRecentQueries]RecentSearchQuery wg sync.WaitGroup @@ -197,13 +196,6 @@ type staleVerifyRequest struct { observationID int64 } -// contextCacheEntry caches clustering results for context injection. -type contextCacheEntry struct { - timestamp time.Time - observations []*models.Observation - obsCount int -} - // RecentSearchQuery tracks a search query for analytics. type RecentSearchQuery struct { Timestamp time.Time `json:"timestamp"` @@ -1240,6 +1232,19 @@ func (s *Service) setupMiddleware() { s.router.Use(PerClientRateLimitMiddleware(s.rateLimiter)) } + // Token authentication: opt-in and default-preserving. + // When CLAUDE_MNEMONIC_AUTH_TOKEN is set, every request (except exempt + // health/ready paths) must present that token via X-Auth-Token or + // "Authorization: Bearer". When the env var is unset (the default), no + // token is configured, auth stays disabled, and the server starts as + // before. The worker binds loopback only, so this is defense-in-depth for + // shared/multi-user hosts rather than a hard requirement. + if authToken := os.Getenv("CLAUDE_MNEMONIC_AUTH_TOKEN"); authToken != "" { + ta := NewTokenAuthWithToken(authToken) + s.router.Use(ta.Middleware) + log.Info().Msg("Token authentication enabled (CLAUDE_MNEMONIC_AUTH_TOKEN set)") + } + // Note: Timeout middleware is applied per-route, not globally, // to avoid killing SSE connections which need to stay open indefinitely } @@ -1588,7 +1593,8 @@ func (s *Service) Start() error { port := config.GetWorkerPort() s.server = &http.Server{ - Addr: fmt.Sprintf(":%d", port), + // Bind loopback only so the worker is never exposed on external interfaces. + Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: s.router, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second,