From 7b30b5beaa90cb52c96397edacd2f7c2ed652b59 Mon Sep 17 00:00:00 2001 From: cjimti Date: Wed, 27 May 2026 01:42:00 -0700 Subject: [PATCH 1/2] feat: add API key authentication to REST API (#507) Require Bearer token auth on all API endpoints except /api/health. On startup the key is read from KUBEFWD_API_KEY env var; if unset a random 32-char hex key is generated and the last 4 chars are printed to the console. Token comparison uses crypto/subtle.ConstantTimeCompare to prevent timing side-channel attacks. MCP HTTP client and `kubefwd mcp --api-key` flag updated to propagate the key automatically. --- cmd/kubefwd/mcp/mcp.go | 10 ++- pkg/fwdapi/manager.go | 24 ++++++++ pkg/fwdapi/manager_test.go | 41 +++++++++++++ pkg/fwdapi/middleware/middleware.go | 35 +++++++++++ pkg/fwdapi/middleware/middleware_test.go | 78 +++++++++++++++++++++++- pkg/fwdapi/server.go | 11 +++- pkg/fwdmcp/httpclient.go | 49 +++++++++++++-- 7 files changed, 236 insertions(+), 12 deletions(-) diff --git a/cmd/kubefwd/mcp/mcp.go b/cmd/kubefwd/mcp/mcp.go index abf00162..f3fb934a 100644 --- a/cmd/kubefwd/mcp/mcp.go +++ b/cmd/kubefwd/mcp/mcp.go @@ -14,7 +14,8 @@ import ( ) var ( - apiURL string + apiURL string + apiKey string verbose bool ) @@ -23,6 +24,7 @@ var Version string func init() { Cmd.Flags().StringVar(&apiURL, "api-url", "http://kubefwd.internal/api", "URL of the kubefwd REST API") + Cmd.Flags().StringVar(&apiKey, "api-key", "", "API key for authentication (env: KUBEFWD_API_KEY)") Cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output") } @@ -92,6 +94,12 @@ func runMCP(_ *cobra.Command, _ []string) { log.SetLevel(log.WarnLevel) } + if apiKey != "" { + if err := os.Setenv("KUBEFWD_API_KEY", apiKey); err != nil { + log.Warnf("Failed to set KUBEFWD_API_KEY: %v", err) + } + } + log.Infof("Starting kubefwd MCP server (version %s)", Version) log.Infof("Connecting to REST API at: %s", apiURL) diff --git a/pkg/fwdapi/manager.go b/pkg/fwdapi/manager.go index 6f008991..fba5c18f 100644 --- a/pkg/fwdapi/manager.go +++ b/pkg/fwdapi/manager.go @@ -2,9 +2,12 @@ package fwdapi import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "net" "net/http" + "os" "sync" "time" @@ -73,6 +76,7 @@ type Manager struct { namespaces []string contexts []string tuiEnabled bool + apiKey string } // Enable marks API mode as enabled @@ -111,6 +115,7 @@ func Init(shutdownChan <-chan struct{}, triggerShutdown func(), version string) startTime: time.Now(), triggerShutdown: triggerShutdown, version: version, + apiKey: resolveAPIKey(), } // Listen for external shutdown signal @@ -373,6 +378,9 @@ func (m *Manager) Run() error { log.Infof("Server listening on http://%s (http://%s/)", addr, Hostname) log.Infof("API: http://%s/api Docs: http://%s/docs", Hostname, Hostname) + if len(m.apiKey) >= 4 { + log.Infof("API key: ...%s", m.apiKey[len(m.apiKey)-4:]) + } // Start server in goroutine errCh := make(chan error, 1) @@ -443,3 +451,19 @@ func (m *Manager) Contexts() []string { func (m *Manager) TUIEnabled() bool { return m.tuiEnabled } + +// APIKey returns the API key used for authentication +func (m *Manager) APIKey() string { + return m.apiKey +} + +func resolveAPIKey() string { + if key := os.Getenv("KUBEFWD_API_KEY"); key != "" { + return key + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("failed to generate API key: %v", err)) + } + return hex.EncodeToString(b) +} diff --git a/pkg/fwdapi/manager_test.go b/pkg/fwdapi/manager_test.go index 3ca566c0..93bd3424 100644 --- a/pkg/fwdapi/manager_test.go +++ b/pkg/fwdapi/manager_test.go @@ -1,6 +1,7 @@ package fwdapi import ( + "os" "testing" "time" @@ -1028,3 +1029,43 @@ func TestDirectNamespaceManagerAdapter_RemoveNamespace(t *testing.T) { t.Error("Expected error for non-existent namespace") } } + +func TestResolveAPIKey_FromEnv(t *testing.T) { + os.Setenv("KUBEFWD_API_KEY", "my-fixed-key") + defer os.Unsetenv("KUBEFWD_API_KEY") + + key := resolveAPIKey() + if key != "my-fixed-key" { + t.Errorf("Expected 'my-fixed-key', got '%s'", key) + } +} + +func TestResolveAPIKey_Generated(t *testing.T) { + os.Unsetenv("KUBEFWD_API_KEY") + + key := resolveAPIKey() + if len(key) != 32 { + t.Errorf("Expected 32-char hex string, got length %d", len(key)) + } + + key2 := resolveAPIKey() + if key == key2 { + t.Error("Expected different keys on each call") + } +} + +func TestManager_APIKey(t *testing.T) { + resetGlobalState() + os.Unsetenv("KUBEFWD_API_KEY") + + shutdownChan := make(chan struct{}) + defer close(shutdownChan) + + manager := Init(shutdownChan, func() {}, "1.0.0") + if manager.APIKey() == "" { + t.Error("Expected non-empty API key") + } + if len(manager.APIKey()) != 32 { + t.Errorf("Expected 32-char hex key, got length %d", len(manager.APIKey())) + } +} diff --git a/pkg/fwdapi/middleware/middleware.go b/pkg/fwdapi/middleware/middleware.go index a816d473..b3ad4273 100644 --- a/pkg/fwdapi/middleware/middleware.go +++ b/pkg/fwdapi/middleware/middleware.go @@ -1,6 +1,8 @@ package middleware import ( + "crypto/subtle" + "strings" "time" "github.com/gin-gonic/gin" @@ -108,6 +110,39 @@ func ErrorHandler() gin.HandlerFunc { } } +// APIKeyAuth rejects requests that don't carry a valid Bearer token. +func APIKeyAuth(apiKey string) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if header == "" { + c.JSON(401, gin.H{ + "success": false, + "error": gin.H{ + "code": "UNAUTHORIZED", + "message": "API key required. Pass via Authorization: Bearer ", + }, + }) + c.Abort() + return + } + + token := strings.TrimPrefix(header, "Bearer ") + if token == header || subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) != 1 { + c.JSON(401, gin.H{ + "success": false, + "error": gin.H{ + "code": "UNAUTHORIZED", + "message": "Invalid API key", + }, + }) + c.Abort() + return + } + + c.Next() + } +} + // NoCache middleware prevents caching of API responses func NoCache() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/pkg/fwdapi/middleware/middleware_test.go b/pkg/fwdapi/middleware/middleware_test.go index 942885f7..bb9cd86a 100644 --- a/pkg/fwdapi/middleware/middleware_test.go +++ b/pkg/fwdapi/middleware/middleware_test.go @@ -20,14 +20,18 @@ func performRequest(r *gin.Engine, method, path string) *httptest.ResponseRecord return w } -func performRequestWithOrigin(r *gin.Engine, method, path, origin string) *httptest.ResponseRecorder { +func performRequestWithHeader(r *gin.Engine, method, path, key, value string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, http.NoBody) - req.Header.Set("Origin", origin) + req.Header.Set(key, value) w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } +func performRequestWithOrigin(r *gin.Engine, method, path, origin string) *httptest.ResponseRecorder { + return performRequestWithHeader(r, method, path, "Origin", origin) +} + func TestRecovery(t *testing.T) { r := setupRouter() r.Use(Recovery()) @@ -266,3 +270,73 @@ func TestErrorHandler_DefaultsTo500(t *testing.T) { t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, w.Code) } } + +func TestAPIKeyAuth_ValidKey(t *testing.T) { + r := setupRouter() + r.Use(APIKeyAuth("test-secret-key")) + r.GET("/protected", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "Bearer test-secret-key") + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestAPIKeyAuth_NoHeader(t *testing.T) { + r := setupRouter() + r.Use(APIKeyAuth("test-secret-key")) + r.GET("/protected", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := performRequest(r, "GET", "/protected") + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestAPIKeyAuth_WrongKey(t *testing.T) { + r := setupRouter() + r.Use(APIKeyAuth("test-secret-key")) + r.GET("/protected", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "Bearer wrong-key") + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestAPIKeyAuth_MissingBearerPrefix(t *testing.T) { + r := setupRouter() + r.Use(APIKeyAuth("test-secret-key")) + r.GET("/protected", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "test-secret-key") + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestAPIKeyAuth_EmptyBearer(t *testing.T) { + r := setupRouter() + r.Use(APIKeyAuth("test-secret-key")) + r.GET("/protected", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := performRequestWithHeader(r, "GET", "/protected", "Authorization", "Bearer ") + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} diff --git a/pkg/fwdapi/server.go b/pkg/fwdapi/server.go index 275bbed2..54b36eac 100644 --- a/pkg/fwdapi/server.go +++ b/pkg/fwdapi/server.go @@ -44,13 +44,18 @@ func (m *Manager) setupRouter() *gin.Engine { // API group api := r.Group("/api") { - // Health endpoints + // Health endpoint (unauthenticated for liveness probes) healthHandler := handlers.NewHealthHandler(m.version, m.startTime, getManagerInfo) api.GET("/health", healthHandler.Health) - api.GET("/info", healthHandler.Info) + + // Authenticated API routes + auth := api.Group("") + auth.Use(middleware.APIKeyAuth(m.apiKey)) + + auth.GET("/info", healthHandler.Info) // API v1 routes - v1 := api.Group("/v1") + v1 := auth.Group("/v1") { // Services endpoints svcHandler := handlers.NewServicesHandler(m.stateReader, m.serviceController) diff --git a/pkg/fwdmcp/httpclient.go b/pkg/fwdmcp/httpclient.go index 87150194..0f1bff43 100644 --- a/pkg/fwdmcp/httpclient.go +++ b/pkg/fwdmcp/httpclient.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -20,25 +21,49 @@ import ( "github.com/txn2/kubefwd/pkg/fwdtui/state" ) -// HTTPClient wraps http.Client with base URL +// HTTPClient wraps http.Client with base URL and optional API key type HTTPClient struct { client *http.Client baseURL string + apiKey string } -// NewHTTPClient creates a new HTTP client for the kubefwd API +// NewHTTPClient creates a new HTTP client for the kubefwd API. +// The API key is read from KUBEFWD_API_KEY if set. func NewHTTPClient(baseURL string) *HTTPClient { return &HTTPClient{ client: &http.Client{ Timeout: 30 * time.Second, }, baseURL: baseURL, + apiKey: os.Getenv("KUBEFWD_API_KEY"), } } +// SetAPIKey configures the Bearer token sent with every request +func (c *HTTPClient) SetAPIKey(key string) { + c.apiKey = key +} + +func (c *HTTPClient) newRequest(method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, c.baseURL+path, body) + if err != nil { + return nil, err + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + return req, nil +} + // Get performs a GET request and decodes JSON response func (c *HTTPClient) Get(path string, result interface{}) error { - resp, err := c.client.Get(c.baseURL + path) + req, err := c.newRequest(http.MethodGet, path, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.client.Do(req) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) } @@ -58,7 +83,13 @@ func (c *HTTPClient) Get(path string, result interface{}) error { // Post performs a POST request and decodes JSON response func (c *HTTPClient) Post(path string, result interface{}) error { - resp, err := c.client.Post(c.baseURL+path, "application/json", nil) + req, err := c.newRequest(http.MethodPost, path, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) } @@ -89,7 +120,13 @@ func (c *HTTPClient) PostJSON(path string, body interface{}, result interface{}) bodyReader = bytes.NewReader(bodyBytes) } - resp, err := c.client.Post(c.baseURL+path, "application/json", bodyReader) + req, err := c.newRequest(http.MethodPost, path, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) } @@ -111,7 +148,7 @@ func (c *HTTPClient) PostJSON(path string, body interface{}, result interface{}) // Delete performs a DELETE request and decodes JSON response func (c *HTTPClient) Delete(path string, result interface{}) error { - req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, http.NoBody) + req, err := c.newRequest(http.MethodDelete, path, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } From 281a502b7aef7c927877f4993127a50cf1e7e081 Mon Sep 17 00:00:00 2001 From: cjimti Date: Fri, 29 May 2026 01:10:16 -0700 Subject: [PATCH 2/2] fix: print full API key on startup and suppress CodeQL false positive The last-4-chars log was flagged by CodeQL go/clear-text-logging yet was useless for authenticating. Print the full key instead so users can copy it into API/MCP clients (localhost-only dev tool, same security context as the launching user), and annotate the line with an lgtm suppression plus justification. --- pkg/fwdapi/manager.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/fwdapi/manager.go b/pkg/fwdapi/manager.go index fba5c18f..b17db28e 100644 --- a/pkg/fwdapi/manager.go +++ b/pkg/fwdapi/manager.go @@ -378,8 +378,14 @@ func (m *Manager) Run() error { log.Infof("Server listening on http://%s (http://%s/)", addr, Hostname) log.Infof("API: http://%s/api Docs: http://%s/docs", Hostname, Hostname) - if len(m.apiKey) >= 4 { - log.Infof("API key: ...%s", m.apiKey[len(m.apiKey)-4:]) + if m.apiKey != "" { + // Print the full key so the user can copy it into API/MCP clients. + // This is an interactive, localhost-only dev tool run by the same + // user that owns the kubeconfig, so echoing the token to that user's + // own console is intended (cf. jupyter/ngrok). Set KUBEFWD_API_KEY to + // pin a known key for automation. The CodeQL clear-text-logging alert + // is a deliberate false positive here. + log.Infof("API key: %s", m.apiKey) // lgtm[go/clear-text-logging] } // Start server in goroutine