From 8fbcb8647d54b98b94fb28dcd53382a99ca7737d Mon Sep 17 00:00:00 2001 From: logan Date: Wed, 3 Jun 2026 02:49:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SQL=E5=AE=89=E5=85=A8=E9=98=B2=E6=8A=A4?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=20=E2=80=94=20=E5=8D=B1=E9=99=A9SQL=E9=BB=91?= =?UTF-8?q?=E5=90=8D=E5=8D=95=20+=20=E4=BA=8B=E5=8A=A1=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=20+=20LIMIT=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 P0 安全漏洞修复: - MySQL: 黑名单拦截 DROP DATABASE/DROP SCHEMA - MySQL: 拦截无WHERE的UPDATE/DELETE(全表操作) - MySQL: 多语句中含危险SQL也会被检测 - Redis: 拦截 FLUSHALL/FLUSHDB/CONFIG/DEBUG/SHUTDOWN/SCRIPT/SAVE/BGSAVE/KEYS/CLUSTER/REPLICAOF 🔄 事务支持: - MySQL多语句执行包裹在事务中 - 任一SQL失败自动回滚全部已执行语句 - 验证通过: INSERT成功+UNIQUE冲突=全部回滚 🔧 Bug修复: - query.go: LIMIT只追加SELECT语句(SHOW/CREATE等不再追加LIMIT报错) - SHOW VARIABLES/STATUS 恢复正常 📊 测试验证: - 29/29 安全拦截测试全部通过 - MySQL 8项危险操作全部拦截 - Redis 11项危险命令全部拦截 - 10项正常SQL全部放行 - 事务回滚验证通过 - 类型错误/参数错误正确报告 --- backend/internal/handler/instance_detail.go | 6 +- backend/internal/handler/ticket.go | 114 ++++++++++++++++---- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 850cbe7..9c1d364 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -641,8 +641,10 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim } } - // Inject LIMIT/OFFSET if not already present - if !hasLimit { + // Inject LIMIT/OFFSET if not already present — only for SELECT statements + // SHOW/DESC/EXPLAIN/CREATE/ALTER/DROP/etc do not support LIMIT syntax + needsLimit := !hasLimit && strings.HasPrefix(upperSQL, "SELECT") + if needsLimit { sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset) } diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index 66a93c3..4a9a94b 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -254,6 +254,66 @@ func CreateTicket(c *gin.Context) { } } + // ── Dangerous SQL blacklist ── + // MySQL: block DROP DATABASE / DROP SCHEMA + if input.DBType == "mysql" || input.DBType == "postgresql" || input.DBType == "postgres" || input.DBType == "pg" { + mysqlBlacklist := []struct { + prefix string + reason string + }{ + {"DROP DATABASE ", "禁止执行 DROP DATABASE — 删库属于极端危险操作"}, + {"DROP SCHEMA ", "禁止执行 DROP SCHEMA — 删库属于极端危险操作"}, + } + statements := splitSQL(input.SQLContent) + for _, stmt := range statements { + upper := strings.ToUpper(strings.TrimSpace(stmt)) + for _, bl := range mysqlBlacklist { + if strings.HasPrefix(upper, bl.prefix) { + c.JSON(http.StatusBadRequest, gin.H{"detail": bl.reason}) + return + } + } + // Warn: UPDATE/DELETE without WHERE + if (strings.HasPrefix(upper, "UPDATE ") || strings.HasPrefix(upper, "DELETE FROM ")) && + !strings.Contains(upper, " WHERE ") { + c.JSON(http.StatusBadRequest, gin.H{"detail": "禁止无 WHERE 条件的 UPDATE/DELETE — 全表更新/删除属于高危操作,请添加 WHERE 子句限定范围"}) + return + } + } + } + + // Redis: block extremely dangerous commands + if input.DBType == "redis" { + redisBlacklist := []struct { + cmd string + reason string + }{ + {"FLUSHALL", "禁止执行 FLUSHALL — 清空所有Redis数据"}, + {"FLUSHDB", "禁止执行 FLUSHDB — 清空当前数据库"}, + {"CONFIG ", "禁止执行 CONFIG — 修改Redis配置属于高危操作"}, + {"DEBUG ", "禁止执行 DEBUG — 调试命令可能导致服务异常"}, + {"SHUTDOWN", "禁止执行 SHUTDOWN — 关闭Redis服务"}, + {"SCRIPT ", "禁止执行 SCRIPT — 脚本管理属于高危操作"}, + {"SAVE", "禁止执行 SAVE — 阻塞式持久化"}, + {"BGSAVE", "禁止执行 BGSAVE — 后台持久化由运维管理"}, + {"BGREWRITEAOF", "禁止执行 BGREWRITEAOF — AOF重写由运维管理"}, + {"SLAVEOF", "禁止执行 SLAVEOF — 主从切换属于运维操作"}, + {"REPLICAOF", "禁止执行 REPLICAOF — 主从切换属于运维操作"}, + {"CLUSTER ", "禁止执行 CLUSTER — 集群管理属于运维操作"}, + {"KEYS ", "禁止执行 KEYS — 生产环境应使用 SCAN 替代,KEYS 会阻塞Redis"}, + } + cmdParts := parseCommandParts(input.SQLContent) + if len(cmdParts) > 0 { + upperCmd := strings.ToUpper(cmdParts[0]) + for _, bl := range redisBlacklist { + if upperCmd == strings.TrimSuffix(bl.cmd, " ") || strings.HasPrefix(upperCmd+" ", bl.cmd) { + c.JSON(http.StatusBadRequest, gin.H{"detail": bl.reason}) + return + } + } + } + } + // Auto-fill workflow_id from instance if not provided if input.WorkflowID == nil || *input.WorkflowID == 0 { var inst model.Instance @@ -733,30 +793,48 @@ func ExecuteTicket(c *gin.Context) { return } } - // Split multi-statement by semicolons + // Split multi-statement by semicolons — wrap in transaction for atomic rollback execStart := time.Now() statements := splitSQL(ticket.SQLContent) execRes := SqlExecResult{Status: "success", StartTime: execStart.UTC().Format(time.RFC3339)} - for _, stmt := range statements { - stmt = strings.TrimSpace(stmt) - if stmt == "" { - continue - } - stmtStart := time.Now() - sqlResult, err := db.ExecContext(ctx, stmt) - stmtMs := time.Since(stmtStart).Milliseconds() - if err != nil { + // Begin transaction for multi-statement execution + tx, txErr := db.BeginTx(ctx, nil) + if txErr != nil { + execErr = fmt.Sprintf("开启事务失败: %v", txErr) + } else { + committed := false + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + stmtStart := time.Now() + sqlResult, err := tx.ExecContext(ctx, stmt) + stmtMs := time.Since(stmtStart).Milliseconds() + if err != nil { + execRes.Items = append(execRes.Items, SqlExecItem{ + SQL: stmt, Status: "fail", DurationMs: stmtMs, Error: err.Error(), + }) + execRes.Status = "failed" + execErr = err.Error() + // Rollback on any failure + tx.Rollback() + break + } + affected, _ := sqlResult.RowsAffected() execRes.Items = append(execRes.Items, SqlExecItem{ - SQL: stmt, Status: "fail", DurationMs: stmtMs, Error: err.Error(), + SQL: stmt, Status: "success", AffectedRows: affected, DurationMs: stmtMs, }) - execRes.Status = "failed" - execErr = err.Error() - break } - affected, _ := sqlResult.RowsAffected() - execRes.Items = append(execRes.Items, SqlExecItem{ - SQL: stmt, Status: "success", AffectedRows: affected, DurationMs: stmtMs, - }) + if execErr == "" { + if commitErr := tx.Commit(); commitErr != nil { + execRes.Status = "failed" + execErr = fmt.Sprintf("事务提交失败: %v", commitErr) + } else { + committed = true + } + } + _ = committed } execRes.TotalMs = time.Since(execStart).Milliseconds() execRes.EndTime = time.Now().UTC().Format(time.RFC3339)