From bdefa535b095da11a5f68883428ba41619cf51fc Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Tue, 6 Oct 2026 02:20:28 +0800 Subject: [PATCH] fix: validate form edits and enforce task result ownership --- docs/bug-sheet-review.md | 9 +++ docs/bug-sheet-status.md | 12 ++-- internal/c2/listener_http.go | 4 +- internal/c2/listener_websocket.go | 2 +- internal/c2/manager.go | 27 +++++++ internal/c2/result_ownership_test.go | 89 ++++++++++++++++++++++++ internal/c2/tcp_beacon_server.go | 12 +++- internal/database/asset.go | 6 +- internal/database/asset_test.go | 22 ++++++ internal/handler/batch_task_manager.go | 3 + internal/handler/form_validation_test.go | 75 ++++++++++++++++++++ internal/handler/project.go | 7 +- web/static/i18n/en-US.json | 1 + web/static/i18n/ru-RU.json | 1 + web/static/i18n/zh-CN.json | 1 + web/static/js/form-validation.test.cjs | 62 +++++++++++++++++ web/static/js/projects.js | 4 +- web/static/js/tasks.js | 8 ++- web/templates/index.html | 6 +- 19 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 internal/c2/result_ownership_test.go create mode 100644 internal/handler/form_validation_test.go create mode 100644 web/static/js/form-validation.test.cjs diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index 9d243e33..70e8f591 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -49,3 +49,12 @@ - 配置凭据处理保留既有字段与空字符串清除语义;掩码标记只用于已保存的同路径凭据,新通道不能直接保存掩码。通道探活新增可选 `channel_id`,模型列表/审查测试支持可选凭据范围,避免同 URL 多密钥选择错误。 - 新增 GET 配置不泄漏/不变更内存、掩码保留/替换/清空、歧义拒绝、WebShell 回显及执行凭据、Markdown 创建/更新唯一性、技能描述切换测试。 - C2 Shell 回归只提取模板中的 Shell 辅助函数,不运行传输循环。验证无换行、有换行、尾部分号、注释、失败退出、带空格目录切换;Unix 系统通过,Windows 路径未修改。模板修复适用于重新生成的客户端。 + +## 追加:输入校验、权限入口与回传归属 + +- Excel 第 40 行:权限管理页三个新建角色/添加成员按钮补上 `rbac:write` 标记,复用现有隐藏、禁用与点击守卫。前端用权限被拒绝的真实守卫验证入口不可见且不可点击。 +- Excel 第 41 行:并发数内联编辑对空值、小数、0、9 等明确报错并保留编辑框,不发送请求;队列元数据更新在任何修改前拒绝越界值。保留旧配置加载时的归一化行为。 +- Excel 第 42 行:项目设置前端和更新 API 均拒绝空/纯空格名称;API 省略名称仍允许部分更新,拒绝时描述等字段不发生部分保存。 +- Excel 第 44 行:后端 URL 补齐使用 `IP 非空` 作为分支条件,导致已由前端解析的 IP 被再填入 Domain。改为先判别 URL 主机类型再补齐缺失 IP;IPv4、IPv6 预填及仅 URL 三条数据库保存回归通过。第 22 行截图与第 25 行为同一 WebShell 路径丢失/错误文本条目问题。 +- Excel 第 36 行:WebSocket 与 TCP Beacon 回传按连接已登记的会话和监听器校验;HTTP 回传及取任务先校验监听器;已有 UUID 不允许通过其他监听器重新登记改变归属。未授权回传不能更改任务状态、结果或产生完成事件。HTTP 同监听器下的会话认证尚待继续核对,本项不标为全部完成。 +- 验证:`form-validation.test.cjs` 4 项通过;项目空名称无部分保存、并发数非法无修改、IP URL 入库及结果来源拒绝均新增回归。全部包回归在提交前再次执行。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 9a530361..100c6326 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -24,7 +24,7 @@ | 19 | 18 | 用户并发配额耗尽时任务静默排队,界面无提示 | 当前仓库未实现,不扩展能力 | | 20 | — | 量任务已经执行结束,任务管理页也将队列标记为“已完成 100%”,但该任务创建的容器仍长期保持“ | 当前仓库未实现,不扩展能力 | | 21 | 19 | webshell 连接口令明文 | 已修复并有针对性验证 | -| 22 | 20 | 不知道这个是啥,应该是样式问题 | 待核对 | +| 22 | 20 | 不知道这个是啥,应该是样式问题 | 与第 25 行同一 WebShell 路径/假文件问题,已修复 | | 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 待核对 | | 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 终态已修复,其他任务路径待核对 | | 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 | @@ -38,15 +38,15 @@ | 33 | 31 | 角色名可以加个 长度限制,否则展示有问题 | 已修复并有针对性验证 | | 34 | 32 | 批量任务里只要指令不调用任何工具(例如「请只回复:OK」),子任务就必然失败并回「缺少执行证据」 | 已修复并有针对性验证 | | 35 | — | Markdown 智能体:同文件名静默覆盖、关键字段不校验、name 含路径字符被静默改写 | 已修复并有针对性验证 | -| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 待核对 | +| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 已修复持久连接跨会话及跨监听器回传;HTTP 同监听器会话隔离待核对 | | 37 | — | 100s 状态不变、无 error、无 completed_at,会话早已离线。 | 待核对 | | 38 | — | 在「工作流」里删掉一个正被角色引用的工作流 → 删除成功且没有任何提示;之后任何人用这个角色发起 | 已修复并有针对性验证 | | 39 | — | 同一 implant uuid 重复上线互踢,旧连接断开把会话判死 | 已修复并有针对性验证 | -| 40 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 待核对 | -| 41 | — | 点击“并发数”当前值进行编辑。 | 待核对 | -| 42 | — | 设置页清空“项目名称”后点击保存,页面提示“已保存”,但名称被静默恢复成原值。 | 待核对 | +| 40 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 已补权限入口标记,按钮可见性验证通过 | +| 41 | — | 点击“并发数”当前值进行编辑。 | 已修复并发越界提示,前后端回归通过 | +| 42 | — | 设置页清空“项目名称”后点击保存,页面提示“已保存”,但名称被静默恢复成原值。 | 已修复空名称误报保存成功,前后端回归通过 | | 43 | — | 样式问题 | 待核对 | -| 44 | — | 输入 目标地址 时,前端正确解析 IP/端口/协议,但提交提示“域名格式无效”,无法创建。 | 待核对 | +| 44 | — | 输入 目标地址 时,前端正确解析 IP/端口/协议,但提交提示“域名格式无效”,无法创建。 | 已修复 URL 与预填 IP 重复解析,IPv4/IPv6 回归通过 | | 45 | — | 无工具节点的工作流成功完成后,被误判为缺少执行证据 | 待核对 | | 46 | — | Agent ID 可重复- 两个不同文件可使用同一个 ID。 | 已修复并有针对性验证 | | 47 | — | nmap 参数缺少前置校验 | 待核对 | diff --git a/internal/c2/listener_http.go b/internal/c2/listener_http.go index a4fc62e4..4f930ba3 100644 --- a/internal/c2/listener_http.go +++ b/internal/c2/listener_http.go @@ -254,7 +254,7 @@ func (l *HTTPBeaconListener) handleTasks(w http.ResponseWriter, r *http.Request) return } session, err := l.manager.DB().GetC2Session(sessionID) - if err != nil || session == nil { + if err != nil || session == nil || session.ListenerID != l.rec.ID { l.disguisedReject(w) return } @@ -299,7 +299,7 @@ func (l *HTTPBeaconListener) handleResult(w http.ResponseWriter, r *http.Request l.disguisedReject(w) return } - if err := l.manager.IngestTaskResult(report); err != nil { + if err := l.manager.IngestTaskResultFromListener(l.rec.ID, "", report); err != nil { http.Error(w, "ingest result failed", http.StatusInternalServerError) return } diff --git a/internal/c2/listener_websocket.go b/internal/c2/listener_websocket.go index b092c8cd..21f3f17c 100644 --- a/internal/c2/listener_websocket.go +++ b/internal/c2/listener_websocket.go @@ -214,7 +214,7 @@ func (l *WebSocketListener) handleConn(ws *websocket.Conn) { case "result": var report TaskResultReport if err := json.Unmarshal(body, &report); err == nil { - _ = l.manager.IngestTaskResult(report) + _ = l.manager.IngestTaskResultFromListener(l.rec.ID, conn.sessionID, report) } case "checkin": // 心跳更新:beacon 周期性送上心跳 diff --git a/internal/c2/manager.go b/internal/c2/manager.go index 5c41bb2b..c4bdd12a 100644 --- a/internal/c2/manager.go +++ b/internal/c2/manager.go @@ -359,6 +359,9 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (* if err != nil { return nil, err } + if existing != nil && existing.ListenerID != listenerID { + return nil, ErrAuthFailed + } now := time.Now() isFirstSeen := existing == nil var sessID string @@ -659,6 +662,30 @@ func (m *Manager) PopTasksForBeacon(sessionID string, limit int) ([]TaskEnvelope } // IngestTaskResult beacon 回传任务结果的统一入口 +// IngestTaskResultFromListener verifies transport-established ownership before +// accepting a result. HTTP transports authenticate the listener; persistent +// connections additionally bind the result to the session established at check-in. +func (m *Manager) IngestTaskResultFromListener(listenerID, sessionID string, report TaskResultReport) error { + if listenerID == "" { + return ErrAuthFailed + } + task, err := m.db.GetC2Task(report.TaskID) + if err != nil { + return err + } + if task == nil { + return ErrTaskNotFound + } + session, err := m.db.GetC2Session(task.SessionID) + if err != nil { + return err + } + if session == nil || session.ListenerID != listenerID || (sessionID != "" && session.ID != sessionID) { + return ErrAuthFailed + } + return m.IngestTaskResult(report) +} + func (m *Manager) IngestTaskResult(report TaskResultReport) error { if strings.TrimSpace(report.TaskID) == "" { return ErrInvalidInput diff --git a/internal/c2/result_ownership_test.go b/internal/c2/result_ownership_test.go new file mode 100644 index 00000000..ed691442 --- /dev/null +++ b/internal/c2/result_ownership_test.go @@ -0,0 +1,89 @@ +package c2 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cyberstrike-ai/internal/database" + "go.uber.org/zap" +) + +func TestResultTransportOwnershipRejectsForeignTask(t *testing.T) { + m, db := terminalTestManager(t) + now := time.Now() + if err := db.UpsertC2Session(&database.C2Session{ID: "other", ListenerID: "listener", ImplantUUID: "other-uuid", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil { + t.Fatal(err) + } + task := &database.C2Task{ID: "t_owner", SessionID: "session", TaskType: "exec", Status: "sent", CreatedAt: now} + if err := db.CreateC2Task(task); err != nil { + t.Fatal(err) + } + report := TaskResultReport{TaskID: task.ID, Success: true, Output: "test-only-result"} + for _, source := range [][2]string{{"listener", "other"}, {"foreign-listener", "session"}, {"foreign-listener", ""}, {"", "session"}} { + if err := m.IngestTaskResultFromListener(source[0], source[1], report); err != ErrAuthFailed { + t.Fatalf("source %v: %v", source, err) + } + saved, err := db.GetC2Task(task.ID) + if err != nil || saved.Status != "sent" || saved.ResultText != "" { + t.Fatalf("foreign result persisted: %#v %v", saved, err) + } + } + if err := m.IngestTaskResultFromListener("listener", "session", report); err != nil { + t.Fatal(err) + } + saved, _ := db.GetC2Task(task.ID) + if saved.Status != "success" || saved.ResultText != report.Output { + t.Fatalf("valid result failed: %#v", saved) + } +} + +func TestHTTPResultCannotUpdateAnotherListenerTask(t *testing.T) { + m, db := terminalTestManager(t) + task := &database.C2Task{ID: "t_foreign", SessionID: "session", TaskType: "exec", Status: "sent", CreatedAt: time.Now()} + if err := db.CreateC2Task(task); err != nil { + t.Fatal(err) + } + key, err := GenerateAESKey() + if err != nil { + t.Fatal(err) + } + l := &HTTPBeaconListener{rec: &database.C2Listener{ID: "http_other", EncryptionKey: key, ImplantToken: "test-only-token"}, manager: m, logger: zap.NewNop()} + raw, _ := json.Marshal(TaskResultReport{TaskID: task.ID, Success: true, Output: "forged"}) + body, err := EncryptAESGCM(key, raw) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/result", strings.NewReader(body)) + req.Header.Set("X-Implant-Token", "test-only-token") + rr := httptest.NewRecorder() + l.handleResult(rr, req) + if rr.Code == http.StatusOK { + t.Fatal("accepted foreign listener result") + } + saved, _ := db.GetC2Task(task.ID) + if saved.Status != "sent" || saved.ResultText != "" { + t.Fatalf("foreign result persisted: %#v", saved) + } + req = httptest.NewRequest(http.MethodGet, "/tasks?session_id=session", nil) + req.Header.Set("X-Implant-Token", "test-only-token") + rr = httptest.NewRecorder() + l.handleTasks(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("foreign session task request status: %d", rr.Code) + } +} + +func TestCheckInCannotMoveAnExistingSessionToAnotherListener(t *testing.T) { + m, db := terminalTestManager(t) + if _, err := m.IngestCheckIn("foreign-listener", ImplantCheckInRequest{ImplantUUID: "test-uuid", Hostname: "forged"}); err != ErrAuthFailed { + t.Fatalf("expected rejection, got %v", err) + } + saved, err := db.GetC2Session("session") + if err != nil || saved.ListenerID != "listener" || saved.Hostname == "forged" { + t.Fatalf("session changed: %#v %v", saved, err) + } +} diff --git a/internal/c2/tcp_beacon_server.go b/internal/c2/tcp_beacon_server.go index 9c044196..5672b28f 100644 --- a/internal/c2/tcp_beacon_server.go +++ b/internal/c2/tcp_beacon_server.go @@ -72,6 +72,7 @@ func tcpBeaconCheckToken(expected, got string) bool { // handleTCPBeaconSession 处理已消费魔数 CSB1 之后的 TCP Beacon 会话(与 HTTP Beacon 相同的 AES-GCM + JSON 语义)。 func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Reader) { var writeMu sync.Mutex + var boundSessionID string defer func() { _ = conn.Close() }() @@ -144,6 +145,10 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea l.logger.Warn("tcp beacon check_in", zap.Error(err)) return } + if boundSessionID != "" && boundSessionID != session.ID { + return + } + boundSessionID = session.ID queued, _ := l.manager.DB().ListC2Tasks(database.ListC2TasksFilter{ SessionID: session.ID, Status: string(TaskQueued), @@ -163,7 +168,7 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea return } var sessionID string - if err := json.Unmarshal(rawSID, &sessionID); err != nil || sessionID == "" { + if err := json.Unmarshal(rawSID, &sessionID); err != nil || sessionID == "" || sessionID != boundSessionID { return } sess, err := l.manager.DB().GetC2Session(sessionID) @@ -188,7 +193,10 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea if err := json.Unmarshal(raw, &report); err != nil { return } - if err := l.manager.IngestTaskResult(report); err != nil { + if boundSessionID == "" { + return + } + if err := l.manager.IngestTaskResultFromListener(l.rec.ID, boundSessionID, report); err != nil { return } resp = map[string]string{"ok": "1"} diff --git a/internal/database/asset.go b/internal/database/asset.go index 846bb25a..183bdd9f 100644 --- a/internal/database/asset.go +++ b/internal/database/asset.go @@ -135,8 +135,10 @@ func normalizeAsset(a *Asset) { if strings.Contains(a.Host, "://") { if parsed, err := url.Parse(a.Host); err == nil && parsed.Hostname() != "" && parsed.User == nil { hostname := strings.Trim(strings.ToLower(parsed.Hostname()), "[]") - if net.ParseIP(hostname) != nil && a.IP == "" { - a.IP = hostname + if net.ParseIP(hostname) != nil { + if a.IP == "" { + a.IP = hostname + } } else if a.Domain == "" { if ascii, err := idna.Lookup.ToASCII(hostname); err == nil { a.Domain = strings.ToLower(ascii) diff --git a/internal/database/asset_test.go b/internal/database/asset_test.go index b9418d9b..18cc637b 100644 --- a/internal/database/asset_test.go +++ b/internal/database/asset_test.go @@ -447,3 +447,25 @@ func TestAssetListFlexibleFiltersAndOldestScanPagination(t *testing.T) { t.Fatalf("structured filters: total=%d assets=%#v err=%v", total, filtered, err) } } + +func TestAssetIPURLWithPrefilledIPDoesNotBecomeDomain(t *testing.T) { + db, err := NewDB(filepath.Join(t.TempDir(), "ip-url.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + cases := []*Asset{ + {Host: "http://192.168.200.100:18081", IP: "192.168.200.100", Port: 18081, Protocol: "http"}, + {Host: "http://[2001:db8::1]:18081", IP: "2001:db8::1", Port: 18081, Protocol: "http"}, + {Host: "http://192.0.2.1:18081"}, + } + for _, a := range cases { + result, err := db.UpsertAssets([]*Asset{a}, "") + if err != nil || result.Created != 1 { + t.Fatalf("rejected IP URL: %#v %v", result, err) + } + if a.Domain != "" || a.IP == "" || a.Port != 18081 { + t.Fatalf("wrong structured fields: %#v", a) + } + } +} diff --git a/internal/handler/batch_task_manager.go b/internal/handler/batch_task_manager.go index 1f910fc3..7343dec7 100644 --- a/internal/handler/batch_task_manager.go +++ b/internal/handler/batch_task_manager.go @@ -757,6 +757,9 @@ func batchQueueConcurrencyFromRow(row *database.BatchTaskQueueRow) int { // UpdateQueueMetadata 更新队列标题、角色、代理模式和并发数(非 running 时可用) func (m *BatchTaskManager) UpdateQueueMetadata(queueID, title, role, agentMode string, concurrency *int, hitlPolicies ...string) error { + if concurrency != nil && (*concurrency < 1 || *concurrency > MaxBatchQueueConcurrency) { + return fmt.Errorf("并发数必须为 1–%d", MaxBatchQueueConcurrency) + } if utf8.RuneCountInString(title) > MaxBatchQueueTitleLen { return fmt.Errorf("标题不能超过 %d 个字符", MaxBatchQueueTitleLen) } diff --git a/internal/handler/form_validation_test.go b/internal/handler/form_validation_test.go new file mode 100644 index 00000000..9c3276bf --- /dev/null +++ b/internal/handler/form_validation_test.go @@ -0,0 +1,75 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/database" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestProjectUpdateRejectsBlankNameWithoutPartialSave(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "project.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + project, err := db.CreateProject(&database.Project{Name: "original", Description: "old", Status: "active"}) + if err != nil { + t.Fatal(err) + } + r := gin.New() + r.PUT("/projects/:id", NewProjectHandler(db, zap.NewNop()).UpdateProject) + for _, body := range []string{`{"name":"","description":"new"}`, `{"name":" "}`} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/projects/"+project.ID, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + saved, err := db.GetProject(project.ID) + if err != nil || saved.Name != "original" || saved.Description != "old" { + t.Fatalf("partial save: %#v %v", saved, err) + } + } + // An omitted name remains a valid partial update. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/projects/"+project.ID, strings.NewReader(`{"description":"updated"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("partial update: %d %s", rec.Code, rec.Body) + } +} + +func TestBatchConcurrencyEditRejectsOutOfRangeWithoutMutation(t *testing.T) { + m := NewBatchTaskManager(zap.NewNop()) + q, err := m.CreateBatchQueue("original", "", "eino_single", "manual", "", "", nil, 3, []string{"hello"}) + if err != nil { + t.Fatal(err) + } + for _, n := range []int{-1, 0, 9, 99} { + if err := m.UpdateQueueMetadata(q.ID, "changed", "", "eino_single", &n); err == nil { + t.Fatalf("accepted %d", n) + } + saved, _ := m.GetBatchQueue(q.ID) + if saved.Concurrency != 3 || saved.Title != "original" { + t.Fatalf("mutated queue: %#v", saved) + } + } + for _, n := range []int{1, 8} { + if err := m.UpdateQueueMetadata(q.ID, "original", "", "eino_single", &n); err != nil { + t.Fatal(err) + } + saved, _ := m.GetBatchQueue(q.ID) + if saved.Concurrency != n { + t.Fatalf("saved %d, want %d", saved.Concurrency, n) + } + } +} diff --git a/internal/handler/project.go b/internal/handler/project.go index c671ac65..a6f62627 100644 --- a/internal/handler/project.go +++ b/internal/handler/project.go @@ -198,9 +198,12 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) { return } if req.Name != nil { - if s := strings.TrimSpace(*req.Name); s != "" { - p.Name = s + s := strings.TrimSpace(*req.Name) + if s == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "项目名称不能为空"}) + return } + p.Name = s } if req.Description != nil { p.Description = clampProjectDescription(*req.Description) diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index bbf57bdb..7a967717 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3619,6 +3619,7 @@ "completedAt": "Completed at", "taskTotal": "Total tasks", "concurrency": "Concurrency", + "concurrencyInvalid": "Concurrency must be an integer from 1 to 8", "concurrencyEditHint": "Click to edit. Cannot change while the queue is running.", "taskList": "Task list", "startLabel": "Start", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 4e6177b6..505cf51b 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -3631,6 +3631,7 @@ "completedAt": "Завершено", "taskTotal": "Всего задач", "concurrency": "Параллельность", + "concurrencyInvalid": "Параллелизм должен быть целым числом от 1 до 8", "concurrencyEditHint": "Нажмите, чтобы изменить. Нельзя менять, пока очередь выполняется.", "taskList": "Список задач", "startLabel": "Старт", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index f24783ef..bbc8be38 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3607,6 +3607,7 @@ "completedAt": "完成时间", "taskTotal": "任务总数", "concurrency": "并发数", + "concurrencyInvalid": "并发数必须为 1–8", "concurrencyEditHint": "点击可修改;队列运行中不可改。", "taskList": "任务列表", "startLabel": "开始", diff --git a/web/static/js/form-validation.test.cjs b/web/static/js/form-validation.test.cjs new file mode 100644 index 00000000..01a1be9a --- /dev/null +++ b/web/static/js/form-validation.test.cjs @@ -0,0 +1,62 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const taskSource = fs.readFileSync('web/static/js/tasks.js','utf8'); +function concurrencyHarness(value) { + let calls = 0, message, focused = false; + const input = {value, focus() { focused = true; }}; + const ctx = {_bqInlineSaving:false, batchQueuesState:{currentQueueId:'q'}, + document:{getElementById:() => input}, _t:x => x, alert:x => {message=x;}, + apiFetch:async () => {calls++;return {ok:true,json:async () => ({queue:{title:'title'}})};}, + showBatchQueueDetail() {},refreshBatchQueues() {},console}; + vm.createContext(ctx); + const start = taskSource.indexOf('async function saveInlineConcurrency('); + vm.runInContext(taskSource.slice(start,taskSource.indexOf('// --- 单条执行 ---',start)),ctx); + return {ctx,calls:() => calls,message:() => message,focused:() => focused}; +} +test('invalid concurrency leaves the editor open and makes no API calls', async () => { + for (const value of ['0','9','1.5','2abc','']) { + const h=concurrencyHarness(value); + await h.ctx.saveInlineConcurrency(); + assert.equal(h.calls(),0); + assert.equal(h.message(),'tasks.concurrencyInvalid'); + assert.equal(h.focused(),true); + assert.equal(h.ctx._bqInlineSaving,false); + } +}); +test('valid concurrency updates and refreshes normally',async () => { + for (const value of ['1','8']) { + const h=concurrencyHarness(value); + await h.ctx.saveInlineConcurrency(); + assert.equal(h.calls(),2); + assert.equal(h.message(),undefined); + } +}); +test('blank project name stops before making a save request',async () => { + const source=fs.readFileSync('web/static/js/projects.js','utf8'); + let calls=0,message; + const ctx={currentProjectId:'p',requireProjectWrite:() => true, + document:{getElementById:() => ({value:' '})},tp:x => x,alert:x => {message=x;},apiFetch:() => {calls++;}}; + vm.createContext(ctx); + const start=source.indexOf('async function saveProjectSettings('); + vm.runInContext(source.slice(start,source.indexOf('function findProjectById(',start)),ctx); + await ctx.saveProjectSettings(); + assert.equal(calls,0); + assert.equal(message,'projects.enterProjectName'); +}); +test('RBAC creation buttons use the existing permission visibility guard',() => { + const html=fs.readFileSync('web/templates/index.html','utf8'); + const buttons=html.match(/]*onclick="openRbac(?:Role|User)Modal\(\)"[^>]*>/g); + assert.ok(buttons.length>=3); + for (const b of buttons) assert.match(b,/data-require-permission="rbac:write"/); + const auth=fs.readFileSync('web/static/js/auth.js','utf8'); + const ctx={permissionAllowedForElement:() => false}; + vm.createContext(ctx); + const start=auth.indexOf('function applyPermissionElement('); + vm.runInContext(auth.slice(start,auth.indexOf('let permissionClickGuardInstalled',start)),ctx); + const button={disabled:false,hidden:false,getAttribute:key => key==='data-require-permission'?'rbac:write':null,classList:{toggle() {}},setAttribute() {}}; + ctx.applyPermissionElement(button); + assert.equal(button.hidden,true); + assert.equal(button.disabled,true); +}); diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 614c7dd7..a75dfb3c 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -2099,6 +2099,8 @@ function insertProjectScopeExample() { async function saveProjectSettings() { if (!currentProjectId || !requireProjectWrite()) return; + const name = document.getElementById('project-edit-name').value.trim(); + if (!name) return alert(tp('projects.enterProjectName')); const scopeRaw = document.getElementById('project-edit-scope').value.trim(); if (scopeRaw) { try { @@ -2109,7 +2111,7 @@ async function saveProjectSettings() { } } const body = { - name: document.getElementById('project-edit-name').value.trim(), + name, description: clampProjectDescription(document.getElementById('project-edit-description').value), scope_json: scopeRaw, status: document.getElementById('project-edit-status')?.value || 'active', diff --git a/web/static/js/tasks.js b/web/static/js/tasks.js index c77b483a..3d545b48 100644 --- a/web/static/js/tasks.js +++ b/web/static/js/tasks.js @@ -2733,7 +2733,13 @@ async function saveInlineConcurrency() { const queueId = batchQueuesState.currentQueueId; if (!queueId) { _bqInlineSaving = false; return; } const inp = document.getElementById('bq-edit-concurrency'); - const concurrency = normalizeBatchQueueConcurrencyInput(inp ? inp.value : 1); + const concurrency = inp && inp.value.trim() !== '' ? Number(inp.value) : NaN; + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 8) { + _bqInlineSaving = false; + alert(_t('tasks.concurrencyInvalid')); + if (inp) inp.focus(); + return; + } try { const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`); const detail = await detailResp.json(); diff --git a/web/templates/index.html b/web/templates/index.html index 6a41cc69..117f5784 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -5185,8 +5185,8 @@
- - + +
@@ -5411,7 +5411,7 @@
- +