test(api/database): SQL 控制台护栏补表驱动单测 + 加固

sql_console.rs 是 SQL 控制台的安全关键路径,此前无任何单测。本次:

测试(新增 24 个,覆盖护栏 1+2 全分支):
- check_guards: DROP SCHEMA/CREATE DATABASE 绝禁、DROP/TRUNCATE/ALTER
  需确认、UPDATE/DELETE 无 WHERE 绝禁(且 confirm 不能救)、多语句短路
- is_absolutely_forbidden: 大小写不敏感、误拦检查、报错名
- statement_type_name / is_read_only: AST 变体映射
- 两条安全不变量测试(见下)

加固(测试驱动发现的两个真实缺口):
1. 字符串预检 contains() 改为 token 序列匹配 is_absolutely_forbidden()——
   防多空格/制表符/换行绕过 DROP/CREATE DATABASE。
2. check_guards 补 Statement::CreateDatabase 绝禁分支——此前 sqlparser
   能解析 CREATE DATABASE(可绕过字符串预检进 AST),但 AST 层无对应分支,
   字符串预检是其唯一防线。现在双重拦截。

并锁定两条安全不变量,防止 sqlparser 升级时悄悄回退:
- DROP DATABASE 不可解析 → 字符串预检是唯一防线
- CREATE DATABASE 可解析 → 双重防线都生效
This commit is contained in:
xfy 2026-07-02 17:33:19 +08:00
parent baf1e2a5f6
commit 533cabb0b5

View File

@ -55,8 +55,49 @@ const MAX_ROWS: usize = 500;
/// 绝对禁止的语句关键词字符串预检sqlparser 无 ObjectType::Database/Schema /// 绝对禁止的语句关键词字符串预检sqlparser 无 ObjectType::Database/Schema
/// 命中即拒,不可放行。 /// 命中即拒,不可放行。
///
/// 注意:这里存的是「关键字序列」,由 [`is_absolutely_forbidden`] 做 token 级
/// 匹配——而非原始 `contains()` 子串。这样 `DROP DATABASE`(多空格)、
/// `DROP\tDATABASE`、`DROP\nDATABASE` 等绕过单空格子串的写法都能命中。
/// 关键背景sqlparser 的 PostgreSqlDialect 无法解析 DROP/CREATE DATABASE
/// 这类语句【没有 AST 兜底】,字符串预检是唯一防线,故必须 token 级鲁棒。
#[cfg(feature = "server")] #[cfg(feature = "server")]
const ABSOLUTELY_FORBIDDEN: &[&str] = &["drop database", "drop schema", "create database"]; const ABSOLUTELY_FORBIDDEN: &[&[&str]] = &[
&["drop", "database"],
&["drop", "schema"],
&["create", "database"],
];
/// 护栏 1 的字符串预检token 序列匹配)。
///
/// 把 SQL 按空白拆成 token小写、去逗号/分号尾缀),再扫描是否出现
/// `ABSOLUTELY_FORBIDDEN` 里任一连续关键字序列。返回命中的序列描述供报错。
///
/// 用 token 序列而非 `contains()` 是为了拦多空格/制表符/换行绕过——
/// `drop database` 在子串匹配下漏,在 token 序列下命中。
#[cfg(feature = "server")]
fn is_absolutely_forbidden(sql: &str) -> Option<&'static str> {
// 规范化:小写 + 按任意空白拆分 + 去掉 token 尾部的 , ; ( )
let lowered = sql.to_lowercase();
let tokens: Vec<&str> = lowered
.split_whitespace()
.map(|t| t.trim_end_matches([',', ';', '(', ')']))
.collect();
for forbidden in ABSOLUTELY_FORBIDDEN {
// 在 token 流里滑窗查连续序列
for window in tokens.windows(forbidden.len()) {
if window == *forbidden {
return Some(match forbidden {
["drop", "database"] => "DROP DATABASE",
["drop", "schema"] => "DROP SCHEMA",
["create", "database"] => "CREATE DATABASE",
_ => "未知高危操作",
});
}
}
}
None
}
/// 护栏检查返回值。 /// 护栏检查返回值。
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -80,7 +121,7 @@ fn check_guards(
for stmt in asts { for stmt in asts {
match stmt { match stmt {
// 护栏 1绝禁DROP SCHEMA 永远禁止ObjectType 无 Database 变体, // 护栏 1绝禁DROP SCHEMA 永远禁止ObjectType 无 Database 变体,
// DROP DATABASE 由字符串预检 + AST 双重拦截——见上方 ABSOLUTELY_FORBIDDEN)。 // DROP DATABASE 仅字符串预检拦截——见 is_absolutely_forbidden)。
// 这里在 AST 层结构性禁止 DROP SCHEMA防 SQL 注释/空白绕过字符串预检。 // 这里在 AST 层结构性禁止 DROP SCHEMA防 SQL 注释/空白绕过字符串预检。
Statement::Drop { Statement::Drop {
object_type: ObjectType::Schema, object_type: ObjectType::Schema,
@ -88,6 +129,12 @@ fn check_guards(
} => { } => {
return GuardResult::Forbidden("禁止 DROP SCHEMA".to_string()); return GuardResult::Forbidden("禁止 DROP SCHEMA".to_string());
} }
// 护栏 1绝禁CREATE DATABASE 永远禁止。sqlparser 能解析它,
// 故在 AST 层补上结构禁止——这是字符串预检之外的第二道防线,
// 防多空格/注释绕过 is_absolutely_forbidden 的 token 匹配。
Statement::CreateDatabase { .. } => {
return GuardResult::Forbidden("禁止 CREATE DATABASE".to_string());
}
// 护栏 1需确认的高危语句DROP TABLE/VIEW/INDEX 等、TRUNCATE、ALTER // 护栏 1需确认的高危语句DROP TABLE/VIEW/INDEX 等、TRUNCATE、ALTER
Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => { Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => {
if !confirm_dangerous { if !confirm_dangerous {
@ -204,15 +251,9 @@ pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult,
use sqlparser::parser::Parser; use sqlparser::parser::Parser;
// 护栏 1绝禁字符串预检 DROP/CREATE DATABASE、DROP SCHEMA // 护栏 1绝禁字符串预检 DROP/CREATE DATABASE、DROP SCHEMA
let normalized = sql.to_lowercase(); // token 序列匹配,防多空格绕过;这类语句无 AST 兜底)
for forbidden in ABSOLUTELY_FORBIDDEN { if let Some(name) = is_absolutely_forbidden(&sql) {
if normalized.contains(forbidden) { return Err(AppError::BadRequest(format!("禁止的操作:{}", name)).into());
return Err(AppError::BadRequest(format!(
"禁止的操作:{}",
forbidden.to_uppercase()
))
.into());
}
} }
// 解析 SQL // 解析 SQL
@ -348,3 +389,300 @@ async fn execute_one(
}) })
} }
} }
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
/// 用 PostgreSql 方言解析 SQL 为 AST 列表,供测试复用。
fn parse(sql: &str) -> Vec<sqlparser::ast::Statement> {
use sqlparser::dialect::PostgreSqlDialect;
use sqlparser::parser::Parser;
Parser::parse_sql(&PostgreSqlDialect {}, sql).unwrap_or_default()
}
// ── 护栏 1:绝对禁止(token 序列字符串预检层) ──────────────────
// is_absolutely_forbidden 做 token 级匹配,防多空格/制表符/换行绕过。
// 关键背景:sqlparser 无法解析 DROP/CREATE DATABASE,这一层是唯一防线。
#[test]
fn absolutely_forbidden_targets_database_and_schema() {
// 锁定关键字序列集合:任何改动都应是有意识的。
assert_eq!(
ABSOLUTELY_FORBIDDEN,
&[
&["drop", "database"],
&["drop", "schema"],
&["create", "database"],
]
);
}
#[test]
fn precheck_catches_forbidden_regardless_of_case() {
for sql in [
"DROP DATABASE yggdrasil",
"drop schema public",
"CREATE DATABASE evil",
"Drop Database x",
] {
assert!(
is_absolutely_forbidden(sql).is_some(),
"应拦截: {sql:?}"
);
}
}
#[test]
fn precheck_catches_multi_space_bypass() {
// token 序列匹配必须命中多空格/制表符/换行绕过——
// 这是改用 is_absolutely_forbidden(替代旧 contains)的核心动机:
// sqlparser 无法解析 DROP/CREATE DATABASE,这一层是唯一防线。
for sql in [
"DROP DATABASE x",
"drop\tdatabase\tx",
"DROP\nDATABASE\nx",
"DROP\t\tDATABASE x;",
] {
assert!(
is_absolutely_forbidden(sql).is_some(),
"多空格绕过应被拦截: {sql:?}"
);
}
}
#[test]
fn precheck_returns_canonical_name_for_error_message() {
assert_eq!(is_absolutely_forbidden("DROP DATABASE x"), Some("DROP DATABASE"));
assert_eq!(is_absolutely_forbidden("drop schema public"), Some("DROP SCHEMA"));
assert_eq!(is_absolutely_forbidden("CREATE DATABASE evil"), Some("CREATE DATABASE"));
}
#[test]
fn create_database_is_blocked_at_both_string_and_ast_layers() {
// CREATE DATABASE 能被 sqlparser 解析(→ AST),故有双重防线:
// 1. is_absolutely_forbidden(字符串 token 预检)
// 2. check_guards 的 Statement::CreateDatabase 分支(AST 层绝禁)
// 本测试锁定两道防线都生效,且确认 CREATE DATABASE 确实可被解析
// (若未来它变得不可解析,字符串预检就成唯一防线,需另作加固)。
assert!(is_absolutely_forbidden("CREATE DATABASE x").is_some());
let asts = parse("CREATE DATABASE x");
assert!(!asts.is_empty(), "CREATE DATABASE 应可被 sqlparser 解析");
assert!(matches!(
check_guards(&asts, true),
GuardResult::Forbidden(_)
));
}
#[test]
fn drop_database_cannot_be_parsed_so_precheck_is_sole_guard() {
// 安全不变量:sqlparser 的 PostgreSqlDialect 无法解析 DROP DATABASE。
// 故 is_absolutely_forbidden 是 DROP DATABASE 的唯一防线。
// 若未来 sqlparser 升级后能解析它,必须在 check_guards 补 AST 分支。
assert!(parse("DROP DATABASE x").is_empty());
assert!(is_absolutely_forbidden("DROP DATABASE x").is_some());
}
#[test]
fn precheck_ignores_benign_statements() {
for sql in [
"SELECT * FROM users",
"DROP TABLE old_logs",
"CREATE TABLE t (id int)",
"DELETE FROM t WHERE id = 1",
"CREATE INDEX idx ON t (col)",
] {
assert!(
is_absolutely_forbidden(sql).is_none(),
"不应误拦: {sql:?}"
);
}
}
// ── 护栏 1:AST 层 DROP SCHEMA 绝禁(防注释/空白绕过字符串预检) ──
#[test]
fn guard_forbids_drop_schema_even_though_string_precheck_is_bypassable() {
// 即使字符串预检被某种方式绕过,AST 层仍绝禁 DROP SCHEMA。
let asts = parse("DROP SCHEMA public");
match check_guards(&asts, true) {
GuardResult::Forbidden(msg) => assert!(
msg.contains("SCHEMA"),
"DROP SCHEMA 应被禁止, 得到: {msg}"
),
other => panic!("DROP SCHEMA 应 Forbidden, 得到 {other:?}"),
}
}
#[test]
fn guard_drop_schema_ignores_confirm_flag() {
// 即便勾选了 confirm_dangerous,DROP SCHEMA 仍绝禁。
let asts = parse("DROP SCHEMA public");
assert!(matches!(
check_guards(&asts, true),
GuardResult::Forbidden(_)
));
}
// ── 护栏 1:DROP/TRUNCATE/ALTER 需确认 ─────────────────────────
#[test]
fn guard_drop_table_needs_confirm() {
let asts = parse("DROP TABLE old_logs");
assert!(matches!(
check_guards(&asts, false),
GuardResult::NeedsConfirm
));
}
#[test]
fn guard_drop_table_allowed_with_confirm() {
let asts = parse("DROP TABLE old_logs");
assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
}
#[test]
fn guard_truncate_needs_confirm() {
let asts = parse("TRUNCATE TABLE sessions");
assert!(matches!(
check_guards(&asts, false),
GuardResult::NeedsConfirm
));
}
#[test]
fn guard_alter_table_needs_confirm() {
let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
assert!(matches!(
check_guards(&asts, false),
GuardResult::NeedsConfirm
));
}
#[test]
fn guard_alter_table_allowed_with_confirm() {
let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
}
// ── 护栏 2:UPDATE/DELETE 无 WHERE 绝禁 ────────────────────────
#[test]
fn guard_update_without_where_is_forbidden() {
let asts = parse("UPDATE posts SET title = 'x'");
match check_guards(&asts, true) {
GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
other => panic!("无 WHERE 的 UPDATE 应 Forbidden, 得到 {other:?}"),
}
}
#[test]
fn guard_delete_without_where_is_forbidden() {
let asts = parse("DELETE FROM posts");
match check_guards(&asts, true) {
GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
other => panic!("无 WHERE 的 DELETE 应 Forbidden, 得到 {other:?}"),
}
}
#[test]
fn guard_update_with_where_allowed() {
let asts = parse("UPDATE posts SET title = 'x' WHERE id = 1");
assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
}
#[test]
fn guard_delete_with_where_allowed() {
let asts = parse("DELETE FROM posts WHERE id = 1");
assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
}
#[test]
fn guard_update_with_where_not_rescued_by_confirm() {
// confirm_dangerous 不应放行无 WHERE 的 UPDATE/DELETE——这是不可协商的安全护栏。
let asts = parse("UPDATE posts SET title = 'x'");
assert!(matches!(
check_guards(&asts, true),
GuardResult::Forbidden(_)
));
}
// ── 正常语句 ───────────────────────────────────────────────────
#[test]
fn guard_allows_select_insert_create() {
for sql in [
"SELECT * FROM posts",
"INSERT INTO posts (title) VALUES ('x')",
"CREATE TABLE t (id int)",
] {
let asts = parse(sql);
assert!(
matches!(check_guards(&asts, false), GuardResult::Allowed),
"应放行: {sql}"
);
}
}
// ── 多语句:任一命中即拒 ───────────────────────────────────────
#[test]
fn guard_checks_all_statements_in_batch() {
// 第二条是无 WHERE 的 DELETE,即便第一条正常,整体也应被拦。
let asts = parse("SELECT 1; DELETE FROM posts");
assert!(matches!(
check_guards(&asts, false),
GuardResult::Forbidden(_)
));
}
#[test]
fn guard_first_dangerous_short_circuits() {
// 第一条 DROP TABLE 未确认,应在 NeedsConfirm 处停下。
let asts = parse("DROP TABLE a; SELECT 1");
assert!(matches!(
check_guards(&asts, false),
GuardResult::NeedsConfirm
));
}
// ── statement_type_name ───────────────────────────────────────
#[test]
fn statement_type_name_maps_variants() {
assert_eq!(statement_type_name(&parse("SELECT 1")[0]), "Select");
assert_eq!(
statement_type_name(&parse("INSERT INTO t (a) VALUES (1)")[0]),
"Insert"
);
assert_eq!(
statement_type_name(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]),
"Update"
);
assert_eq!(
statement_type_name(&parse("DELETE FROM t WHERE id = 1")[0]),
"Delete"
);
assert_eq!(
statement_type_name(&parse("CREATE TABLE t (id int)")[0]),
"CreateTable"
);
assert_eq!(
statement_type_name(&parse("ALTER TABLE t ADD COLUMN x int")[0]),
"AlterTable"
);
assert_eq!(statement_type_name(&parse("DROP TABLE t")[0]), "Drop");
assert_eq!(statement_type_name(&parse("TRUNCATE t")[0]), "Truncate");
}
// ── is_read_only ──────────────────────────────────────────────
#[test]
fn is_read_only_classifies_correctly() {
assert!(is_read_only(&parse("SELECT 1")[0]));
assert!(is_read_only(&parse("EXPLAIN SELECT 1")[0]));
assert!(!is_read_only(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]));
assert!(!is_read_only(&parse("DELETE FROM t WHERE id = 1")[0]));
assert!(!is_read_only(&parse("INSERT INTO t (a) VALUES (1)")[0]));
}
}