Rails 的 ActiveRecord 自带参数化查询机制,这层防护让很多开发者误以为框架能自动拦截所有注入攻击。实际情况是,当业务逻辑需要拼接原始 SQL 片段时,防护边界就会出现裂缝。最典型的场景是使用 "where" 方法时直接传入字符串插值,比如 "User.where("email = '#{params[:email]}'")",这段代码把用户输入直接嵌进了 SQL 语句,攻击者输入 "' OR '1'='1" 就能绕过条件判断。

另一个容易被忽视的入口是 "order"、"group"、"select" 这些接受动态字段名的方法。Rails 对这些方法的参数不会自动做转义处理,因为字段名本身不是值,不能用引号包裹。如果代码写成 "Product.order(params[:sort_by])",攻击者可以通过构造 "price; DELETE FROM products;--" 这样的载荷来篡改查询逻辑。同样的问题也出现在 "pluck"、"reorder"、"from" 等方法上,它们都接受原始字符串参数。

更隐蔽的风险藏在 "Arel" 的底层调用中。"Arel.sql" 方法会标记字符串为已安全的 SQL 片段,绕过所有转义检查。有些开发者在处理复杂查询时,为了方便直接用 "Arel.sql("column_name")" 来构建条件,却忘了这个方法同样接受用户输入。一旦 "params[:column]" 被传入 "Arel.sql",注入攻击就畅通无阻了。

参数化查询的正确使用姿势

处理值类型的用户输入时,必须使用占位符语法。Rails 提供了三种等效写法:数组条件、哈希条件和命名占位符。数组条件是最常见的形式,"User.where("email = ?", params[:email])" 中的问号会被安全替换。多个值时按顺序对应,"User.where("email = ? AND role = ?", params[:email], params[:role])"。哈希条件更简洁,"User.where(email: params[:email])" 会自动生成参数化查询,但只适用于等值比较。

命名占位符在处理复杂查询时更清晰,"User.where("email = :email AND role = :role", email: params[:email], role: params[:role])" 避免了参数顺序错位的问题。对于 IN 查询,Rails 能自动展开数组,"Product.where(category_id: params[:category_ids])" 会生成 "WHERE category_id IN (?, ?, ?)" 这样的安全语句,不需要手动拼接逗号分隔的列表。

动态字段名的安全处理方案

当业务确实需要动态排序或分组时,最稳妥的做法是白名单验证。维护一个允许的字段列表,将用户输入与列表比对,不匹配就拒绝或回退到默认值。具体实现可以这样写:

ALLOWED_SORT_COLUMNS = %w[created_at price name stock].freeze

def sort_products
  column = ALLOWED_SORT_COLUMNS.include?(params[:sort_by]) ? params[:sort_by] : 'created_at'
  direction = %w[asc desc].include?(params[:direction]&.downcase) ? params[:direction] : 'desc'
  Product.order("#{column} #{direction}")
end

这里的 "column" 和 "direction" 都经过了严格的白名单过滤,确保拼进 SQL 的只能是预定义的合法值。如果字段名来自数据库 schema,也可以用 "Product.column_names" 动态获取允许列表,但要小心性能开销,最好缓存这个数组。

另一个思路是使用 ActiveRecord 提供的安全方法。"sanitize_sql_for_order" 能对排序条件做基础转义,但它的保护范围有限,不能完全替代白名单。对于表名,"ActiveRecord::Base.connection.quote_table_name" 可以添加引号保护,但同样建议结合白名单使用。依赖转义函数而不做白名单验证,本质上是在和攻击者玩猫鼠游戏。

like查询与特殊字符转义

LIKE 查询的注入风险常被低估。即使使用了参数化查询,"User.where("name LIKE ?", "%#{params[:keyword]}%")" 这种写法仍然存在安全隐患,因为 LIKE 通配符 "%" 和 "_" 在参数值中会被解释为模式匹配符。攻击者输入 "%" 可以匹配所有记录,输入 "_" 可以探测字段长度。正确的做法是转义这些特殊字符:

def safe_like(keyword)
  escaped = keyword.gsub(/[%_]/, '%' => '\%', '_' => '\_')
  "%#{escaped}%"
end

User.where("name LIKE ?", safe_like(params[:keyword]))

Rails 6.1 之后,"sanitize_sql_like" 方法可以直接完成这个转义,"ActiveRecord::Base.sanitize_sql_like(params[:keyword])" 会自动处理 "%" 和 "_" 的转义,再手动拼接 "%" 通配符即可。

find_by_sql与exec_query的风险控制

"find_by_sql" 和 "exec_query" 是直接执行原始 SQL 的方法,框架对它们几乎不设防。使用这些方法时,所有参数拼接都必须手动做参数化处理。最安全的方式是使用 "ActiveRecord::Base.sanitize_sql_array" 来构建完整的 SQL 语句:

sql = ActiveRecord::Base.sanitize_sql_array([
  "SELECT * FROM users WHERE email = ? AND status = ?",
  params[:email],
  params[:status]
])
results = User.find_by_sql(sql)

这个方法会将数组的第一个元素作为 SQL 模板,后续参数按顺序安全替换。对于更复杂的场景,"quote" 和 "quote_column_name" 方法可以分别处理值和标识符的转义,但除非万不得已,不要绕过 "sanitize_sql_array" 直接拼接。

批量操作与update_all的陷阱

"update_all"、"delete_all" 这类批量操作方法同样接受字符串条件,而且它们不会触发 ActiveRecord 回调,很多开发者在使用时会放松警惕。"Product.where("category = '#{params[:category]}'").update_all(price: 0)" 这样的代码和直接拼接 SQL 一样危险。正确写法是 "Product.where(category: params[:category]).update_all(price: 0)",让 "where" 方法自动处理参数化。

如果必须用字符串条件,就用占位符语法:"Product.where("category = ?", params[:category]).update_all(price: 0)"。批量操作的破坏范围大,一条注入语句可能影响全表数据,这个环节的防护标准应该比普通查询更严格。

JSON字段与PostgreSQL的特殊查询

Rails 对 JSON 字段的查询支持在不断完善,但底层仍然依赖数据库特定的操作符。PostgreSQL 的 JSON 查询操作符 "->"、"->>"、"@>" 等在 ActiveRecord 中通常通过 Hash 条件来表达,"User.where("preferences->>'theme' = ?", params[:theme])" 这种写法是安全的,因为值部分用了占位符。但如果动态构建 JSON 路径,比如 "User.where("preferences->'#{params[:key]}' IS NOT NULL")",键名部分就暴露了注入风险。处理 JSON 键名时同样需要白名单验证,或者使用 ActiveRecord 提供的 JSON 查询方法,"User.where("preferences @> ?", { params[:key] => params[:value] }.to_json)" 可以把整个 JSON 对象作为参数传递。

代码审查中的SQL注入检查清单

实际项目中,最有效的防线是代码审查时的系统性检查。重点扫描以下几个模式:字符串插值出现在 "where"、"order"、"group"、"having"、"select"、"from"、"joins"、"includes"、"pluck" 等方法参数中;"Arel.sql" 的调用参数是否包含用户输入;"find_by_sql" 和 "exec_query" 的参数是否经过 "sanitize_sql_array" 处理;"update_all" 和 "delete_all" 的条件参数是否安全。审查工具可以辅助,Brakeman 静态分析工具能自动检测大部分常见的注入模式,但它无法理解业务逻辑中的白名单验证,所以人工审查仍然是最后一道防线。

纵深防御的配置加固

除了代码层面的防护,数据库权限配置也是重要一环。应用连接的数据库用户应该遵循最小权限原则,只授予必要的 SELECT、INSERT、UPDATE、DELETE 权限,禁止 DROP、ALTER 等 DDL 操作。对于读取为主的查询接口,使用只读数据库用户连接。Rails 的 "database.yml" 可以配置多个数据库连接,敏感操作使用受限账户,即使注入成功,攻击者的破坏范围也会被压缩。

日志记录同样关键。Rails 默认会在日志中输出 SQL 语句,生产环境应该确保日志不记录敏感参数。"config.filter_parameters" 可以过滤密码、令牌等字段,但对于注入攻击的溯源来说,保留查询日志有助于发现异常模式。可以配置日志级别,在保留 SQL 日志的同时过滤掉参数值中的敏感部分,或者使用专门的审计日志系统记录所有查询语句,便于事后分析攻击载荷的特征。

Ruby on Rails 的防注入机制是分层设计的,框架默认的安全行为覆盖了大部分场景,但业务需求的复杂性决定了开发者必须理解每一层的边界。参数化查询解决值注入,白名单验证解决标识符注入,最小权限限制破坏范围,日志审计提供事后追溯能力。这四个层次的防护叠加起来,才能把 SQL 注入的风险降到可控范围内。