From c5b45e76fcfd62b965516a66354bbde400e3df7c Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 24 Aug 2026 17:16:13 +0100 Subject: [PATCH 1/2] fix: Allow peer deployment requests across origins --- internal/api/cors_test.go | 36 ++++++++++++++++++++++++++++++++++++ internal/api/server.go | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 internal/api/cors_test.go diff --git a/internal/api/cors_test.go b/internal/api/cors_test.go new file mode 100644 index 0000000..514692b --- /dev/null +++ b/internal/api/cors_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/flatrun/agent/pkg/config" +) + +func TestPeerDeploymentHeaderPassesCORSPreflight(t *testing.T) { + server := New(&config.Config{ + DeploymentsPath: t.TempDir(), + API: config.APIConfig{ + EnableCORS: true, + AllowedOrigins: []string{"https://panel.example.com"}, + }, + }, "") + + request := httptest.NewRequest(http.MethodOptions, "/api/cluster/peers/prod2/proxy/deployments/smartpings", nil) + request.Header.Set("Origin", "https://panel.example.com") + request.Header.Set("Access-Control-Request-Method", http.MethodGet) + request.Header.Set("Access-Control-Request-Headers", "authorization,x-flatrun-deployment") + response := httptest.NewRecorder() + + server.router.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want %d", response.Code, http.StatusNoContent) + } + allowed := strings.ToLower(response.Header().Get("Access-Control-Allow-Headers")) + if !strings.Contains(allowed, "x-flatrun-deployment") { + t.Fatalf("allowed headers = %q, want x-flatrun-deployment", allowed) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index ffc2794..3c14a50 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -161,7 +161,7 @@ func New(cfg *config.Config, configPath string) *Server { if cfg.API.EnableCORS { corsConfig := cors.Config{ AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Content-Type", "Authorization", "Accept", "Origin", "Cache-Control", "X-Requested-With"}, + AllowHeaders: []string{"Content-Type", "Authorization", "Accept", "Origin", "Cache-Control", "X-Requested-With", "X-FlatRun-Deployment"}, AllowCredentials: true, MaxAge: 12 * time.Hour, } From 724f3b836366efed1e1012c5ffd10ddca5c39f1f Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 24 Aug 2026 17:45:23 +0100 Subject: [PATCH 2/2] fix: Bind peer requests to deployment scope --- internal/api/authz.go | 36 +++++++++++++++++++++ internal/api/authz_test.go | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/internal/api/authz.go b/internal/api/authz.go index 812ac36..4907969 100644 --- a/internal/api/authz.go +++ b/internal/api/authz.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/contextkeys" "github.com/gin-gonic/gin" ) @@ -41,6 +42,9 @@ func restrictClusterServiceResources(c *gin.Context) { c.Next() return } + if !narrowClusterServiceDeployment(c, actor) { + return + } path := c.Request.URL.Path if strings.HasPrefix(path, "/api/deployments/") || strings.HasPrefix(path, "/api/containers/") || @@ -58,6 +62,38 @@ func restrictClusterServiceResources(c *gin.Context) { c.Next() } +func narrowClusterServiceDeployment(c *gin.Context, actor *auth.ActorContext) bool { + deployment := strings.TrimSpace(c.GetHeader("X-FlatRun-Deployment")) + if deployment == "" { + return true + } + if actor.APIKey == nil { + c.JSON(http.StatusForbidden, gin.H{"error": "A Fleet peer credential is required"}) + c.Abort() + return false + } + + level := "" + for _, candidate := range []string{auth.AccessLevelAdmin, auth.AccessLevelWrite, auth.AccessLevelRead} { + if actor.CanAccessDeployment(deployment, candidate) { + level = candidate + break + } + } + if level == "" { + c.JSON(http.StatusForbidden, gin.H{"error": "No access to this peer deployment"}) + c.Abort() + return false + } + + scopedActor := *actor + scopedKey := *actor.APIKey + scopedKey.Deployments = auth.DeploymentAccess{deployment: level} + scopedActor.APIKey = &scopedKey + c.Set(contextkeys.Actor, &scopedActor) + return true +} + func (s *Server) requireContainerAccess(c *gin.Context, containerID, level string) bool { if containerID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Container ID required"}) diff --git a/internal/api/authz_test.go b/internal/api/authz_test.go index da12291..c7f6849 100644 --- a/internal/api/authz_test.go +++ b/internal/api/authz_test.go @@ -70,6 +70,71 @@ func TestClusterServiceCredentialsRejectUnscopedSensitiveResources(t *testing.T) } } +func TestClusterServiceDeploymentHeaderNarrowsResourceAccess(t *testing.T) { + gin.SetMode(gin.TestMode) + actor := &auth.ActorContext{ + Type: "api_key", + Role: auth.RoleService, + User: &auth.User{Role: auth.RoleService, Username: "__flatrun_cluster"}, + APIKey: &auth.APIKey{ + Deployments: auth.DeploymentAccess{"allowed": auth.AccessLevelAdmin, "other": auth.AccessLevelAdmin}, + }, + } + + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { + router := gin.New() + router.Use(actorMiddleware(actor), restrictClusterServiceResources) + router.Handle(method, "/api/resources/:deployment", func(c *gin.Context) { + scoped := auth.GetActorFromContext(c) + if !scoped.CanAccessDeployment(c.Param("deployment"), auth.AccessLevelRead) { + c.Status(http.StatusForbidden) + return + } + c.Status(http.StatusNoContent) + }) + + allowed := httptest.NewRequest(method, "/api/resources/allowed", nil) + allowed.Header.Set("X-FlatRun-Deployment", "allowed") + allowedResponse := httptest.NewRecorder() + router.ServeHTTP(allowedResponse, allowed) + if allowedResponse.Code != http.StatusNoContent { + t.Fatalf("%s allowed status = %d", method, allowedResponse.Code) + } + + other := httptest.NewRequest(method, "/api/resources/other", nil) + other.Header.Set("X-FlatRun-Deployment", "allowed") + otherResponse := httptest.NewRecorder() + router.ServeHTTP(otherResponse, other) + if otherResponse.Code != http.StatusForbidden { + t.Fatalf("%s cross-deployment status = %d", method, otherResponse.Code) + } + } +} + +func TestClusterServiceDeploymentHeaderCannotWidenPeerPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + actor := &auth.ActorContext{ + Type: "api_key", + Role: auth.RoleService, + User: &auth.User{Role: auth.RoleService, Username: "__flatrun_cluster"}, + APIKey: &auth.APIKey{ + Deployments: auth.DeploymentAccess{"allowed": auth.AccessLevelRead}, + }, + } + + router := gin.New() + router.Use(actorMiddleware(actor), restrictClusterServiceResources) + router.GET("/api/resources", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "/api/resources", nil) + request.Header.Set("X-FlatRun-Deployment", "other") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } +} + func TestListVirtualHostsFiltersByDeploymentAccess(t *testing.T) { gin.SetMode(gin.TestMode)