From 46a9b42fde143f3439f7522e9884bcde5d6cf647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:58:17 +0800 Subject: [PATCH] Add files via upload --- internal/c2/payload_builder.go | 8 +- internal/security/auth_manager.go | 41 +++++--- internal/security/auth_manager_test.go | 44 ++++++++ internal/security/auth_middleware.go | 18 +++- internal/security/rbac.go | 8 +- internal/security/rbac_middleware.go | 83 ++++++++++++++- internal/security/rbac_middleware_test.go | 118 ++++++++++++++++++++++ internal/security/route_inventory_test.go | 60 +++++++++++ 8 files changed, 351 insertions(+), 29 deletions(-) create mode 100644 internal/security/route_inventory_test.go diff --git a/internal/c2/payload_builder.go b/internal/c2/payload_builder.go index 871ca683..bfef21da 100644 --- a/internal/c2/payload_builder.go +++ b/internal/c2/payload_builder.go @@ -5,9 +5,9 @@ import ( "fmt" "net" "os" - "strconv" "os/exec" "path/filepath" + "strconv" "strings" "text/template" @@ -173,15 +173,16 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro } // 交叉编译 + payloadID := "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14] binName := strings.TrimSpace(in.OutputName) if binName == "" { - binName = fmt.Sprintf("beacon_%s_%s", goos, goarch) + binName = fmt.Sprintf("beacon_%s_%s_%s", goos, goarch, payloadID) } if goos == "windows" && !strings.HasSuffix(binName, ".exe") { binName += ".exe" } binPath := filepath.Join(b.outputDir, binName) - + if err := os.MkdirAll(b.outputDir, 0755); err != nil { return nil, fmt.Errorf("mkdir output: %w", err) } @@ -214,7 +215,6 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro return nil, fmt.Errorf("stat output: %w", err) } - payloadID := "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14] return &BuildResult{ PayloadID: payloadID, ListenerID: listener.ID, diff --git a/internal/security/auth_manager.go b/internal/security/auth_manager.go index e824e962..7ec2d5b1 100644 --- a/internal/security/auth_manager.go +++ b/internal/security/auth_manager.go @@ -19,14 +19,15 @@ var ( // Session represents an authenticated user session. type Session struct { - Token string - ExpiresAt time.Time - UserID string - Username string - DisplayName string - Roles []string - Permissions map[string]bool - Scope string + Token string + ExpiresAt time.Time + UserID string + Username string + DisplayName string + Roles []string + Permissions map[string]bool + PermissionScopes map[string]string + Scope string } // AuthManager manages password-based authentication and session lifecycle. @@ -135,17 +136,25 @@ func (a *AuthManager) authenticateSession(username, password string) (Session, e roleIDs = append(roleIDs, role.ID) } return Session{ - Token: token, - ExpiresAt: expiresAt, - UserID: user.ID, - Username: user.Username, - DisplayName: user.DisplayName, - Roles: roleIDs, - Permissions: access.Permissions, - Scope: access.Scope, + Token: token, + ExpiresAt: expiresAt, + UserID: user.ID, + Username: user.Username, + DisplayName: user.DisplayName, + Roles: roleIDs, + Permissions: access.Permissions, + PermissionScopes: access.PermissionScopes, + Scope: access.Scope, }, nil } +func (s Session) ScopeFor(permission string) string { + if scope := strings.TrimSpace(s.PermissionScopes[strings.TrimSpace(permission)]); scope != "" { + return scope + } + return strings.TrimSpace(s.Scope) +} + // ValidateToken checks whether the provided token is still valid. func (a *AuthManager) ValidateToken(token string) (Session, bool) { if strings.TrimSpace(token) == "" { diff --git a/internal/security/auth_manager_test.go b/internal/security/auth_manager_test.go index 7e9dcf92..83456cc8 100644 --- a/internal/security/auth_manager_test.go +++ b/internal/security/auth_manager_test.go @@ -1,11 +1,15 @@ package security import ( + "net/http" + "net/http/httptest" "path/filepath" "testing" + "cyberstrike-ai/internal/authctx" "cyberstrike-ai/internal/database" + "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -50,4 +54,44 @@ func TestAuthManagerAuthenticatesCreatedRBACUser(t *testing.T) { if _, _, err := manager.Authenticate("", "operator-secret"); err == nil { t.Fatalf("empty username must not authenticate non-admin user") } + + router := gin.New() + router.Use(AuthMiddleware(manager)) + router.GET("/principal", func(c *gin.Context) { + principal, ok := authctx.PrincipalFromContext(c.Request.Context()) + if !ok || principal.UserID != user.ID || !principal.HasPermission("chat:read") || principal.ScopeFor("chat:read") != database.RBACScopeAssigned { + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusNoContent) + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/principal", nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("principal propagation status = %d", w.Code) + } +} + +func TestQueryTokenOnlyAllowedForSSEAndWebSocketGET(t *testing.T) { + requestToken := func(method, accept, upgrade string) string { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(method, "/api/test?token=secret", nil) + c.Request.Header.Set("Accept", accept) + c.Request.Header.Set("Upgrade", upgrade) + return extractTokenFromRequest(c) + } + if got := requestToken(http.MethodGet, "application/json", ""); got != "" { + t.Fatalf("ordinary GET accepted query token %q", got) + } + if got := requestToken(http.MethodPost, "text/event-stream", ""); got != "" { + t.Fatalf("POST accepted query token %q", got) + } + if got := requestToken(http.MethodGet, "text/event-stream", ""); got != "secret" { + t.Fatalf("SSE token = %q", got) + } + if got := requestToken(http.MethodGet, "", "websocket"); got != "secret" { + t.Fatalf("WebSocket token = %q", got) + } } diff --git a/internal/security/auth_middleware.go b/internal/security/auth_middleware.go index ad116992..8b4d5e24 100644 --- a/internal/security/auth_middleware.go +++ b/internal/security/auth_middleware.go @@ -4,6 +4,7 @@ import ( "net/http" "strings" + "cyberstrike-ai/internal/authctx" "cyberstrike-ai/internal/database" "github.com/gin-gonic/gin" @@ -36,6 +37,11 @@ func AuthMiddleware(manager *AuthManager) gin.HandlerFunc { c.Set(ContextUsernameKey, session.Username) c.Set(ContextUserScopeKey, session.Scope) c.Set(ContextSessionKey, session) + // Gin context values do not survive into Agent/MCP/background contexts. + // Attach an immutable principal to the request context as the canonical + // identity for every downstream execution layer. + principal := authctx.NewPrincipalWithScopes(session.UserID, session.Username, session.Scope, session.Permissions, session.PermissionScopes) + c.Request = c.Request.WithContext(authctx.WithPrincipal(c.Request.Context(), principal)) c.Next() } } @@ -79,12 +85,12 @@ func RequireResourcePermission(db *database.DB, permission, resourceType, paramN return } if db == nil { - c.Next() + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "资源鉴权服务不可用"}) return } resourceID := strings.TrimSpace(c.Param(paramName)) if resourceID == "" { - c.Next() + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "资源 ID 不能为空"}) return } session, ok := CurrentSession(c) @@ -129,8 +135,12 @@ func extractTokenFromRequest(c *gin.Context) string { return strings.TrimSpace(authHeader) } - if token := c.Query("token"); token != "" { - return strings.TrimSpace(token) + if token := c.Query("token"); token != "" && c.Request.Method == http.MethodGet { + acceptsSSE := strings.Contains(strings.ToLower(c.GetHeader("Accept")), "text/event-stream") + upgradesWebSocket := strings.EqualFold(strings.TrimSpace(c.GetHeader("Upgrade")), "websocket") + if acceptsSSE || upgradesWebSocket { + return strings.TrimSpace(token) + } } if cookie, err := c.Cookie("auth_token"); err == nil { diff --git a/internal/security/rbac.go b/internal/security/rbac.go index c15049e1..845f297b 100644 --- a/internal/security/rbac.go +++ b/internal/security/rbac.go @@ -19,6 +19,7 @@ var PermissionCatalog = map[string]string{ "chat:write": "Create and update conversations", "chat:delete": "Delete conversations and turns", "agent:execute": "Run AI agents and workflows", + "agent:local-execute": "Use local filesystem, shell, and configured command tools from an agent", "hitl:read": "View HITL queues and logs", "hitl:write": "Approve, dismiss, and configure HITL", "tasks:read": "View task queues", @@ -37,7 +38,9 @@ var PermissionCatalog = map[string]string{ "c2:write": "Operate C2 listeners, sessions, tasks, payloads, files, and profiles", "c2:delete": "Delete C2 objects", "mcp:read": "View MCP status and external MCP configuration", - "mcp:write": "Manage external MCP servers and invoke MCP endpoint", + "mcp:execute": "Invoke the authenticated MCP endpoint", + "mcp:external:execute": "Invoke tools exposed by configured external MCP servers", + "mcp:write": "Manage external MCP server configuration and lifecycle", "knowledge:read": "View knowledge base and retrieval logs", "knowledge:write": "Create, update, index, and scan knowledge base", "knowledge:delete": "Delete knowledge items and retrieval logs", @@ -51,7 +54,8 @@ var PermissionCatalog = map[string]string{ "roles:write": "Create and update AI testing roles", "roles:delete": "Delete AI testing roles", "workflow:read": "View workflow definitions and runs", - "workflow:write": "Create, update, validate, run, and resume workflows", + "workflow:execute": "Validate, dry-run, and resume authorized workflow runs", + "workflow:write": "Create and update workflow definitions", "workflow:delete": "Delete workflows", "config:read": "View system configuration", "config:write": "Update and apply system configuration", diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go index 3179364a..e21d08ba 100644 --- a/internal/security/rbac_middleware.go +++ b/internal/security/rbac_middleware.go @@ -12,22 +12,44 @@ import ( // RBACMiddleware maps protected API routes to platform permissions. It keeps // enforcement centralized so route declarations stay readable. func RBACMiddleware(db *database.DB) gin.HandlerFunc { + return RBACMiddlewareWithDenyHook(db, nil) +} + +type RBACDenyHook func(c *gin.Context, reason, permission string) + +func RBACMiddlewareWithDenyHook(db *database.DB, denyHook RBACDenyHook) gin.HandlerFunc { return func(c *gin.Context) { permission := permissionForRequest(c.Request.Method, c.FullPath()) if permission == "" { + if denyHook != nil { + denyHook(c, "unmapped_route", "") + } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "未配置访问权限", }) return } if SessionHasPermission(c, permission) { + // Bind the scope of the permission authorizing this request. Scope is + // permission-specific; using the user's broadest role scope here would + // let an unrelated global read role widen a write permission. + session, _ := CurrentSession(c) + session.Scope = session.ScopeFor(permission) + c.Set(ContextSessionKey, session) + c.Set(ContextUserScopeKey, session.Scope) if db != nil && !resourceAllowed(c, db) { + if denyHook != nil { + denyHook(c, "resource_denied", permission) + } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) return } c.Next() return } + if denyHook != nil { + denyHook(c, "permission_denied", permission) + } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "权限不足", "permission": permission, @@ -59,7 +81,10 @@ func permissionForRequest(method, fullPath string) string { } return "agent:execute" case strings.HasPrefix(path, "/hitl"): - return crudPermission(method, "hitl") + if method == http.MethodGet || method == http.MethodHead { + return "hitl:read" + } + return "hitl:write" case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"): return crudPermission(method, "tasks") case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"): @@ -79,11 +104,19 @@ func permissionForRequest(method, fullPath string) string { return "terminal:execute" case strings.HasPrefix(path, "/audit"): return crudPermission(method, "audit") - case strings.HasPrefix(path, "/external-mcp"), path == "/mcp": - return crudPermission(method, "mcp") + case path == "/mcp": + return "mcp:execute" + case strings.HasPrefix(path, "/external-mcp"): + if method == http.MethodGet || method == http.MethodHead { + return "mcp:read" + } + return "mcp:write" case strings.HasPrefix(path, "/attack-chain"): return crudPermission(method, "attackchain") case strings.HasPrefix(path, "/knowledge"): + if path == "/knowledge/search" { + return "knowledge:read" + } return crudPermission(method, "knowledge") case strings.HasPrefix(path, "/vulnerabilities"): return crudPermission(method, "vulnerability") @@ -98,6 +131,9 @@ func permissionForRequest(method, fullPath string) string { case strings.HasPrefix(path, "/roles"): return crudPermission(method, "roles") case strings.HasPrefix(path, "/workflows"): + if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") { + return "workflow:execute" + } return crudPermission(method, "workflow") case strings.HasPrefix(path, "/skills"): return crudPermission(method, "skills") @@ -128,6 +164,20 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool { } path := strings.TrimPrefix(c.FullPath(), "/api") switch { + case path == "/monitor/stats", path == "/monitor/calls-timeline": + // These APIs currently operate on process-global state. Until every MCP + // invocation and persisted execution record carries an immutable owner, + // allowing an assigned/own-scoped session would be a cross-user bypass. + return session.Scope == database.RBACScopeAll + case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet: + return session.Scope == database.RBACScopeAll + case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet: + return session.Scope == database.RBACScopeAll + case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path): + // These definitions/configurations are shared by every user and do not + // carry owners. A module write permission with assigned/own scope must + // not silently become a process-global administrative capability. + return session.Scope == database.RBACScopeAll case strings.HasPrefix(path, "/projects/:id"): return db.UserCanAccessResource(session.UserID, session.Scope, "project", c.Param("id")) case strings.HasPrefix(path, "/conversations/:id"): @@ -154,3 +204,30 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool { return true } } + +func isMutationMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +func isProcessGlobalMutationPath(path string) bool { + if strings.HasPrefix(path, "/roles") || strings.HasPrefix(path, "/skills") || + strings.HasPrefix(path, "/external-mcp") || strings.HasPrefix(path, "/robot") { + return true + } + if strings.HasPrefix(path, "/workflows") { + // Workflow runs inherit conversation access; definitions are global. + return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" + } + if strings.HasPrefix(path, "/knowledge") { + return path != "/knowledge/search" + } + if strings.HasPrefix(path, "/eino-agent/markdown-agents") || strings.HasPrefix(path, "/multi-agent/markdown-agents") { + return true + } + return false +} diff --git a/internal/security/rbac_middleware_test.go b/internal/security/rbac_middleware_test.go index 1d33bac1..fec7ccb1 100644 --- a/internal/security/rbac_middleware_test.go +++ b/internal/security/rbac_middleware_test.go @@ -118,3 +118,121 @@ func TestRBACResourcePickerRequiresWritePermission(t *testing.T) { t.Fatalf("assignment list permission = %q, want rbac:read", got) } } + +func TestMCPInvocationPermissionIsSeparateFromMCPAdministration(t *testing.T) { + if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" { + t.Fatalf("MCP invocation permission = %q, want mcp:execute", got) + } + if got := permissionForRequest(http.MethodPut, "/api/external-mcp/example"); got != "mcp:write" { + t.Fatalf("external MCP admin permission = %q, want mcp:write", got) + } +} + +func TestWorkflowRunPermissionIsSeparateFromDefinitionManagement(t *testing.T) { + if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" { + t.Fatalf("resume permission = %q, want workflow:execute", got) + } + if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" { + t.Fatalf("definition permission = %q, want workflow:write", got) + } +} + +func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) { + gin.SetMode(gin.TestMode) + called := false + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(ContextSessionKey, Session{UserID: "viewer", Permissions: map[string]bool{"project:read": true}, Scope: database.RBACScopeAssigned}) + c.Next() + }) + router.Use(RBACMiddlewareWithDenyHook(nil, func(_ *gin.Context, reason, permission string) { + called = reason == "permission_denied" && permission == "project:write" + })) + router.POST("/api/projects", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/projects", nil)) + if w.Code != http.StatusForbidden || !called { + t.Fatalf("denial = status %d, hook called %v", w.Code, called) + } +} + +func TestRBACMiddlewareBindsPermissionSpecificScope(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(ContextSessionKey, Session{ + UserID: "mixed", Scope: database.RBACScopeAll, + Permissions: map[string]bool{"project:read": true, "project:write": true}, + PermissionScopes: map[string]string{"project:read": database.RBACScopeAll, "project:write": database.RBACScopeOwn}, + }) + c.Next() + }) + router.Use(RBACMiddleware(nil)) + handler := func(c *gin.Context) { + session, _ := CurrentSession(c) + c.String(http.StatusOK, session.Scope) + } + router.GET("/api/projects/:id", handler) + router.PUT("/api/projects/:id", handler) + + for _, tc := range []struct{ method, want string }{ + {http.MethodGet, database.RBACScopeAll}, + {http.MethodPut, database.RBACScopeOwn}, + } { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(tc.method, "/api/projects/p1", nil)) + if w.Code != http.StatusOK || w.Body.String() != tc.want { + t.Fatalf("%s scope response = %d/%q, want 200/%q", tc.method, w.Code, w.Body.String(), tc.want) + } + } +} + +func TestRBACMiddlewareRejectsAssignedScopeForGlobalMonitorAggregates(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + method string + path string + permission string + }{ + {method: http.MethodGet, path: "/api/monitor/stats", permission: "monitor:read"}, + } { + t.Run(tc.path, func(t *testing.T) { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(ContextSessionKey, Session{ + UserID: "assigned-user", Permissions: map[string]bool{tc.permission: true}, Scope: database.RBACScopeAssigned, + }) + c.Next() + }) + router.Use(RBACMiddleware(&database.DB{})) + router.Handle(tc.method, tc.path, func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil)) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden) + } + }) + } +} + +func TestAssignedScopeCannotMutateProcessGlobalAssets(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, path := range []string{"/api/roles/demo", "/api/skills/demo", "/api/external-mcp/demo", "/api/workflows/demo", "/api/knowledge/items/demo"} { + t.Run(path, func(t *testing.T) { + permission := permissionForRequest(http.MethodPut, path) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(ContextSessionKey, Session{UserID: "operator", Scope: database.RBACScopeAssigned, Permissions: map[string]bool{permission: true}, PermissionScopes: map[string]string{permission: database.RBACScopeAssigned}}) + c.Next() + }) + router.Use(RBACMiddleware(&database.DB{})) + router.PUT(path, func(c *gin.Context) { c.Status(http.StatusNoContent) }) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodPut, path, nil)) + if w.Code != http.StatusForbidden { + t.Fatalf("global mutation status = %d, want 403", w.Code) + } + }) + } +} diff --git a/internal/security/route_inventory_test.go b/internal/security/route_inventory_test.go new file mode 100644 index 00000000..c08bfe99 --- /dev/null +++ b/internal/security/route_inventory_test.go @@ -0,0 +1,60 @@ +package security + +import ( + "go/ast" + "go/parser" + "go/token" + "net/http" + "path/filepath" + "strconv" + "testing" +) + +func TestEveryProtectedRouteHasCatalogPermission(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "app", "app.go"), nil, 0) + if err != nil { + t.Fatal(err) + } + methods := map[string]string{ + "GET": http.MethodGet, "POST": http.MethodPost, "PUT": http.MethodPut, + "PATCH": http.MethodPatch, "DELETE": http.MethodDelete, + } + prefixes := map[string]string{"protected": "", "c2Routes": "/c2", "knowledgeRoutes": "/knowledge"} + found := 0 + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + prefix, protected := prefixes[ident.Name] + method, routeMethod := methods[sel.Sel.Name] + literal, literalPath := call.Args[0].(*ast.BasicLit) + if !protected || !routeMethod || !literalPath || literal.Kind != token.STRING { + return true + } + path, err := strconv.Unquote(literal.Value) + if err != nil { + t.Errorf("invalid route literal %s", literal.Value) + return true + } + found++ + permission := permissionForRequest(method, "/api"+prefix+path) + if permission == "" { + t.Errorf("unmapped protected route: %s %s%s", method, prefix, path) + } else if _, ok := PermissionCatalog[permission]; !ok { + t.Errorf("route %s %s%s maps to unknown permission %q", method, prefix, path, permission) + } + return true + }) + if found < 100 { + t.Fatalf("route inventory unexpectedly small: %d", found) + } +}