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:
1 parent
8ceb249274
commit
8fbcb8647d
2 files changed
+100
-20
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user