diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index 86bff03b..19041c2a 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -80,3 +80,9 @@ - Excel 第 8、9 行:截图与模板对照证明灰色 CN/Beijing 是 placeholder,省市是手工输入,不是时区识别结果。示例增加中、英、俄翻译;不修改用户保存的地理字段,也不新增地区识别或联动能力。 - Excel 第 10 行:CSV 与 XLSX 表头采用当前语言,环境、重要性、状态与已知风险等级显示本地化标签。原始地址、地区、项目、标签、未知枚举值保持原样。导入兼容旧英文列名与当前语言的导出列名、枚举值。 - 验证:7 项测试通过,包含使用仓库内实际 SheetJS 生成和读取 CSV/XLSX,再调用真实导入解析;中、英、俄三种语言均验证地址、项目归属、带引号/换行标题、标签和枚举值不丢失。 + +## 追加:监听器表单与保存入口 + +- Excel 第 50 行:编辑保存使用 document 点击委托,但 c2-modal 内层 inline stopPropagation 阻止按钮到达委托。移除内层阻断;背景关闭本来已检查 event.target===this,不会因内部点击关闭。验证实际委托分发到真实 saveListener 后发送 PUT 并在成功后关闭。 +- Excel 第 23 行:名称、类型、端口增加必填标识;创建/编辑均提示并聚焦具体错误字段,端口仅允许 1–65535 整数,不再接受 parseInt 的小数或尾随文本。网络/响应解析失败显示错误并保留表单。 +- 编辑 API 在修改字段前检查空名称和端口范围;数据库回归验证非法请求不部分保存、合法编辑正常落库。5 项前端测试和针对性后端测试通过。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 261d71eb..c10cab52 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -25,7 +25,7 @@ | 20 | — | 量任务已经执行结束,任务管理页也将队列标记为“已完成 100%”,但该任务创建的容器仍长期保持“ | 当前仓库未实现,不扩展能力 | | 21 | 19 | webshell 连接口令明文 | 已修复并有针对性验证 | | 22 | 20 | 不知道这个是啥,应该是样式问题 | 与第 25 行同一 WebShell 路径/假文件问题,已修复 | -| 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 待核对 | +| 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 已修复必填标识、逐字段提示及端口严格校验;前后端回归通过 | | 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 终态已修复,其他任务路径待核对 | | 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 | | 26 | 24 | 工作流 id 由客户端随便填、零校验 | 已修复新 ID 格式/长度、重复创建及编辑改 ID 校验 | @@ -52,7 +52,7 @@ | 47 | — | nmap 参数缺少前置校验 | 待核对 | | 48 | — | 对单个资产发起“只读连通性与服务指纹核验”后,Agent 已通过 nmap 和 HTTP 指纹工 | 待核对 | | 49 | — | C2 会话终端执行没有换行结尾的命令时,系统内部用于获取当前目录的 pwd 输出会与用户命令输出 | 已修复,Unix Shell 回归通过 | -| 50 | — | 监听器编辑后点击保存没有反应 | 待核对 | +| 50 | — | 监听器编辑后点击保存没有反应 | 已修复弹窗事件冒泡阻断;实际委托保存和失败提示回归通过 | | 51 | — | 创建 Malleable Profile 时 UA、响应头和 Jitter 配置被静默丢弃 | 已修复并有针对性验证 | | 52 | — | C2 载荷生成未校验回连地址,非法地址仍能生成 Oneliner | 待核对 | | 53 | — | 知识库索引构建失败原因不怎么太清晰 | 待核对 | diff --git a/internal/handler/c2.go b/internal/handler/c2.go index dd5156ec..77c54c4a 100644 --- a/internal/handler/c2.go +++ b/internal/handler/c2.go @@ -177,6 +177,15 @@ func (h *C2Handler) UpdateListener(c *gin.Context) { return } + if strings.TrimSpace(req.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "listener name is required"}) + return + } + if err := c2.SafeBindPort(req.BindPort); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // 若监听器在运行,不能修改关键字段 if h.mgr().IsListenerRunning(id) { if req.BindHost != listener.BindHost || req.BindPort != listener.BindPort { diff --git a/internal/handler/c2_listener_validation_test.go b/internal/handler/c2_listener_validation_test.go new file mode 100644 index 00000000..50fab25c --- /dev/null +++ b/internal/handler/c2_listener_validation_test.go @@ -0,0 +1,55 @@ +package handler + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "cyberstrike-ai/internal/c2" + "cyberstrike-ai/internal/database" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestC2ListenerEditRejectsInvalidFieldsWithoutSaving(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "listener.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + original := &database.C2Listener{ID: "listener", Name: "original", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 8080, Status: "stopped", CreatedAt: time.Now()} + if err := db.CreateC2Listener(original); err != nil { + t.Fatal(err) + } + h := NewC2Handler(c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop()) + r := gin.New() + r.PUT("/listeners/:id", h.UpdateListener) + for _, tc := range []struct { + name string + port int + status int + }{{"", 8080, 400}, {" ", 8080, 400}, {"changed", 0, 400}, {"changed", 65536, 400}, {"changed", 8081, 200}} { + body := fmt.Sprintf(`{"name":%q,"bind_host":"127.0.0.1","bind_port":%d,"remark":"new remark"}`, tc.name, tc.port) + req := httptest.NewRequest(http.MethodPut, "/listeners/listener", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != tc.status { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + saved, err := db.GetC2Listener("listener") + if err != nil { + t.Fatal(err) + } + if tc.status == 400 && (saved.Name != "original" || saved.BindPort != 8080 || saved.Remark != "") { + t.Fatalf("invalid request saved fields: %#v", saved) + } + if tc.status == 200 && (saved.Name != "changed" || saved.BindPort != 8081 || saved.Remark != "new remark") { + t.Fatalf("valid edit not saved: %#v", saved) + } + } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index df545fc0..11ea2bab 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -4144,6 +4144,10 @@ "failed": "Failed" }, "listeners": { + "nameRequired": "Enter a listener name", + "typeRequired": "Select a listener type", + "portInvalid": "Port must be an integer from 1 to 65535", + "saveFailed": "Failed to save listener", "title": "Listener Management", "create": "Create Listener", "name": "Name", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 9104482f..73f826b8 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -4156,6 +4156,10 @@ "failed": "Ошибка" }, "listeners": { + "nameRequired": "Введите имя слушателя", + "typeRequired": "Выберите тип слушателя", + "portInvalid": "Порт должен быть целым числом от 1 до 65535", + "saveFailed": "Не удалось сохранить слушатель", "title": "Управление слушателями", "create": "Создать слушатель", "name": "Имя", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 93e0dec9..d00e0080 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -4132,6 +4132,10 @@ "failed": "失败" }, "listeners": { + "nameRequired": "请输入监听器名称", + "typeRequired": "请选择监听器类型", + "portInvalid": "端口必须为 1–65535 的整数", + "saveFailed": "监听器保存失败", "title": "监听器管理", "create": "创建监听器", "name": "名称", diff --git a/web/static/js/c2-listener-form.test.cjs b/web/static/js/c2-listener-form.test.cjs new file mode 100644 index 00000000..8a9da19b --- /dev/null +++ b/web/static/js/c2-listener-form.test.cjs @@ -0,0 +1,66 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = fs.readFileSync('web/static/js/c2.js', 'utf8'); +function harness(overrides = {}, failure) { + const fields = Object.fromEntries(Object.entries({ name: 'listener', type: 'http', host: '127.0.0.1', port: '8080', ...overrides }).map(([key, value]) => ['c2-listener-' + key, { value, setAttribute(k, v) { this[k] = v; }, focus() { this.focused = true; }, setCustomValidity(v) { this.message = v; }, reportValidity() {} }])); + const requests = [], messages = []; + let closed = false; + const c = vm.createContext({ document: { getElementById: id => fields[id] }, C2: { listeners: [], closeModal() { closed = true; }, loadListeners() {} }, API_BASE: '/api/c2', c2t: key => key, showToast: (message, type) => messages.push({ message, type }), apiRequest: async (...args) => { requests.push(args); if (failure) throw Error(failure); return {}; } }); + vm.runInContext(source.slice(source.indexOf(' function validateListenerForm('), source.indexOf(' C2.startListener =')), c); + vm.runInContext(source.slice(source.indexOf(' C2.saveListener ='), source.indexOf(' // ============================================================================\n // 会话管理')), c); + return { c, fields, requests, messages, closed: () => closed }; +} +test('creation and editing reject invalid fields and identify the field', async () => { + for (const method of ['createListener', 'saveListener']) for (const values of [{ name: '' }, { port: '0' }, { port: '65536' }, { port: '1.5' }, { port: '80abc' }]) { + const h = harness(values); + await h.c.C2[method]('id'); + assert.equal(h.requests.length, 0); + assert.equal(h.messages.length, 1); + assert.ok(Object.values(h.fields).some(field => field.focused && field['aria-invalid'] === 'true')); + } +}); +test('valid edit sends PUT and closes only after success', async () => { + const h = harness(); + await h.c.C2.saveListener('id'); + assert.equal(h.requests[0][0], 'PUT'); + assert.equal(h.requests[0][1], '/api/c2/listeners/id'); + assert.equal(h.requests[0][2].bind_port, 8080); + assert.equal(h.closed(), true); +}); +test('transport failure is visible and keeps editor open', async () => { + for (const method of ['createListener', 'saveListener']) { + const h = harness({}, 'offline'); + await h.c.C2[method]('id'); + assert.equal(h.messages.at(-1).message, 'offline'); + assert.equal(h.messages.at(-1).type, 'error'); + assert.equal(h.closed(), false); + } +}); +test('modal allows click delegation while background close is target guarded', () => { + const html = fs.readFileSync('web/templates/index.html', 'utf8'); + const modal = html.slice(html.indexOf('