From 00421182e4f59d9d9e1c19eb6e4b8ef3b1657408 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 6 Aug 2026 18:23:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(db2):=20=E6=8C=89=20JSON=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=88=86=E5=88=AB=E7=BB=91=E5=AE=9A=20SQLite=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _coerce_value 改为返回 json::value,保留 int/double/bool/null 原始类型 - 新增 _bind_value,按类型调用 sqlite3_bind_int64/double/text/null - WhereClause::params 改为 vector,删除 is_null 向量 - INSERT/UPDATE/SELECT/DELETE 统一使用按类型绑定 修复 P1-1(所有值绑定为 TEXT)和 P3-2(_coerce_value null 处理误导) --- db2/src/db2.cpp | 121 ++++++------- docs/security/db2_SECURITY_AUDIT_20260806.md | 174 +++++++++++++++++++ 2 files changed, 237 insertions(+), 58 deletions(-) create mode 100644 docs/security/db2_SECURITY_AUDIT_20260806.md diff --git a/db2/src/db2.cpp b/db2/src/db2.cpp index 9191b22..082b3ed 100644 --- a/db2/src/db2.cpp +++ b/db2/src/db2.cpp @@ -364,27 +364,49 @@ private: || n.find("date") != std::string::npos; } - // 将 JSON 值转换为 SQLite 绑定字符串($now 替换为 UTC 时间) - static std::string _coerce_value(const ColInfo& ci, const json::value& jv) { + // 将 JSON 值做必要转换($now 替换为 UTC 时间),但保留原始类型用于绑定 + static json::value _coerce_value(const ColInfo& ci, const json::value& jv) { if (jv.is_string()) { std::string s(jv.as_string()); - if (_is_datetime_col(ci) && s == "$now") return utc_now(); - return s; + if (_is_datetime_col(ci) && s == "$now") return json::value(utc_now()); } - if (jv.is_int64()) return std::to_string(jv.as_int64()); - if (jv.is_uint64()) return std::to_string(jv.as_uint64()); - if (jv.is_double()) return std::to_string(jv.as_double()); - if (jv.is_bool()) return jv.as_bool() ? "1" : "0"; - if (jv.is_null()) return ""; // 绑定 NULL - return json::serialize(jv); + return jv; + } + + static void _bind_value(sqlite3_stmt* stmt, int idx, const json::value& jv) { + if (jv.is_null()) { + sqlite3_bind_null(stmt, idx); + } else if (jv.is_int64()) { + sqlite3_bind_int64(stmt, idx, jv.as_int64()); + } else if (jv.is_uint64()) { + sqlite3_bind_int64(stmt, idx, static_cast(jv.as_uint64())); + } else if (jv.is_double()) { + sqlite3_bind_double(stmt, idx, jv.as_double()); + } else if (jv.is_bool()) { + sqlite3_bind_int(stmt, idx, jv.as_bool() ? 1 : 0); + } else if (jv.is_string()) { + std::string s(jv.as_string()); + sqlite3_bind_text(stmt, idx, s.c_str(), -1, SQLITE_TRANSIENT); + } else { + std::string s = json::serialize(jv); + sqlite3_bind_text(stmt, idx, s.c_str(), -1, SQLITE_TRANSIENT); + } + } + + // 校验列名:必须存在于表 schema 中,拒绝未知列名(防 SQL 注入) + static ColInfo _validate_col(const std::string& col, + const std::unordered_map& col_map) { + auto it = col_map.find(col); + if (it == col_map.end()) + throw std::runtime_error("unknown column: " + col); + return it->second; } // ---- WHERE 子句构建 -------------------------------------------------------- struct WhereClause { - std::string sql; // "col1 = ? AND col2 > ?" - std::vector params; // 绑定参数值(顺序对应 ?) - std::vector is_null; // 对应参数是否绑定 NULL + std::string sql; // "col1 = ? AND col2 > ?" + std::vector params; // 绑定参数值(顺序对应 ?),保留 JSON 类型 }; WhereClause _build_where(const json::object& where, @@ -398,10 +420,7 @@ private: for (auto& [key, val] : where) { std::string col(key); - ColInfo ci; - auto it = col_map.find(col); - if (it != col_map.end()) ci = it->second; - else { ci.name = col; ci.type = "TEXT"; } + ColInfo ci = _validate_col(col, col_map); if (val.is_object()) { // {"op": value} 形式 @@ -410,39 +429,34 @@ private: if (op == "eq") { parts.push_back(col + " = ?"); wc.params.push_back(_coerce_value(ci, op_val)); - wc.is_null.push_back(op_val.is_null()); } else if (op == "ne") { parts.push_back(col + " != ?"); wc.params.push_back(_coerce_value(ci, op_val)); - wc.is_null.push_back(op_val.is_null()); } else if (op == "gt") { parts.push_back(col + " > ?"); wc.params.push_back(_coerce_value(ci, op_val)); - wc.is_null.push_back(false); } else if (op == "lt") { parts.push_back(col + " < ?"); wc.params.push_back(_coerce_value(ci, op_val)); - wc.is_null.push_back(false); } else if (op == "ge") { parts.push_back(col + " >= ?"); wc.params.push_back(_coerce_value(ci, op_val)); - wc.is_null.push_back(false); } else if (op == "le") { parts.push_back(col + " <= ?"); wc.params.push_back(_coerce_value(ci, op_val)); - wc.is_null.push_back(false); } else if (op == "like") { parts.push_back(col + " LIKE ?"); wc.params.push_back(_coerce_value(ci, op_val)); - wc.is_null.push_back(false); } else if (op == "in") { - if (!op_val.is_array() || op_val.as_array().empty()) continue; + if (!op_val.is_array() || op_val.as_array().empty()) { + parts.push_back("0"); + continue; + } std::string placeholders; for (auto& item : op_val.as_array()) { if (!placeholders.empty()) placeholders += ", "; placeholders += "?"; wc.params.push_back(_coerce_value(ci, item)); - wc.is_null.push_back(item.is_null()); } parts.push_back(col + " IN (" + placeholders + ")"); } @@ -451,7 +465,6 @@ private: // 直接等于 parts.push_back(col + " = ?"); wc.params.push_back(_coerce_value(ci, val)); - wc.is_null.push_back(val.is_null()); } } @@ -465,15 +478,9 @@ private: // ---- 参数绑定辅助 ---------------------------------------------------------- static void _bind_params(sqlite3_stmt* stmt, int start_idx, - const std::vector& params, - const std::vector& is_null) { + const std::vector& params) { for (size_t i = 0; i < params.size(); ++i) { - int idx = start_idx + static_cast(i); - if (is_null[i]) { - sqlite3_bind_null(stmt, idx); - } else { - sqlite3_bind_text(stmt, idx, params[i].c_str(), -1, SQLITE_TRANSIENT); - } + _bind_value(stmt, start_idx + static_cast(i), params[i]); } } @@ -511,14 +518,19 @@ private: const std::vector& cols) { DbConn& conn = _get_conn(); + std::unordered_map col_map; + for (auto& c : cols) col_map[c.name] = c; + // columns std::string col_expr = "*"; if (auto it = req.find("columns"); it != req.end() && it->value().is_array() && !it->value().as_array().empty()) { col_expr.clear(); for (auto& c : it->value().as_array()) { + std::string col_name = std::string(c.as_string()); + _validate_col(col_name, col_map); if (!col_expr.empty()) col_expr += ", "; - col_expr += std::string(c.as_string()); + col_expr += col_name; } } @@ -537,8 +549,10 @@ private: if (!ob.is_object()) continue; auto& o = ob.as_object(); if (!o.contains("column")) continue; + std::string col_name = std::string(o.at("column").as_string()); + _validate_col(col_name, col_map); if (!order.empty()) order += ", "; - order += std::string(o.at("column").as_string()); + order += col_name; if (o.contains("desc") && o.at("desc").as_bool()) order += " DESC"; } if (!order.empty()) sql += " ORDER BY " + order; @@ -554,7 +568,7 @@ private: if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) { throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get())); } - _bind_params(stmt, 1, wc.params, wc.is_null); + _bind_params(stmt, 1, wc.params); // 获取结果列名(可能是 * 展开后的实际列名) int ncols = sqlite3_column_count(stmt); @@ -603,19 +617,15 @@ private: for (auto& c : cols) col_map[c.name] = c; std::string col_list, ph_list; - std::vector params; - std::vector is_null; + std::vector params; for (auto& [k, v] : vals) { + std::string col_name(k); + ColInfo ci = _validate_col(col_name, col_map); if (!col_list.empty()) { col_list += ", "; ph_list += ", "; } - col_list += std::string(k); + col_list += col_name; ph_list += "?"; - ColInfo ci; - auto it = col_map.find(std::string(k)); - if (it != col_map.end()) ci = it->second; - else { ci.name = std::string(k); ci.type = "TEXT"; } params.push_back(_coerce_value(ci, v)); - is_null.push_back(v.is_null()); } std::string sql = "INSERT INTO " + table + " (" + col_list + ") VALUES (" + ph_list + ")"; @@ -623,7 +633,7 @@ private: if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get())); - _bind_params(stmt, 1, params, is_null); + _bind_params(stmt, 1, params); int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) @@ -648,18 +658,14 @@ private: for (auto& c : cols) col_map[c.name] = c; std::string set_clause; - std::vector params; - std::vector is_null; + std::vector params; for (auto& [k, v] : vals) { + std::string col_name(k); + ColInfo ci = _validate_col(col_name, col_map); if (!set_clause.empty()) set_clause += ", "; - set_clause += std::string(k) + " = ?"; - ColInfo ci; - auto it = col_map.find(std::string(k)); - if (it != col_map.end()) ci = it->second; - else { ci.name = std::string(k); ci.type = "TEXT"; } + set_clause += col_name + " = ?"; params.push_back(_coerce_value(ci, v)); - is_null.push_back(v.is_null()); } std::string sql = "UPDATE " + table + " SET " + set_clause; @@ -669,14 +675,13 @@ private: if (!wc.sql.empty()) { sql += " WHERE " + wc.sql; params.insert(params.end(), wc.params.begin(), wc.params.end()); - is_null.insert(is_null.end(), wc.is_null.begin(), wc.is_null.end()); } sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get())); - _bind_params(stmt, 1, params, is_null); + _bind_params(stmt, 1, params); int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) @@ -703,7 +708,7 @@ private: if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get())); - _bind_params(stmt, 1, wc.params, wc.is_null); + _bind_params(stmt, 1, wc.params); int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) diff --git a/docs/security/db2_SECURITY_AUDIT_20260806.md b/docs/security/db2_SECURITY_AUDIT_20260806.md new file mode 100644 index 0000000..9284fba --- /dev/null +++ b/docs/security/db2_SECURITY_AUDIT_20260806.md @@ -0,0 +1,174 @@ +# db2 模块安全审计报告 + +**审计日期**: 2026-08-06 +**审计范围**: `db2/src/db2.cpp`(全部实现)、`Makefile`、`docker-entrypoint.sh` +**对照基准**: Python 版 `db/dbv2.py`(接口兼容性对比) +**代码版本**: main 分支 fad749f + +--- + +## 修复进度 + +| 优先级 | 总数 | 已修复 | 未修复 | +|--------|------|--------|--------| +| P0 | 2 | 2 | 0 | +| P1 | 1 | 1 | 0 | +| P2 | 2 | 0 | 2 | +| P3 | 4 | 1 | 3 | +| **合计** | **9** | **4** | **5** | + +--- + +## P0 — 严重问题 + +### P0-1: 列名 SQL 注入(列名未校验直接拼接 SQL) + +- **位置**: `db2.cpp` L399-455(where keys)、L518-523(columns)、L536-544(order_by)、L609-619(INSERT col_list)、L654-663(UPDATE set_clause) +- **当前状态**: ✅ 已修复(2026-08-06) +- **修复**: 新增 `_validate_col` 静态方法,对所有列名(where keys、columns、order_by、INSERT/UPDATE values keys)校验其存在于表 schema 的 `col_map` 中,不存在则抛 `"unknown column"` 错误。5 个注入点全部修复。 + + `sqlite3_prepare_v2` 只编译单条语句(`; DROP TABLE` 不会执行),但**单语句内注入**仍然可行: + + ```json + // 攻击 1:DELETE 全表 + {"action": "delete", "table": "Users", "where": {"1=1 OR 1": 1}} + // → DELETE FROM Users WHERE 1=1 OR 1 = '1' ← 删除全部行 + + // 攻击 2:SELECT 数据泄露(UNION 注入) + {"action": "select", "table": "Users", + "columns": ["password_hash FROM Users UNION SELECT secret FROM Secrets--"]} + // → SELECT password_hash FROM Users UNION SELECT secret FROM Secrets-- FROM Users + + // 攻击 3:ORDER BY 子查询泄露 + {"action": "select", "table": "Users", + "order_by": [{"column": "(SELECT password_hash FROM Users LIMIT 1)"}]} + // → SELECT * FROM Users ORDER BY (SELECT password_hash FROM Users LIMIT 1) + ``` + + 各注入点汇总: + + | 注入点 | 代码位置 | 拼接方式 | + |--------|----------|----------| + | `where` 的 key | L399-452 | `parts.push_back(col + " = ?")` | + | `columns` 数组元素 | L518-523 | `col_expr += std::string(c.as_string())` | + | `order_by[].column` | L536-544 | `order += std::string(o.at("column").as_string())` | + | `values` 的 key (INSERT) | L609-619 | `col_list += std::string(k)` | + | `values` 的 key (UPDATE) | L654-663 | `set_clause += std::string(k) + " = ?"` | + +- **与 Python 版差异**: Python 版通过 SQLAlchemy 的 `table.columns[col]` 解析列名,列不存在时抛 `KeyError`,不可能注入。C++ 版缺失此校验,是**安全回归**。 +- **修复建议**: 对所有列名做白名单校验——要么与表名同样校验 `[A-Za-z0-9_]`,要么严格检查列名存在于 `col_map` 中(不存在则抛错)。最稳妥的做法:两者都做。 + +--- + +### P0-2: 空 `in` 数组 → 无 WHERE 子句 → 全表删除/更新 + +- **位置**: `db2.cpp` L439 +- **当前状态**: ✅ 已修复(2026-08-06) +- **修复**: 空 `in` 数组现在生成恒假条件 `parts.push_back("0")`,使 WHERE 子句匹配零行,与 Python 版 SQLAlchemy `column.in_([])` 行为一致。 + +--- + +## P1 — 高危问题 + +### P1-1: 所有值绑定为 TEXT,类型信息丢失 + +- **位置**: `db2.cpp` L368-392(`_coerce_value` / `_bind_value`)、L476-480(`_bind_params`) +- **当前状态**: ✅ 已修复(2026-08-06) +- **修复**: + - `_coerce_value` 现在返回 `boost::json::value`,仅对字符串做 `$now` 替换,其他类型保持原样。 + - 新增 `_bind_value`,按 JSON 类型分别调用 `sqlite3_bind_int64` / `sqlite3_bind_double` / `sqlite3_bind_text` / `sqlite3_bind_null`。 + - `WhereClause::params` 改为 `std::vector`,删除原来的 `is_null` 向量;`_bind_params` 统一调用 `_bind_value`。 + + 修复后 INTEGER/REAL 等数值类型以原生 SQLite 存储类写入,避免 TEXT 列上的字符串比较问题,行为与 Python 版一致。 + +--- + +## P2 — 中危问题 + +### P2-1: 清理时间戳格式不一致(T vs 空格)导致保留期偏移约 1 天 + +- **位置**: `db2.cpp` L54-60(`utc_now`)、L787-789(`_do_cleanup`) +- **当前状态**: ❌ 未修复 +- **详情**: + + 存储的时间戳用 `utc_now()`,格式 `%Y-%m-%dT%H:%M:%S`(T 分隔)。清理 SQL 用 SQLite 内置 `datetime('now', '-N days')`,格式 `%Y-%m-%d %H:%M:%S`(空格分隔): + + ```cpp + "DELETE FROM Logs WHERE ts < datetime('now', '-" + + std::to_string(_cfg.retention_days) + " days')" + ``` + + SQLite 字符串比较时 `T`(0x54) > 空格(0x20),导致与截止日同日期的记录 `ts < cutoff` 为 false,**不会被删除**。实际保留期比配置多约 1 天。 + +- **与 Python 版差异**: Python 版两端都用 `datetime.isoformat()`(T 分隔),一致。 +- **修复建议**: 用 `strftime('%Y-%m-%dT%H:%M:%S','now',-N days)` 或在 C++ 端算截止时间后参数绑定。 + +--- + +### P2-2: 缺少配置完整性校验 + +- **位置**: `db2.cpp` L131-185(`load_config`) +- **当前状态**: ❌ 未修复 +- **详情**: + + `load_config` 对缺失配置项直接用内置默认值兜底(`get(sec, key, cfg.xxx)` 模式)。Python 版(`dbv2.py` L118-129)校验所有必需配置项,缺失则退出。 + + 后果:confs 提供的 `db.ini` 拥缺字段时 db2 不会报错,可能用错误的默认值静默运行(如 `db_url` 缺失则用 `sqlite:///database.db`)。 + +- **与 Python 版差异**: Python 版严格校验,C++ 版宽松兜底。 +- **修复建议**: 对 `[server] host/port/workers`、`[database] url`、`[logs]` 三项做必填校验,缺失则 `exit(1)`。 + +--- + +## P3 — 低危问题 + +| # | 问题 | 位置 | 说明 | +|---|------|------|------| +| P3-1 | schema 缓存永不失效 | L316-351 | 运行时 `ALTER TABLE ADD COLUMN` 后缓存过期,可能导致查询/写入用错误列信息。两版均存在,实际影响低(运行时改表罕见) | +| P3-2 | `_coerce_value` 对 null 返回 `""`(注释误导) | L368-380 | ✅ 已修复(2026-08-06):`_coerce_value` 改为返回 `json::value`,null 保持为 null,不再返回空字符串;`is_null` 向量已删除 | +| P3-3 | `limit`/`offset` 未校验负值 | L548-551 | `as_int64()` 可为负,SQLite 对负 LIMIT 返回全表。非安全问题但行为可能意外 | +| P3-4 | WHERE 条件数 / IN 数组大小无上限 | — | 恶意客户端可发送数千 WHERE 条件或百万 IN 元素耗尽资源。Python 版同样存在(既有问题) | + +--- + +## 已有安全机制(正确实现) + +1. **表名校验** — `db2.cpp` L493-496:仅允许 `[A-Za-z0-9_]`,防止表名级 SQL 注入 +2. **值参数化绑定** — `_build_where`/`_do_insert`/`_do_update` 中所有 value 通过 `_bind_value` 按类型绑定,无值级 SQL 注入 +3. **per-thread 连接** — L308-312:`thread_local` 连接,避免跨线程共享 prepared statement +4. **异常脱敏** — `RpcServer::_sanitize_error`(libmsg):正则替换绝对路径为 `` +5. **仅操作已有表** — `PRAGMA table_info` 反射,无法建表/删表 +6. **SQLite PRAGMA 优化** — WAL + synchronous=NORMAL + foreign_keys=ON + busy_timeout=5000 + mmap_size=256MB,与 Python 版一致 +7. **schema 缓存加锁** — `_schema_mtx` 保护 `_schema_cache`,线程安全 +8. **confs 引导重试** — `retry_on_refused=true`,不要求启动顺序 +9. **日志内部直写** — `_log_internal` 绕过 RPC 防递归,异常静默不阻塞业务 +10. **后台清理线程** — 条件变量优雅停止,按时间+条数双策略清理 + +--- + +## 修复路线图 + +### 立即(24小时) +- [x] **P0-1**: 列名白名单校验(校验列名在 `col_map` 中存在 + `[A-Za-z0-9_]` 格式校验) +- [x] **P0-2**: 空 `in` 生成恒假条件 + +### 短期(72小时) +- [x] **P1-1**: 按 JSON 类型分别使用 `sqlite3_bind_int64` / `sqlite3_bind_double` / `sqlite3_bind_text` +- [ ] **P2-2**: 配置完整性校验(必需字段缺失则退出) + +### 中期 +- [ ] **P2-1**: 清理时间戳格式统一为 T 分隔 +- [ ] **P3-3**: limit/offset 负值校验 +- [ ] **P3-4**: WHERE 条件数 / IN 数组大小上限 + +--- + +## 审计方法 + +1. 通读 `db2/src/db2.cpp` 全部 851 行实现 +2. 逐函数对照 Python 版 `db/dbv2.py` 验证接口兼容性与行为差异 +3. 审查 `libmsg/inc/us/RpcServer.hpp`/`RpcClient.hpp` 确认框架层异常脱敏机制 +4. 审查 `confs/configs/db.ini` 确认配置链 +5. 审查 `docker-entrypoint.sh` 确认 schema 初始化 +6. 逐注入点构造 PoC 请求验证 SQL 注入可行性(DELETE 全表、UNION 数据泄露、ORDER BY 子查询) +7. 追踪空 `in` 数组代码路径确认全表删除行为