fix(database): reject cross-file statements during SQL import

`import_sql_string_inner` validated only that the file starts with the
`-- CC Switch SQLite 导出` comment, then handed the whole text to
`execute_batch`. Anything after that prefix ran unchecked, so a crafted
backup could `ATTACH DATABASE '/path/x.db'` and create a SQLite file
anywhere the user can write. The side effect lands before
`validate_basic_state`, so the file survives even when the import as a
whole fails. `settings` is in neither SYNC_SKIP_TABLES nor
SYNC_PRESERVE_TABLES, so the WebDAV/S3 sync path reaches the same code.

Install a SQLite authorizer for the duration of the external batch only,
then clear it so our own schema maintenance is unaffected.

Deny what can leave the temp database rather than allow-listing what
`dump_sql` emits. The batch runs on a throwaway NamedTempFile whose
entire contents are already decided by that same SQL, so DELETE/DROP/
UPDATE hand an attacker nothing new -- the only meaningful boundary is
the temp file itself. A strict allow-list only adds the risk of refusing
a legitimate backup whose schema has a shape we did not anticipate.

The denied set was measured, not guessed: `ATTACH DATABASE 'x'`,
`VACUUM INTO 'x'` and bare `VACUUM` all surface as `AuthAction::Attach`,
so one rule covers all three -- which keyword scanning would not, since
`VACUUM INTO` contains no "ATTACH". Also deny vtable creation
(file-backed modules such as csvfile can read and write arbitrary paths)
and `Unknown`, so future SQLite statements fail closed.

Tests cover both denied statements (asserting no file is left on disk,
not merely that the call errors) and a real export round-trip, which
guards against the allow-list regressing into false refusals.
This commit is contained in:
Jason
2026-07-28 22:46:44 +08:00
parent 993077c60c
commit c98913df41
+125 -3
View File
@@ -15,6 +15,56 @@ use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
/// `dump_sql` 会写出的 PRAGMA。其余 PRAGMA 一律拒绝——`temp_store_directory`
/// 能把临时文件重定向到任意目录,`writable_schema` 能绕过 schema 完整性检查。
const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"];
/// 执行外部 SQL 期间的 authorizer:拒绝一切能**离开临时数据库文件**的动作。
///
/// 头部校验(`validate_cc_switch_sql_export`)只比较一个注释前缀,任何人都能在
/// 合法前缀后面接着写别的语句。`ATTACH DATABASE '/path/x.db'` 的副作用发生在
/// `validate_basic_state` 之前,导入即使最终失败,文件也已经被创建;而 `settings`
/// 表不在 `SYNC_SKIP_TABLES` / `SYNC_PRESERVE_TABLES` 之列,WebDAV/S3 同步会走
/// 同一条 `import_sql_string_inner`,所以这条路径的输入不可信。
///
/// 为什么是 authorizer 而不是「扫描 ATTACH 关键字」:字符串扫描会被 `/*x*/ATTACH`、
/// 大小写、换行绕过,还漏掉 `VACUUM INTO`。authorizer 在 prepare 阶段按**解析结果**
/// 回调,绕不过语法层。
///
/// 为什么是「拒绝越界动作」而不是「只放行 dump_sql 的语句」:这段 SQL 跑在
/// `NamedTempFile` 建的一次性库上,而那个库的全部内容本来就由这份 SQL 决定。
/// 因此 `DELETE` / `DROP` / `UPDATE` 给不了攻击者任何新东西——**唯一有意义的边界
/// 是那个临时文件本身**。按 dump_sql 的产物做严格白名单只会带来误伤风险(用户
/// 库里出现一种没预料到的对象就恢复不了备份),却不多挡任何攻击。
///
/// 越界动作是实测出来的,不是推断的:
/// - `ATTACH DATABASE 'x'`、`VACUUM INTO 'x'`、裸 `VACUUM` **三者都**报
/// `AuthAction::Attach`,所以拒 `Attach` 一条即可覆盖
/// - 文件后端的虚拟表模块(`csvfile`、`zipfile` 等)能读写任意路径 → 拒 vtable
/// - `Unknown` 是 rusqlite 对未识别动作码的兜底 → 未知即拒,将来 SQLite 新增的
/// 跨文件语句会默认落进这里,不依赖有人记得回来补名单
fn import_authorizer(context: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization {
use rusqlite::hooks::{AuthAction, Authorization};
let escapes_temp_db = match context.action {
AuthAction::Attach { .. } | AuthAction::Detach { .. } => true,
AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true,
AuthAction::Unknown { .. } => true,
AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS
.iter()
.any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)),
_ => false,
};
if escapes_temp_db {
// SQLite 只会回一句 "not authorized",不记日志就无从知道是哪条语句被拦。
log::warn!("SQL 导入拒绝了越界语句: {:?}", context.action);
Authorization::Deny
} else {
Authorization::Allow
}
}
/// Tables whose data rows are skipped when exporting for WebDAV sync.
const SYNC_SKIP_TABLES: &[&str] = &[
"proxy_request_logs",
@@ -117,9 +167,15 @@ impl Database {
let temp_conn =
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn
.execute_batch(sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// authorizer 只覆盖外部 SQL,执行完立刻摘掉:紧随其后的
// `create_tables_on_conn` / `apply_schema_migrations_on_conn` 是本程序自己的
// schema 维护语句,不属于需要设防的输入,没必要让它们也过一遍守卫。
temp_conn.authorizer(Some(import_authorizer));
let batch_result = temp_conn.execute_batch(sql_content);
temp_conn.authorizer(
None::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>,
);
batch_result.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验
Self::create_tables_on_conn(&temp_conn)?;
@@ -694,6 +750,72 @@ mod tests {
use crate::settings::{update_settings, AppSettings};
use serial_test::serial;
#[test]
fn import_rejects_cross_file_statements_and_leaves_no_file_behind() -> Result<(), AppError> {
// `VACUUM INTO` 是关键字扫描方案最容易漏的一条:它不含 "ATTACH" 字样,
// 却和 ATTACH 一样落到 `AuthAction::Attach`(实测),因此同一条规则挡住两者。
let cases: [(&str, &str); 2] = [
("attach", "ATTACH DATABASE '{path}' AS evil;"),
("vacuum-into", "VACUUM INTO '{path}';"),
];
for (label, template) in cases {
let target = std::env::temp_dir().join(format!("cc-switch-authorizer-{label}.sqlite"));
let _ = std::fs::remove_file(&target);
// 合法的导出头 + 越界语句。头部校验只比前缀,这份输入过得了它,
// 真正拦下来的必须是 authorizer。
let malicious = format!(
"{}\n{}\n",
super::CC_SWITCH_SQL_EXPORT_HEADER,
template.replace("{path}", &target.display().to_string())
);
let db = Database::memory()?;
let result = db.import_sql_string(&malicious);
assert!(result.is_err(), "{label} 必须被拒绝");
// 光报错不够:文件创建发生在 prepare 之后、`validate_basic_state` 之前,
// 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。
assert!(
!target.exists(),
"被拒绝的 {label} 不得在磁盘上留下文件: {}",
target.display()
);
let _ = std::fs::remove_file(&target);
}
Ok(())
}
#[test]
fn import_still_accepts_a_genuine_export() -> Result<(), AppError> {
// 白名单收得紧,必须有一条回归防线证明它没误伤自家导出格式——
// 这条测试红了就说明 dump_sql 写出了白名单没覆盖的语句。
let source = Database::memory()?;
{
let conn = crate::database::lock_conn!(source.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES ('p1', 'claude', 'Provider One', '{}', '{}')",
[],
)?;
}
let exported = source.export_sql_string()?;
let target = Database::memory()?;
target.import_sql_string(&exported)?;
let conn = crate::database::lock_conn!(target.conn);
let name: String = conn.query_row(
"SELECT name FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|row| row.get(0),
)?;
assert_eq!(name, "Provider One");
Ok(())
}
#[test]
fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
let remote_db = Database::memory()?;