feat: SQL安全防护体系 — 危险SQL黑名单 + 事务回滚 + LIMIT修复

🔴 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全部放行
- 事务回滚验证通过
- 类型错误/参数错误正确报告
This commit is contained in:
logan committed 2026-06-03 02:49:07 +08:00
1 parent 8ceb249274
commit 8fbcb8647d
2 files changed
+84 -4

No files matched your search

+4 -2
View File
@@ -641,8 +641,10 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
} }
} }
// Inject LIMIT/OFFSET if not already present // Inject LIMIT/OFFSET if not already present — only for SELECT statements
if !hasLimit { // 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) sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
} }
+80 -2
View File
@@ -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 // Auto-fill workflow_id from instance if not provided
if input.WorkflowID == nil || *input.WorkflowID == 0 { if input.WorkflowID == nil || *input.WorkflowID == 0 {
var inst model.Instance var inst model.Instance
@@ -733,17 +793,23 @@ func ExecuteTicket(c *gin.Context) {
return return
} }
} }
// Split multi-statement by semicolons // Split multi-statement by semicolons — wrap in transaction for atomic rollback
execStart := time.Now() execStart := time.Now()
statements := splitSQL(ticket.SQLContent) statements := splitSQL(ticket.SQLContent)
execRes := SqlExecResult{Status: "success", StartTime: execStart.UTC().Format(time.RFC3339)} execRes := SqlExecResult{Status: "success", StartTime: execStart.UTC().Format(time.RFC3339)}
// 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 { for _, stmt := range statements {
stmt = strings.TrimSpace(stmt) stmt = strings.TrimSpace(stmt)
if stmt == "" { if stmt == "" {
continue continue
} }
stmtStart := time.Now() stmtStart := time.Now()
sqlResult, err := db.ExecContext(ctx, stmt) sqlResult, err := tx.ExecContext(ctx, stmt)
stmtMs := time.Since(stmtStart).Milliseconds() stmtMs := time.Since(stmtStart).Milliseconds()
if err != nil { if err != nil {
execRes.Items = append(execRes.Items, SqlExecItem{ execRes.Items = append(execRes.Items, SqlExecItem{
@@ -751,6 +817,8 @@ func ExecuteTicket(c *gin.Context) {
}) })
execRes.Status = "failed" execRes.Status = "failed"
execErr = err.Error() execErr = err.Error()
// Rollback on any failure
tx.Rollback()
break break
} }
affected, _ := sqlResult.RowsAffected() affected, _ := sqlResult.RowsAffected()
@@ -758,6 +826,16 @@ func ExecuteTicket(c *gin.Context) {
SQL: stmt, Status: "success", AffectedRows: affected, DurationMs: stmtMs, 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.TotalMs = time.Since(execStart).Milliseconds()
execRes.EndTime = time.Now().UTC().Format(time.RFC3339) execRes.EndTime = time.Now().UTC().Format(time.RFC3339)
if jsonBytes, err := json.Marshal(execRes); err == nil { if jsonBytes, err := json.Marshal(execRes); err == nil {