Rust in Everything
基于 sqlx 0.9.0

dynamic SQL strings should be audited for possible injections

主线位置:查询主线的第一步。sqlx::query(sql) 把 sql 转成 SqlStr 时要求 impl SqlSafeStr,0.8.6 能编译的 query(&format!(...)) 到这里就过不去。SqlSafeStr 的设计和来历见 Query、QueryAs 与 SqlSafeStr,本页只讲升级时碰到它怎么改。

flowchart TB
  sql["要执行的 SQL"] --> lit{"字面量吗"}
  lit -->|是| ok["直接传"]
  lit -->|否| part{"动态的是什么"}
  part -->|值| bind["$N 加 bind"]
  part -->|"表名列名"| white["白名单加 Assert"]
  part -->|"条件或批量"| qb["QueryBuilder"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0"。

现象

src/bin/sqlsafe_fail.rs,0.8.6 里常见的写法:

  let name = "pen";
  let rows = sqlx::query(&format!("select id from item where name = '{name}'"))
    .fetch_all(&pool)
    .await?;
error[E0277]: dynamic SQL strings should be audited for possible injections
   --> src/bin/sqlsafe_fail.rs:7:26
    |
  7 |   let rows = sqlx::query(&format!("select id from item where name = '{name}'"))
    |              ----------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dynamic SQL string
    |              |
    |              required by a bound introduced by this call
    |
    = help: the trait `SqlSafeStr` is not implemented for `&std::string::String`
    = note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.
            
            To bypass this error, manually audit for potential injection vulnerabilities and wrap with `AssertSqlSafe()`.
            For details, see the docs for `SqlSafeStr`.
            
    = note: this trait is only implemented for `&'static str`, not all `&str` like the compiler error may suggest
help: the trait `SqlSafeStr` is implemented for `&str`
   --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sqlx-core-0.9.0/src/sql_str.rs:52:1
    |
 52 | impl SqlSafeStr for &'static str {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `sqlx::query`
   --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sqlx-core-0.9.0/src/query.rs:653:32
    |
653 | pub fn query<'a, DB>(sql: impl SqlSafeStr) -> Query<'a, DB, <DB as Database>::Arguments>
    |                                ^^^^^^^^^^ required by this bound in `query`

For more information about this error, try `rustc --explain E0277`.

另一种写法没有这么友好的提示。把拼好的字符串用 as_str() 交给 Executor::execute(src/bin/sqlsafe_fail2.rs):

  let sql = format!("update item set note = null where name = '{name}'");
  pool.execute(sql.as_str()).await?;
error[E0597]: `sql` does not live long enough
  --> src/bin/sqlsafe_fail2.rs:8:16
   |
 7 |   let sql = format!("update item set note = null where name = '{name}'");
   |       --- binding `sql` declared here
 8 |   pool.execute(sql.as_str()).await?;
   |   -------------^^^----------
   |   |            |
   |   |            borrowed value does not live long enough
   |   argument requires that `sql` is borrowed for `'static`
 9 |   Ok(())
10 | }
   | - `sql` dropped here while still borrowed
   |
note: requirement that the value outlives `'static` introduced here
  --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sqlx-core-0.9.0/src/executor.rs:63:17
   |
63 |         E: 'q + Execute<'q, Self::Database>,
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0597`.

这是同一个原因换了一种报法:&str 在类型上满足 SqlSafeStr(实现是 &'static str),于是类型检查通过,到借用检查时才发现它不是 'static,on_unimplemented 的提示没有机会出现。看到「borrowed for 'static」指向 sqlx 的某个函数,多半就是这个坑。

出处

SqlSafeStr 上的 #[diagnostic::on_unimplemented] 给出了标题、dynamic SQL string 标签和两条 note。实现它的只有 &'static str、AssertSqlSafe<T>(T 是 &str、String、Box<str>、Arc<str>、Arc<String>、Cow<'static, str>)和 SqlStr 自己。收它的入口:query、query_as、query_scalar、raw_sql,以及把字符串直接交给 Executor 时用的 impl Execute for T where T: SqlSafeStr。

AssertSqlSafe 是一个什么都不做的 newtype。文档写得很直白:「SQLx does not provide any sort of sanitization」。它不转义、不检查,只是让「把动态字符串当 SQL 执行」在代码里留下一个能搜到的词。

修法

最省事的改法是把 format! 包进 AssertSqlSafe,能编译,但注入还在。src/bin/sqlsafe_fix.rs 用一个恶意的 name 比较几种改法:

  let name = "x' or '1'='1";

  // what the format! version would have done once wrapped in AssertSqlSafe
  let ids: Vec<i64> = sqlx::query_scalar(AssertSqlSafe(format!("select id from item where name = '{name}'")))
    .fetch_all(&pool)
    .await?;
  println!("format!+Assert -> {ids:?}");

  // 1. values: bind
  let ids: Vec<i64> = sqlx::query_scalar("select id from item where name = $1")
    .bind(name)
    .fetch_all(&pool)
    .await?;
  println!("bind           -> {ids:?}");

  // 2. identifiers: pick from a fixed list, then assert
  let sort = "price; drop table item";
  let column = match sort {
    "name" | "price" | "created_at" => sort,
    _ => "id",
  };
  let ids: Vec<i64> = sqlx::query_scalar(AssertSqlSafe(format!("select id from item order by {column} desc")))
    .fetch_all(&pool)
    .await?;
  println!("whitelist      -> order by {column}: {ids:?}");

  // 3. optional filters: QueryBuilder
  let min_price: Option<f64> = Some(5.0);
  let has_note: Option<bool> = None;
  let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select id from item where true");
  if let Some(p) = min_price {
    qb.push(" and price >= ").push_bind(p).push("::numeric");
  }
  if let Some(n) = has_note {
    qb.push(" and (note is not null) = ").push_bind(n);
  }
  println!("QueryBuilder   -> {}", qb.sql().as_str());
  let ids = qb.build_query_scalar::<i64>().fetch_all(&pool).await?;
  println!("               -> {ids:?}");
format!+Assert -> [1, 2, 3]
bind           -> []
whitelist      -> order by id: [3, 2, 1]
QueryBuilder   -> select id from item where true and price >= $1::numeric
               -> [1]
  • 第一行:AssertSqlSafe(format!(...)) 把三行全部返回了,' or '1'='1 成了 SQL 的一部分。这正是 0.9 想拦住的写法,换个包装不算修好。
  • 值用 $N 加 bind:值走 Bind 消息,不进 SQL 文本,恶意字符串只是一个查不到的名字。能用 bind 的地方都用 bind,SQL 保持字面量,AssertSqlSafe 根本不用出现。
  • 表名、列名、ORDER BY 方向不能做参数,只能拼进文本。从一个固定的集合里选,选不中就用默认值,再包 AssertSqlSafe。拼进去的只可能是代码里写死的几个字面量之一。
  • 结构是动态的(可选条件、不定长的值列表、批量插入)用 QueryBuilder:push 拼结构,push_bind 放值并自动写 $N。它的 new / push 不要求 SqlSafeStr,安全靠的是只用 push_bind 放值。IN 列表另有更好的写法,见 operator does not exist: bigint = bigint[]。

还有一种情况是 SQL 来自文件或配置(迁移脚本、运维工具里用户自己写的查询)。这时字符串本来就是 SQL,没有可以拦的东西,直接 AssertSqlSafe(sql),并在旁边写明它从哪来。

怎么确认没中招

  • 升级到 0.9 后编译不过的每一处,都按上面的顺序改;不要统一替换成 AssertSqlSafe(...)。
  • 搜 AssertSqlSafe:每一处的 format! 参数要么是从固定集合里选出来的字面量,要么是整数这类不可能带引号的值(sqlx 自己生成保存点语句时就是 AssertSqlSafe(format!("SAVEPOINT _sqlx_savepoint_{depth}"))),要么是注明来源的整段 SQL。
  • 搜 QueryBuilder 的 push(:参数里不应该出现用户输入。

小结

  • 0.9 的 query 系列和 raw_sql 只收 &'static str、AssertSqlSafe<T> 和 SqlStr。format! 出来的 String 报 E0277 加这段专门的提示;借来的非 'static &str 报 E0597「borrowed for 'static」。
  • AssertSqlSafe 不转义也不检查,只是一个能被搜到的标记;包住 format! 的注入仍然有效。
  • 修法按顺序:值用 bind;标识符从白名单选再 AssertSqlSafe;动态结构用 QueryBuilder。

💬 关联讨论

发起讨论
加载中...