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)