diff --git a/cmd/server/apikey_security_test.go b/cmd/server/apikey_security_test.go new file mode 100644 index 00000000..49913797 --- /dev/null +++ b/cmd/server/apikey_security_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsWeakAPIKey(t *testing.T) { + // Known defaults must be detected + for _, weak := range []string{ + "your-secret-api-key-here", "change-me", "example", "test", + "password", "admin", "apikey", "api-key", "secret", "default", + } { + if !IsWeakAPIKey(weak) { + t.Errorf("expected %q to be weak", weak) + } + } + // Case-insensitive + if !IsWeakAPIKey("Password") { + t.Error("expected case-insensitive match for Password") + } + if !IsWeakAPIKey("YOUR-SECRET-API-KEY-HERE") { + t.Error("expected case-insensitive match") + } + + // Short keys (<16 chars) are weak + if !IsWeakAPIKey("short") { + t.Error("expected short key to be weak") + } + if !IsWeakAPIKey("exactly15chars!") { // 15 chars + t.Error("expected 15-char key to be weak") + } + + // Empty key is NOT weak (handled separately as "disabled") + if IsWeakAPIKey("") { + t.Error("empty key should not be flagged as weak") + } + + // Strong keys pass + if IsWeakAPIKey("a-very-strong-key-1234") { + t.Error("expected strong key to pass") + } + if IsWeakAPIKey("xK9!mP2@nL5#qR8$") { + t.Error("expected 17-char random key to pass") + } +} + +func TestRequireAPIKey_RejectsWeakKey(t *testing.T) { + s := &Server{cfg: &Config{APIKey: "test"}} + handler := s.requireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/api/packets", nil) + req.Header.Set("X-API-Key", "test") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for weak key, got %d", rr.Code) + } +} + +func TestRequireAPIKey_AcceptsStrongKey(t *testing.T) { + strongKey := "a-very-strong-key-1234" + s := &Server{cfg: &Config{APIKey: strongKey}} + handler := s.requireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/api/packets", nil) + req.Header.Set("X-API-Key", strongKey) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200 for strong key, got %d", rr.Code) + } +} + +func TestRequireAPIKey_EmptyKeyDisablesEndpoints(t *testing.T) { + s := &Server{cfg: &Config{APIKey: ""}} + handler := s.requireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/api/packets", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for empty key, got %d", rr.Code) + } +} + +func TestRequireAPIKey_WrongKeyUnauthorized(t *testing.T) { + s := &Server{cfg: &Config{APIKey: "a-very-strong-key-1234"}} + handler := s.requireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/api/packets", nil) + req.Header.Set("X-API-Key", "wrong-key-entirely-here") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for wrong key, got %d", rr.Code) + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go index 9bd40abc..61f388d0 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -62,6 +62,34 @@ type Config struct { NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"` } +// weakAPIKeys is the blocklist of known default/example API keys that must be rejected. +var weakAPIKeys = map[string]bool{ + "your-secret-api-key-here": true, + "change-me": true, + "example": true, + "test": true, + "password": true, + "admin": true, + "apikey": true, + "api-key": true, + "secret": true, + "default": true, +} + +// IsWeakAPIKey returns true if the key is in the blocklist or shorter than 16 characters. +func IsWeakAPIKey(key string) bool { + if key == "" { + return false // empty is handled separately (endpoints disabled) + } + if weakAPIKeys[strings.ToLower(key)] { + return true + } + if len(key) < 16 { + return true + } + return false +} + // ResolvedPathConfig controls async backfill behavior. type ResolvedPathConfig struct { BackfillHours int `json:"backfillHours"` // how far back (hours) to scan for NULL resolved_path (default 24) diff --git a/cmd/server/main.go b/cmd/server/main.go index a70c0531..6c45973c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -104,6 +104,8 @@ func main() { } if cfg.APIKey == "" { log.Printf("[security] WARNING: no apiKey configured — write endpoints are BLOCKED (set apiKey in config.json to enable them)") + } else if IsWeakAPIKey(cfg.APIKey) { + log.Printf("[security] WARNING: API key is weak or a known default — write endpoints are vulnerable") } // Resolve DB path diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f0030962..c860e607 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1,6 +1,7 @@ package main import ( + "crypto/subtle" "database/sql" "encoding/json" "fmt" @@ -238,10 +239,15 @@ func (s *Server) requireAPIKey(next http.Handler) http.Handler { writeError(w, http.StatusForbidden, "write endpoints disabled — set apiKey in config.json") return } - if r.Header.Get("X-API-Key") != s.cfg.APIKey { + key := r.Header.Get("X-API-Key") + if !constantTimeEqual(key, s.cfg.APIKey) { writeError(w, http.StatusUnauthorized, "unauthorized") return } + if IsWeakAPIKey(key) { + writeError(w, http.StatusForbidden, "forbidden") + return + } next.ServeHTTP(w, r) }) } @@ -2310,3 +2316,8 @@ func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) { log.Printf("[prune] deleted %d transmissions older than %d days", n, days) writeJSON(w, map[string]interface{}{"deleted": n, "days": days}) } + +// constantTimeEqual compares two strings in constant time to prevent timing attacks. +func constantTimeEqual(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 83a54775..88be48f9 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -47,7 +47,7 @@ func setupTestServerWithAPIKey(t *testing.T, apiKey string) (*Server, *mux.Route } func TestWriteEndpointsRequireAPIKey(t *testing.T) { - _, router := setupTestServerWithAPIKey(t, "test-secret") + _, router := setupTestServerWithAPIKey(t, "test-secret-key-strong-enough") t.Run("missing key returns 401", func(t *testing.T) { req := httptest.NewRequest("POST", "/api/perf/reset", nil) @@ -65,7 +65,7 @@ func TestWriteEndpointsRequireAPIKey(t *testing.T) { t.Run("wrong key returns 401", func(t *testing.T) { req := httptest.NewRequest("POST", "/api/perf/reset", nil) - req.Header.Set("X-API-Key", "wrong-secret") + req.Header.Set("X-API-Key", "wrong-secret-key-strong-enough") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { @@ -75,7 +75,7 @@ func TestWriteEndpointsRequireAPIKey(t *testing.T) { t.Run("correct key passes", func(t *testing.T) { req := httptest.NewRequest("POST", "/api/perf/reset", nil) - req.Header.Set("X-API-Key", "test-secret") + req.Header.Set("X-API-Key", "test-secret-key-strong-enough") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK {