fix: validate form edits and enforce task result ownership

This commit is contained in:
Ed1s0nZ committed 2026-10-06 02:20:28 +08:00
1 parent ab17ffda87
commit bdefa535b0
19 files changed
+331 -20

No files matched your search

+9
View File
@@ -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 入库及结果来源拒绝均新增回归。全部包回归在提交前再次执行。
+6 -6
View File
@@ -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 参数缺少前置校验 | 待核对 |
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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 周期性送上心跳
+27
View File
@@ -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
+89
View File
@@ -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)
}
}
+10 -2
View File
@@ -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"}
+4 -2
View File
@@ -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)
+22
View File
@@ -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)
}
}
}
+3
View File
@@ -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)
}
+75
View File
@@ -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)
}
}
}
+5 -2
View File
@@ -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)
+1
View File
@@ -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",
+1
View File
@@ -3631,6 +3631,7 @@
"completedAt": "Завершено",
"taskTotal": "Всего задач",
"concurrency": "Параллельность",
"concurrencyInvalid": "Параллелизм должен быть целым числом от 1 до 8",
"concurrencyEditHint": "Нажмите, чтобы изменить. Нельзя менять, пока очередь выполняется.",
"taskList": "Список задач",
"startLabel": "Старт",
+1
View File
@@ -3607,6 +3607,7 @@
"completedAt": "完成时间",
"taskTotal": "任务总数",
"concurrency": "并发数",
"concurrencyInvalid": "并发数必须为 1–8",
"concurrencyEditHint": "点击可修改;队列运行中不可改。",
"taskList": "任务列表",
"startLabel": "开始",
+62
View File
@@ -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(/<button\b[^>]*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);
});
+3 -1
View File
@@ -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',
+7 -1
View File
@@ -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();
+3 -3
View File
@@ -5185,8 +5185,8 @@
</div>
<div class="page-header-actions">
<button class="btn-secondary" type="button" onclick="refreshPlatformRbac(this)" data-i18n="common.refresh">刷新</button>
<button class="btn-secondary" type="button" onclick="openRbacRoleModal()" data-i18n="rbac.createRole">新建角色</button>
<button class="btn-primary" type="button" onclick="openRbacUserModal()" data-i18n="rbac.createUser">添加成员</button>
<button class="btn-secondary" type="button" onclick="openRbacRoleModal()" data-require-permission="rbac:write" data-i18n="rbac.createRole">新建角色</button>
<button class="btn-primary" type="button" onclick="openRbacUserModal()" data-require-permission="rbac:write" data-i18n="rbac.createUser">添加成员</button>
</div>
</div>
<div class="page-content">
@@ -5411,7 +5411,7 @@
<label for="rbac-role-search" data-i18n="rbac.filterRoles">筛选角色</label>
<input id="rbac-role-search" class="form-input" type="search" placeholder="搜索角色或权限" data-i18n="rbac.searchRoles" data-i18n-attr="placeholder" oninput="setRbacRoleSearch(this.value)">
</div>
<button class="btn-primary btn-small" type="button" onclick="openRbacRoleModal()" data-i18n="rbac.createRole">新建角色</button>
<button class="btn-primary btn-small" type="button" onclick="openRbacRoleModal()" data-require-permission="rbac:write" data-i18n="rbac.createRole">新建角色</button>
</div>
</div>
<div id="rbac-role-catalog" class="rbac-role-catalog"></div>