fix: validate login and role inputs and protect task access

This commit is contained in:
Ed1s0nZ committed 2026-10-06 01:36:31 +08:00
1 parent 17498368f6
commit 079ba0d2b3
18 files changed
+328 -8

No files matched your search

+7 -1
View File
@@ -16,6 +16,9 @@
| --- | --- | --- |
| C2 wait/cancel 资源权限 | handler 直接按 ID 查询和取消,缺少 c2ResourceAllowed | 与同模块接口一致,先校验任务访问权限,再执行 |
| 32 纯文本批量/机器人任务缺少执行证据 | batch_queue_executor 和 finalizeRobotAgentSuccess 显式传 true | 与普通对话的默认策略一致,允许无工具文本回复,保留 pending/HITL 检查 |
| 30、31 角色名路径字符/长度 | 名称未校验,文件名会转换,存在碰撞 | 拒绝路径和控制字符、首尾空格、超过 64 字符及文件名碰撞 |
| 工作流悬空引用 | 角色保存与工作流删除均未检查引用 | 保存时验证存在;删除引用中的工作流返回冲突 |
| 4 登录用户名为空 | 前端无 required,后端空用户名默认 admin | 登录接口及页面拒绝空白/超过 64 字符用户名 |
## 不适用
@@ -23,4 +26,7 @@
## 验证
待填写实际执行结果。
- `go test ./internal/handler ./internal/app ./internal/agentfinalizer` 通过。
- 新增 C2 跨用户禁止等待/取消、所有者正常操作、机器人纯文本交付、角色名称边界、工作流引用、登录用户名测试。
- 前端已有测试存在 HITL 断言失败,正在核实基线;未修改 HITL 脚本。
- 当前尚未完成全部表内条目核对,也尚未合入 main。
+1
View File
@@ -471,6 +471,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
externalMCPHandler.SetAudit(auditSvc)
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
roleHandler.SetDB(db)
roleHandler.SetAudit(auditSvc)
skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger)
skillsHandler.SetAudit(auditSvc)
+2 -2
View File
@@ -763,9 +763,9 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
func (h *AgentHandler) finalizeRobotAgentSuccess(taskCtx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, false)
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, false)
}
h.persistFinalizationDecision(conversationID, assistantMessageID, "robot", resultMA.MCPExecutionIDs, reasoningContent, decision)
responseText := decision.FinalText
+6
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/config"
@@ -54,6 +55,11 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "密码不能为空"})
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || utf8.RuneCountInString(req.Username) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空,且不能超过 64 个字符"})
return
}
token, expiresAt, err := h.manager.Authenticate(req.Username, req.Password)
if err != nil {
@@ -0,0 +1,28 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestLoginRejectsMissingAndLongUsernameBeforeAuthentication(t *testing.T) {
h := &AuthHandler{}
r := gin.New()
r.POST("/login", h.Login)
for _, username := range []string{"", " ", strings.Repeat("x", 65), strings.Repeat("中", 65)} {
body, _ := json.Marshal(map[string]string{"username": username, "password": "test-only-password"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("username %q: %d", username, w.Code)
}
}
}
+2 -2
View File
@@ -302,10 +302,10 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
if useBatchMulti {
agentMode = "batch_eino_" + batchOrch
}
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, false)
autoCancelledPendingExecutionIDs := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback)
if len(autoCancelledPendingExecutionIDs) > 0 {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, false)
}
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpIDs, reasoningContent, decision)
resText := decision.FinalText
+8
View File
@@ -641,6 +641,10 @@ func (h *C2Handler) CreateTask(c *gin.Context) {
// CancelTask 取消任务
func (h *C2Handler) CancelTask(c *gin.Context) {
id := c.Param("id")
if !h.c2ResourceAllowed(c, "c2_task", id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if err := h.mgr().CancelTask(id); err != nil {
code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok {
@@ -658,6 +662,10 @@ func (h *C2Handler) CancelTask(c *gin.Context) {
// WaitTask 等待任务完成
func (h *C2Handler) WaitTask(c *gin.Context) {
id := c.Param("id")
if !h.c2ResourceAllowed(c, "c2_task", id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
timeout := 60 * time.Second
if t := c.Query("timeout"); t != "" {
if n, err := strconv.Atoi(t); err == nil && n > 0 {
+74
View File
@@ -0,0 +1,74 @@
package handler
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestC2TaskWaitAndCancelRejectForeignTask(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "access.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.CreateC2Listener(&database.C2Listener{ID: "l_foreign", Name: "test", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 12345, Status: "stopped", CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := db.SetResourceOwner("c2_listener", "l_foreign", "owner"); err != nil {
t.Fatal(err)
}
if err := db.UpsertC2Session(&database.C2Session{ID: "s_foreign", ListenerID: "l_foreign", ImplantUUID: "test-uuid", Status: "active", FirstSeenAt: time.Now(), LastCheckIn: time.Now()}); err != nil {
t.Fatal(err)
}
task := &database.C2Task{ID: "t_foreign", SessionID: "s_foreign", TaskType: "exec", Status: "queued", CreatedAt: time.Now()}
if err := db.CreateC2Task(task); err != nil {
t.Fatal(err)
}
h := NewC2Handler(c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop())
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(security.ContextSessionKey, security.Session{UserID: "other", Scope: database.RBACScopeOwn})
})
r.GET("/tasks/:id/wait", h.WaitTask)
r.POST("/tasks/:id/cancel", h.CancelTask)
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, "/tasks/t_foreign/wait", nil),
httptest.NewRequest(http.MethodPost, "/tasks/t_foreign/cancel", nil),
} {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("%s: status=%d body=%s", req.URL, w.Code, w.Body.String())
}
}
saved, err := db.GetC2Task(task.ID)
if err != nil || saved.Status != "queued" {
t.Fatalf("foreign task mutated: %#v, %v", saved, err)
}
// The same endpoints must still work for the listener's owner.
owned := gin.New()
owned.Use(func(c *gin.Context) {
c.Set(security.ContextSessionKey, security.Session{UserID: "owner", Scope: database.RBACScopeOwn})
})
owned.GET("/tasks/:id/wait", h.WaitTask)
owned.POST("/tasks/:id/cancel", h.CancelTask)
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodPost, "/tasks/t_foreign/cancel", nil),
httptest.NewRequest(http.MethodGet, "/tasks/t_foreign/wait", nil),
} {
w := httptest.NewRecorder()
owned.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("owner %s: status=%d body=%s", req.URL, w.Code, w.Body.String())
}
}
}
@@ -0,0 +1,29 @@
package handler
import (
"context"
"path/filepath"
"testing"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
func TestRobotDeliversPlainTextWithoutToolEvidence(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "robot.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
conv, err := db.CreateConversation("text", database.ConversationCreateMeta{})
if err != nil {
t.Fatal(err)
}
h := &AgentHandler{db: db, logger: zap.NewNop()}
result := &multiagent.RunResult{Response: "OK"}
text, _, err := h.finalizeRobotAgentSuccess(context.Background(), "", conv.ID, result)
if err != nil || text != "OK" || !result.Finalized {
t.Fatalf("text=%q finalized=%v error=%v", text, result.Finalized, err)
}
}
+16 -2
View File
@@ -10,6 +10,7 @@ import (
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"gopkg.in/yaml.v3"
@@ -23,8 +24,11 @@ type RoleHandler struct {
configPath string
logger *zap.Logger
audit *audit.Service
db *database.DB
}
func (h *RoleHandler) SetDB(db *database.DB) { h.db = db }
// SetAudit wires platform audit logging.
func (h *RoleHandler) SetAudit(s *audit.Service) {
h.audit = s
@@ -106,6 +110,16 @@ func (h *RoleHandler) UpdateRole(c *gin.Context) {
if req.Name == "" {
req.Name = roleName
}
if err := h.validateRole(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != roleName {
if _, exists := h.config.Roles[req.Name]; exists {
c.JSON(http.StatusConflict, gin.H{"error": "角色已存在"})
return
}
}
// 初始化Roles map
if h.config.Roles == nil {
@@ -198,8 +212,8 @@ func (h *RoleHandler) CreateRole(c *gin.Context) {
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "角色名称不能为空"})
if err := h.validateRole(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+57
View File
@@ -0,0 +1,57 @@
package handler
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
"cyberstrike-ai/internal/config"
)
func validateRoleName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("角色名称不能为空")
}
if name != strings.TrimSpace(name) || utf8.RuneCountInString(name) > 64 {
return fmt.Errorf("角色名称不能包含首尾空格,长度不能超过 64 个字符")
}
if strings.Contains(name, "..") || strings.ContainsAny(name, `/\:*?"<>|`) {
return fmt.Errorf("角色名称不能包含路径或文件名特殊字符")
}
for _, r := range name {
if unicode.IsControl(r) {
return fmt.Errorf("角色名称不能包含控制字符")
}
}
return nil
}
func (h *RoleHandler) validateRole(role config.RoleConfig) error {
if err := validateRoleName(role.Name); err != nil {
return err
}
// Spaces are kept in display names but mapped to underscores on disk.
for key, existing := range h.config.Roles {
name := existing.Name
if name == "" {
name = key
}
if name != role.Name && strings.EqualFold(sanitizeFileName(name), sanitizeFileName(role.Name)) {
return fmt.Errorf("角色名称与已有角色的文件名冲突")
}
}
if strings.TrimSpace(role.WorkflowID) != "" {
if h.db == nil {
return fmt.Errorf("工作流存储不可用,无法验证绑定")
}
wf, err := h.db.GetWorkflowDefinition(role.WorkflowID)
if err != nil {
return fmt.Errorf("无法验证工作流绑定: %w", err)
}
if wf == nil {
return fmt.Errorf("角色绑定的工作流不存在")
}
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package handler
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestRoleNameValidation(t *testing.T) {
for _, name := range []string{"", " ", "../role", "a/b", `a\b`, "a..b", "a\n", strings.Repeat("中", 65)} {
if validateRoleName(name) == nil {
t.Fatalf("accepted %q", name)
}
}
for _, name := range []string{"安全分析", "Web Audit", "role-v2", strings.Repeat("中", 64)} {
if err := validateRoleName(name); err != nil {
t.Fatalf("rejected %q: %v", name, err)
}
}
}
func TestRoleWorkflowValidationAndDeletion(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "roles.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.UpsertWorkflowDefinition(&database.WorkflowDefinition{ID: "wf", Name: "test", GraphJSON: `{"nodes":[],"edges":[]}`, Enabled: true}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{Roles: map[string]config.RoleConfig{"bound": {Name: "bound", WorkflowID: "wf"}}}
h := NewRoleHandler(cfg, filepath.Join(t.TempDir(), "config.yaml"), zap.NewNop())
h.SetDB(db)
wf := NewWorkflowHandler(db, zap.NewNop())
wf.SetRuntime(nil, cfg)
r := gin.New()
r.POST("/roles", h.CreateRole)
r.PUT("/roles/:name", h.UpdateRole)
r.DELETE("/workflows/:id", wf.Delete)
for _, method := range []string{http.MethodPost, http.MethodPut} {
path := "/roles"
if method == http.MethodPut {
path += "/bound"
}
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, bytes.NewBufferString(`{"name":"new-role","workflow_id":"missing"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: %d %s", method, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/workflows/wf", nil))
if w.Code != http.StatusConflict {
t.Fatalf("delete referenced: %d %s", w.Code, w.Body.String())
}
if err := h.validateRole(config.RoleConfig{Name: "valid", WorkflowID: "wf"}); err != nil {
t.Fatal(err)
}
if cfg.Roles["bound"].WorkflowID != "wf" {
t.Fatal("rejected update mutated roles")
}
delete(cfg.Roles, "bound")
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/workflows/wf", nil))
if w.Code != http.StatusOK {
t.Fatalf("delete unreferenced: %d %s", w.Code, w.Body.String())
}
}
+8
View File
@@ -243,6 +243,14 @@ func (h *WorkflowHandler) Delete(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 id 不能为空"})
return
}
if h.cfg != nil {
for _, role := range h.cfg.Roles {
if role.WorkflowID == id {
c.JSON(http.StatusConflict, gin.H{"error": "工作流仍被角色引用,请先解除绑定"})
return
}
}
}
if err := h.db.DeleteWorkflowDefinition(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+1
View File
@@ -1965,6 +1965,7 @@
"forbidden": "Insufficient permissions",
"requestFailed": "Request failed",
"enterPassword": "Please enter password",
"usernameRequired": "Enter a username of at most 64 characters",
"loginFailedCheck": "Sign-in failed, please check the username or password",
"loginFailedRetry": "Sign-in failed, please try again later",
"loggedOut": "Signed out"
+1
View File
@@ -1978,6 +1978,7 @@
"forbidden": "Недостаточно прав",
"requestFailed": "Запрос не выполнен",
"enterPassword": "Введите пароль",
"usernameRequired": "Введите имя пользователя длиной не более 64 символов",
"loginFailedCheck": "Вход не выполнен, проверьте имя пользователя или пароль",
"loginFailedRetry": "Вход не выполнен, повторите позже",
"loggedOut": "Вы вышли"
+1
View File
@@ -1953,6 +1953,7 @@
"forbidden": "权限不足",
"requestFailed": "请求失败",
"enterPassword": "请输入密码",
"usernameRequired": "用户名不能为空,且不能超过 64 个字符",
"loginFailedCheck": "登录失败,请检查用户名或密码",
"loginFailedRetry": "登录失败,请稍后重试",
"loggedOut": "已退出登录"
+8
View File
@@ -257,6 +257,14 @@ async function submitLogin(event) {
const username = usernameInput ? usernameInput.value.trim() : '';
const password = passwordInput.value.trim();
if (!username || Array.from(username).length > 64) {
if (errorBox) {
errorBox.textContent = typeof window.t === 'function' ? window.t('auth.usernameRequired') : '用户名不能为空,且不能超过 64 个字符';
errorBox.style.display = 'block';
}
if (usernameInput) usernameInput.focus();
return;
}
if (!password) {
if (errorBox) {
const msgEmpty = (typeof window !== 'undefined' && typeof window.t === 'function')
+1 -1
View File
@@ -53,7 +53,7 @@
<form id="login-form" class="login-form">
<div class="form-group">
<label for="login-username" data-i18n="login.usernameLabel">用户名</label>
<input type="text" id="login-username" placeholder="admin" autocomplete="username" data-i18n="login.usernamePlaceholder" data-i18n-attr="placeholder" />
<input type="text" id="login-username" placeholder="admin" required maxlength="64" autocomplete="username" data-i18n="login.usernamePlaceholder" data-i18n-attr="placeholder" />
</div>
<div class="form-group">
<label for="login-password" data-i18n="login.passwordLabel">密码</label>