db.raw()不是防注入保险箱,因其不解析sql、仅原样传递字符串;占位符参数才安全,表名等结构部分须白名单校验。

db.Raw() 为什么不是防注入的保险箱
很多人以为调用 db.Raw() 就等于进了安全区,其实它只是把字符串原样交给数据库驱动执行——GORM 不做任何 SQL 解析或参数校验。你传进去的是拼接好的字符串,它就照单全收;你传的是带占位符的模板,它才走参数绑定流程。
常见错误现象:用 fmt.Sprintf() 拼接表名、字段名或条件,再塞进 db.Raw(),比如 db.Raw(fmt.Sprintf("SELECT * FROM %s WHERE id = ?", tableName))。这和直接写 db.Query() 字符串拼接没区别,只是多了一层 ORM 包装。
-
db.Raw("SELECT * FROM users WHERE name = ?", name)安全:占位符被 GORM 正确识别并绑定 -
db.Raw("SELECT * FROM " + tableName + " WHERE id = " + strconv.Itoa(id))危险:纯字符串拼接,用户控制tableName就能切换任意表 -
db.Raw(fmt.Sprintf("SELECT * FROM %s", tableName)).Scan(&users)危险:Scan()只负责读结果,不干预 SQL 构建过程
哪些地方必须白名单校验,不能靠占位符
SQL 标准规定,表名、字段名、排序方向(ASC/DESC)、GROUP BY 子句等属于查询结构,在数据库编译阶段就必须确定,无法运行时参数化。GORM 的占位符(? 或 $1)对这些位置完全无效。
使用场景:按用户选择的字段排序、租户隔离查不同后缀表、动态聚合维度。
- 排序字段:定义
validSortFields := map[string]bool{"created_at": true, "score": true, "name": true},查不到就拒掉 - 排序方向:只接受
"ASC"或"DESC"字面量,别信strings.ToUpper(dir)后直接拼 - 表名映射:用
switch tenantID显式列出允许值,如"prod" → "users_prod",而不是"users_" + tenantID - 字段别名或函数:如
COUNT(*)、JSON_EXTRACT,也得从预设列表里选,不能由用户自由输入
GORM 开启 PrepareStmt 能否兜底
开启 PrepareStmt: true(GORM v2 默认开启)确实会让大部分常规查询走预处理语句,但它只作用于 GORM 自动生成的 SQL,对 db.Raw() 里的内容完全不干预。也就是说,Raw() 是绕过 GORM 查询构建逻辑的“逃生通道”,PrepareStmt 对它无效。
性能 / 兼容性影响:强制开启 PrepareStmt 对连接池、MySQL 8.0+ 的 server-prepared-statement 行为有影响,但这是次要问题;主要问题是误以为开了就万事大吉。
- 检查是否生效:启动时看日志是否有
prepare statement enabled,但这不保证Raw()安全 - 禁用
Raw()并非总是可行,但凡要用,就得配套白名单 + 单元测试覆盖边界输入 - 更稳妥的做法是封装一个
SafeRaw()工具函数,内部先校验再调用db.Raw(),避免散落各处的手动拼接
JSON 配置里藏 SQL 模板也危险
有些系统把 SQL 模板存在数据库或 JSON 配置文件里,运行时读出来再用 db.Raw() 执行。只要模板里留了字符串插槽(比如 "WHERE status = '${status}'"),而填充逻辑又没走参数绑定,就等于在配置里埋雷。
容易踩的坑:以为“配置不是代码,所以不用审计”,或者用 sql.RawBytes 去接配置值,误以为类型安全——sql.RawBytes 只是扫描二进制列的容器,跟 SQL 构建毫无关系。
- 所有外部来源的 SQL 片段(JSON/YAML/DB 配置)都必须视为不可信输入
- 模板中只允许出现占位符(
?或$1),禁止任何形式的字符串插值语法 - 若必须支持动态结构,应拆解为字段白名单 + 条件树解析器,而非拼 SQL 字符串
实际中最容易被忽略的一点:白名单校验必须在参数绑定之前做,且不能依赖前端传来的“看起来合法”的值——攻击者可以绕过前端直接调后端接口,所有校验必须落在服务端入口。










