mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
fix(worker): harden HTTP server (loopback bind, token auth, constant-time compare)
Bind 127.0.0.1 instead of all interfaces; wire the previously-unused TokenAuth middleware behind opt-in CLAUDE_MNEMONIC_AUTH_TOKEN (unset = unauthenticated, default-preserving); compare tokens with subtle.ConstantTimeCompare. Also drops the dead contextCache field/type (zero readers).
This commit is contained in:
@@ -4,6 +4,7 @@ package worker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -121,6 +122,22 @@ func NewTokenAuth(enabled bool) (*TokenAuth, error) {
|
|||||||
return ta, nil
|
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.
|
// Token returns the authentication token.
|
||||||
// Returns empty string if authentication is disabled.
|
// Returns empty string if authentication is disabled.
|
||||||
func (ta *TokenAuth) Token() string {
|
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)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ type Service struct {
|
|||||||
updater *update.Updater
|
updater *update.Updater
|
||||||
rateLimiter *PerClientRateLimiter
|
rateLimiter *PerClientRateLimiter
|
||||||
expensiveOpLimiter *ExpensiveOperationLimiter
|
expensiveOpLimiter *ExpensiveOperationLimiter
|
||||||
contextCache sync.Map
|
|
||||||
version string
|
version string
|
||||||
recentQueriesBuf [maxRecentQueries]RecentSearchQuery
|
recentQueriesBuf [maxRecentQueries]RecentSearchQuery
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -197,13 +196,6 @@ type staleVerifyRequest struct {
|
|||||||
observationID int64
|
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.
|
// RecentSearchQuery tracks a search query for analytics.
|
||||||
type RecentSearchQuery struct {
|
type RecentSearchQuery struct {
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
@@ -1240,6 +1232,19 @@ func (s *Service) setupMiddleware() {
|
|||||||
s.router.Use(PerClientRateLimitMiddleware(s.rateLimiter))
|
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,
|
// Note: Timeout middleware is applied per-route, not globally,
|
||||||
// to avoid killing SSE connections which need to stay open indefinitely
|
// to avoid killing SSE connections which need to stay open indefinitely
|
||||||
}
|
}
|
||||||
@@ -1588,7 +1593,8 @@ func (s *Service) Start() error {
|
|||||||
port := config.GetWorkerPort()
|
port := config.GetWorkerPort()
|
||||||
|
|
||||||
s.server = &http.Server{
|
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,
|
Handler: s.router,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
|
|||||||
Reference in New Issue
Block a user