This commit is contained in:
qing committed 2026-08-07 15:47:34 +08:00
commit cc0475e5dc
2 files changed
+234 -38

No files matched your search

+81 -10
View File
@@ -47,6 +47,10 @@ using namespace pmc::net;
static constexpr size_t kMaxMessage = 8192;
static constexpr size_t kMaxExtra = 4096;
// 输入规模限制(防资源耗尽)
static constexpr size_t kMaxWhereConditions = 64; // 单请求 WHERE 条件数上限
static constexpr size_t kMaxInElements = 1000; // 单个 IN 运算符元素上限
// ============================================================================
// 工具:当前 UTC 时间 ISO 8601
// ============================================================================
@@ -151,6 +155,25 @@ static Config load_config(int argc, char* argv[]) {
// 3. 解析 INI(白名单字段)
IniMap ini = parse_ini(ini_text);
// 校验必需配置项(confs 提供的 db.ini 必须包含完整配置)
static const std::pair<const char*, const char*> required_keys[] = {
{"server", "host"},
{"server", "port"},
{"server", "workers"},
{"database", "url"},
{"logs", "retention_days"},
{"logs", "max_records"},
{"logs", "cleanup_interval"},
};
for (auto& [sec, key] : required_keys) {
auto si = ini.find(sec);
if (si == ini.end() || si->second.find(key) == si->second.end()) {
std::fprintf(stderr, "[db2] missing required config: [%s] %s\n", sec, key);
std::exit(1);
}
}
auto get = [&](const std::string& sec, const std::string& key,
const std::string& def = "") -> std::string {
auto si = ini.find(sec);
@@ -243,6 +266,12 @@ struct ColInfo {
std::string type; // 原始类型字符串,大写,如 "DATETIME" "TEXT" "INTEGER"
};
// 表 schema 缓存项,携带 PRAGMA schema_version 以感知 DDL 变更
struct SchemaCacheEntry {
int schema_version;
std::vector<ColInfo> cols;
};
// ============================================================================
// DB2Server
// ============================================================================
@@ -295,7 +324,7 @@ private:
// 表列信息缓存
std::mutex _schema_mtx;
std::unordered_map<std::string, std::vector<ColInfo>> _schema_cache;
std::unordered_map<std::string, SchemaCacheEntry> _schema_cache;
// 后台清理线程
std::atomic<bool> _cleanup_running{false};
@@ -313,15 +342,32 @@ private:
// ---- 列 schema 反射 --------------------------------------------------------
static int _get_schema_version(sqlite3* db) {
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, "PRAGMA schema_version", -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("PRAGMA schema_version failed");
}
int version = -1;
if (sqlite3_step(stmt) == SQLITE_ROW) {
version = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return version;
}
std::vector<ColInfo> _get_schema(const std::string& table) {
DbConn& conn = _get_conn();
const int current_version = _get_schema_version(conn.get());
{
std::lock_guard<std::mutex> lk(_schema_mtx);
auto it = _schema_cache.find(table);
if (it != _schema_cache.end()) return it->second;
if (it != _schema_cache.end() && it->second.schema_version == current_version) {
return it->second.cols;
}
}
// PRAGMA table_info 返回: cid, name, type, notnull, dflt_value, pk
DbConn& conn = _get_conn();
std::string sql = "PRAGMA table_info(" + table + ")";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
@@ -346,8 +392,8 @@ private:
}
std::lock_guard<std::mutex> lk(_schema_mtx);
_schema_cache[table] = cols;
return cols;
_schema_cache[table] = SchemaCacheEntry{current_version, std::move(cols)};
return _schema_cache[table].cols;
}
// 判断列是否为日期时间类型
@@ -414,6 +460,12 @@ private:
WhereClause wc;
std::vector<std::string> parts;
if (where.size() > kMaxWhereConditions) {
throw std::runtime_error("too many where conditions: "
+ std::to_string(where.size())
+ " (max " + std::to_string(kMaxWhereConditions) + ")");
}
// 建立列名 -> ColInfo 映射
std::unordered_map<std::string, ColInfo> col_map;
for (auto& c : cols) col_map[c.name] = c;
@@ -452,6 +504,11 @@ private:
parts.push_back("0");
continue;
}
if (op_val.as_array().size() > kMaxInElements) {
throw std::runtime_error("too many elements in 'in' operator: "
+ std::to_string(op_val.as_array().size())
+ " (max " + std::to_string(kMaxInElements) + ")");
}
std::string placeholders;
for (auto& item : op_val.as_array()) {
if (!placeholders.empty()) placeholders += ", ";
@@ -459,6 +516,8 @@ private:
wc.params.push_back(_coerce_value(ci, item));
}
parts.push_back(col + " IN (" + placeholders + ")");
} else {
throw std::runtime_error("unknown operator: " + op);
}
}
} else {
@@ -559,10 +618,18 @@ private:
}
// limit / offset
if (auto it = req.find("limit"); it != req.end() && it->value().is_int64())
sql += " LIMIT " + std::to_string(it->value().as_int64());
if (auto it = req.find("offset"); it != req.end() && it->value().is_int64())
sql += " OFFSET " + std::to_string(it->value().as_int64());
if (auto it = req.find("limit"); it != req.end() && it->value().is_int64()) {
std::int64_t limit = it->value().as_int64();
if (limit < 0)
throw std::runtime_error("limit must be non-negative");
sql += " LIMIT " + std::to_string(limit);
}
if (auto it = req.find("offset"); it != req.end() && it->value().is_int64()) {
std::int64_t offset = it->value().as_int64();
if (offset < 0)
throw std::runtime_error("offset must be non-negative");
sql += " OFFSET " + std::to_string(offset);
}
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
@@ -611,6 +678,8 @@ private:
DbConn& conn = _get_conn();
const json::object& vals = req.at("values").as_object();
if (vals.empty())
throw std::runtime_error("insert requires non-empty 'values'");
// 建列名 -> ColInfo 映射
std::unordered_map<std::string, ColInfo> col_map;
@@ -653,6 +722,8 @@ private:
DbConn& conn = _get_conn();
const json::object& vals = req.at("values").as_object();
if (vals.empty())
throw std::runtime_error("update requires non-empty 'values'");
std::unordered_map<std::string, ColInfo> col_map;
for (auto& c : cols) col_map[c.name] = c;
@@ -790,7 +861,7 @@ private:
// 1. 删除超过 retention_days 的记录
std::string cutoff_sql =
"DELETE FROM Logs WHERE ts < datetime('now', '-"
"DELETE FROM Logs WHERE ts < strftime('%Y-%m-%dT%H:%M:%S','now', '-"
+ std::to_string(_cfg.retention_days) + " days')";
conn.exec(cutoff_sql);
int expired = sqlite3_changes(conn.get());
+153 -28
View File
@@ -1,9 +1,10 @@
# db2 模块安全审计报告
**审计日期**: 2026-08-06
**审计范围**: `db2/src/db2.cpp`(全部实现)、`Makefile`、`docker-entrypoint.sh`
**初次审计**: 2026-08-06
**最近复审**: 2026-08-07(P2 修复后全量重审,新增 P3-5/P3-6)
**审计范围**: `db2/src/db2.cpp`(全部实现 926 行)、`Makefile`、`docker-entrypoint.sh`
**对照基准**: Python 版 `db/dbv2.py`(接口兼容性对比)
**代码版本**: main 分支 fad749f
**代码版本**: main 分支 ec626dc + P3 全量修复
---
@@ -13,9 +14,9 @@
|--------|------|--------|--------|
| P0 | 2 | 2 | 0 |
| P1 | 1 | 1 | 0 |
| P2 | 2 | 0 | 2 |
| P3 | 4 | 1 | 3 |
| **合计** | **9** | **4** | **5** |
| P2 | 2 | 2 | 0 |
| P3 | 6 | 6 | 0 |
| **合计** | **11** | **11** | **0** |
---
@@ -88,8 +89,10 @@
### P2-1: 清理时间戳格式不一致(T vs 空格)导致保留期偏移约 1 天
- **位置**: `db2.cpp` L54-60(`utc_now`)、L787-789(`_do_cleanup`)
- **当前状态**: ❌ 未修复
- **详情**:
- **当前状态**: ✅ 已修复(2026-08-07)
- **修复**: 清理 SQL 改用 `strftime('%Y-%m-%dT%H:%M:%S','now','-N days')`,与 `utc_now()` 的 `T` 分隔格式完全对齐,截止时间字符串比较正确,保留期精确匹配配置天数。
- **原始问题**:
存储的时间戳用 `utc_now()`,格式 `%Y-%m-%dT%H:%M:%S`(T 分隔)。清理 SQL 用 SQLite 内置 `datetime('now', '-N days')`,格式 `%Y-%m-%d %H:%M:%S`(空格分隔):
@@ -108,8 +111,10 @@
### P2-2: 缺少配置完整性校验
- **位置**: `db2.cpp` L131-185(`load_config`)
- **当前状态**: ❌ 未修复
- **详情**:
- **当前状态**: ✅ 已修复(2026-08-07)
- **修复**: 在 INI 解析后、环境变量覆盖前,对 7 个必需字段(`[server] host/port/workers`、`[database] url`、`[logs] retention_days/max_records/cleanup_interval`)做必填校验,缺失任一则 `fprintf` 明确错误信息后 `exit(1)`,不再用错误默认值静默运行。
- **原始问题**:
`load_config` 对缺失配置项直接用内置默认值兜底(`get(sec, key, cfg.xxx)` 模式)。Python 版(`dbv2.py` L118-129)校验所有必需配置项,缺失则退出。
@@ -124,25 +129,34 @@
| # | 问题 | 位置 | 说明 |
|---|------|------|------|
| 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 版同样存在(既有问题) |
| P3-1 | schema 缓存永不失效 | L341-392 | ✅ 已修复:缓存项携带 `PRAGMA schema_version`,每次取 schema 前比对版本号;DDL 变更后自动失效并重新反射,无需重启 db2 |
| P3-2 | `_coerce_value` 对 null 返回 `""`(注释误导) | L387-393 | ✅ 已修复(2026-08-06):`_coerce_value` 改为返回 `json::value`,null 保持为 null,不再返回空字符串;`is_null` 向量已删除 |
| P3-3 | `limit`/`offset` 未校验负值 | L620-632 | ✅ 已修复:提取 `limit`/`offset` 后校验非负,负值直接抛出 `"limit/offset must be non-negative"` |
| P3-4 | WHERE 条件数 / IN 数组大小无上限 | L458-512 | ✅ 已修复:`_build_where` 入口校验 `where.size() <= 64`,`in` 分支校验数组元素 `<= 1000`,超限立即报错 |
| P3-5 | 空 `values` 对象导致 INSERT/UPDATE 语法错误 | L628-671 | ✅ 已修复(2026-08-07):`_do_insert` 和 `_do_update` 新增 `vals.empty()` 前置校验,空 `values` 直接抛出 `"insert/update requires non-empty 'values'"` 错误,不再生成无效 SQL |
| P3-6 | 未知 WHERE 运算符被静默跳过 | L446-485 | ✅ 已修复(2026-08-07):运算符分支末尾新增 `else throw "unknown operator: X"`,非法运算符立即报错,不再静默跳过导致条件失效 |
---
## 已有安全机制(正确实现)
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):正则替换绝对路径为 `<redacted>`
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. **后台清理线程** — 条件变量优雅停止,按时间+条数双策略清理
1. **表名校验** — `db2.cpp` L518-522:仅允许 `[A-Za-z0-9_]`,防止表名级 SQL 注入
2. **列名白名单校验** — `_validate_col` L416-422:所有列名必须在表 schema 中存在,5 个拼接点全部覆盖
3. **值参数化绑定** — `_bind_value` L395-413:所有 value 按 JSON 类型分别 `sqlite3_bind_*`,无值级 SQL 注入
4. **per-thread 连接** — L327-331:`thread_local` 连接,避免跨线程共享 prepared statement
5. **异常脱敏** — `RpcServer::_sanitize_error`(libmsg):正则替换绝对路径为 `<redacted>`
6. **仅操作已有表** — `PRAGMA table_info` 反射,无法建表/删表
7. **空 `in` 恒假条件** — L470-473:空 `in` 数组生成 `"0"` 使 WHERE 匹配零行
8. **SQLite PRAGMA 优化** — WAL + synchronous=NORMAL + foreign_keys=ON + busy_timeout=5000 + mmap_size=256MB,与 Python 版一致
9. **schema 缓存加锁 + 版本感知失效** — `_schema_mtx` L316 保护 `_schema_cache`;缓存项携带 `PRAGMA schema_version`,每次取 schema 前比对版本号,DDL 变更后自动重新反射
10. **配置完整性校验** — L155-171:7 个必需字段缺失则 `exit(1)`,不再用默认值静默运行
11. **confs 引导重试** — `retry_on_refused=true`,不要求启动顺序
12. **日志内部直写** — `_log_internal` L762-791 绕过 RPC 防递归,异常静默不阻塞业务
13. **后台清理线程** — 条件变量优雅停止,时间戳格式 T 对齐,按时间+条数双策略清理
14. **空 values 前置校验** — `_do_insert`/`_do_update` 检测空 `values` 立即报错,不生成无效 SQL
15. **WHERE 运算符白名单** — 未知运算符立即抛出 `"unknown operator"` 错误,不再静默跳过
16. **`limit`/`offset` 非负校验** — 负值直接报错,避免 SQLite 负 LIMIT 返回全表的意外行为
17. **WHERE / IN 输入规模上限** — 单请求最多 64 个 WHERE 条件、单个 `IN` 最多 1000 个元素,防止恶意大请求耗尽资源
---
@@ -154,17 +168,22 @@
### 短期(72小时)
- [x] **P1-1**: 按 JSON 类型分别使用 `sqlite3_bind_int64` / `sqlite3_bind_double` / `sqlite3_bind_text`
- [ ] **P2-2**: 配置完整性校验(必需字段缺失则退出)
- [x] **P2-2**: 配置完整性校验(必需字段缺失则退出)
### 中期
- [ ] **P2-1**: 清理时间戳格式统一为 T 分隔
- [ ] **P3-3**: limit/offset 负值校验
- [ ] **P3-4**: WHERE 条件数 / IN 数组大小上限
- [x] **P2-1**: 清理时间戳格式统一为 T 分隔
- [x] **P3-1**: schema 缓存失效机制(基于 `PRAGMA schema_version` 自动失效并重新反射)
- [x] **P3-3**: limit/offset 非负校验
- [x] **P3-4**: WHERE 条件数 / IN 数组大小上限(64 / 1000)
- [x] **P3-5**: 空 `values` 对象前置校验(`values` 为空时直接返回错误,避免生成无效 SQL)
- [x] **P3-6**: 未知 WHERE 运算符校验(遇到非合法运算符时抛出错误而非静默跳过)
---
## 审计方法
### 初次审计(2026-08-06)
1. 通读 `db2/src/db2.cpp` 全部 851 行实现
2. 逐函数对照 Python 版 `db/dbv2.py` 验证接口兼容性与行为差异
3. 审查 `libmsg/inc/us/RpcServer.hpp`/`RpcClient.hpp` 确认框架层异常脱敏机制
@@ -172,3 +191,109 @@
5. 审查 `docker-entrypoint.sh` 确认 schema 初始化
6. 逐注入点构造 PoC 请求验证 SQL 注入可行性(DELETE 全表、UNION 数据泄露、ORDER BY 子查询)
7. 追踪空 `in` 数组代码路径确认全表删除行为
### 复审(2026-08-07)
1. 通读修复后 `db2/src/db2.cpp` 全部 875 行实现
2. 验证 P2-1 修复:确认 `strftime('%Y-%m-%dT%H:%M:%S','now',-N days)` 与 `utc_now()` 格式对齐
3. 验证 P2-2 修复:确认 7 个必需字段校验逻辑正确,`confs/configs/db.ini` 包含全部必需字段
4. 确认 P0-1/P0-2/P1-1/P3-2 修复在代码中仍然正确,无回退
5. 全量逐函数扫描发现 P3-5(空 values)、P3-6(未知运算符)两个新既有问题
6. 对照 Python `dbv2.py` 确认新增问题在两版中行为一致(非回归)
7. 更新安全机制清单行号引用至当前代码
### 本次 P3-1 修复验证
1. 审查 `SchemaCacheEntry` 结构,确认缓存项同时保存 `schema_version` 与 `cols`
2. 确认 `_get_schema_version` 通过 `PRAGMA schema_version` 读取当前数据库 schema 版本
3. 确认 `_get_schema` 在加锁前读取当前版本,缓存命中时严格比对版本号;版本不一致则重新执行 `PRAGMA table_info` 并更新缓存
4. 编译通过,无新增告警
5. 更新审计文档:修复进度、P3-1 状态、修复路线图、安全机制清单、并发性分析与复审结论
### 本次 P3-3 / P3-4 修复验证
1. 审查 `_do_select` 中 `limit`/`offset` 分支,确认提取 `int64` 值后校验 `>= 0`,负值抛 `"limit/offset must be non-negative"`
2. 审查 `_build_where` 入口,确认校验 `where.size() <= kMaxWhereConditions`(64),超限抛 `"too many where conditions"`
3. 审查 `in` 运算符分支,确认校验数组元素 `<= kMaxInElements`(1000),超限抛 `"too many elements in 'in' operator"`
4. 确认常量 `kMaxWhereConditions` / `kMaxInElements` 位于文件顶部,便于统一调整
5. 编译通过,无新增告警
6. 更新审计文档:修复进度、P3-3/P3-4 状态、修复路线图、安全机制清单与复审结论
---
## 并发性分析
### 并发模型
```
RpcServer (N workers, 默认 4)
├── thread 1 → thread_local DbConn (sqlite3*)
├── thread 2 → thread_local DbConn (sqlite3*)
├── thread 3 → thread_local DbConn (sqlite3*)
└── thread 4 → thread_local DbConn (sqlite3*)
Cleanup thread → thread_local DbConn (sqlite3*)
```
每个 worker 持有独立的 `sqlite3*` 连接(`thread_local`,L327-331),通过 WAL 模式实现并发读写。
### 优势
| 机制 | 位置 | 效果 |
|------|------|------|
| **WAL 模式** | L219 | 读不阻塞写、写不阻塞读,多读并发零争用 |
| **per-thread 连接** | L327-331 | 无跨线程共享 prepared statement,无需连接池锁 |
| **busy_timeout=5000** | L222 | 写-写冲突时等锁最多 5 秒,短暂突发写入可自动串行化 |
| **synchronous=NORMAL** | L220 | WAL 下减少 fsync,写吞吐提升约 2-3 倍(对比 FULL) |
| **mmap_size=256MB** | L223 | 热数据走内存映射,读性能接近 memcpy |
| **schema 缓存加锁** | L316 | 仅首次请求做 `PRAGMA table_info`,后续锁外快路径命中缓存 |
| **无 GIL** | — | C++ 线程真并行,不受 Python GIL 限制 |
典型场景表现:
- **读密集**(社区首页、用户查询):4 worker 并发读无争用,SQLite 读延迟 ~0.01ms,吞吐可达数万 QPS
- **读写混合**(dnfs 增删节点 + 查询):WAL 允许读写并行,偶尔写-写冲突由 busy_timeout 消化
### 瓶颈与风险
**1. 写-写串行化**
SQLite 任何时刻只允许一个写事务。4 个 worker 同时写时,后到者排队等锁:
```
thread 1: BEGIN → INSERT → COMMIT (持有写锁 ~0.1ms)
thread 2: BEGIN → INSERT → 等锁... → COMMIT
thread 3: BEGIN → INSERT → 等锁... → COMMIT
```
- `busy_timeout=5000` 兜底:等锁超 5 秒则抛 `SQLITE_BUSY`,返回 error 响应
- 实际影响:单表写入吞吐约 300-800 TPS(SQLite 典型值),对本项目(日志写入 + 偶尔 CRUD)足够
**2. 无事务包装的批量操作**
当前每条 execute 请求是独立的 auto-commit 事务。若客户端需要"插入 100 条要么全成功要么全失败",db2 不支持——每条独立提交,中途失败无法回滚前序行。Python 版同样如此(既有设计限制)。
**3. `_log_internal` 与业务写竞争**
后台清理线程的 `DELETE FROM Logs`(L806-851)和业务 worker 的 `INSERT INTO Logs` 可能同时争用写锁。清理操作涉及大量删除,持锁时间可能达数百毫秒,期间业务写入被阻塞。
**4. `_schema_mtx` 锁粒度**
全局互斥锁保护整个 `_schema_cache`。极端高并发(数百 QPS 首次请求不同表)时可能短暂排队,但实际影响极低(缓存命中后锁外快路径,仅首次 miss 持锁)。
### 与 Python 版对比
| 维度 | Python db (dbv2.py) | C++ db2 |
|------|---------------------|---------|
| 并发模型 | RpcServer N worker 线程 + 共享 engine | RpcServer N worker 线程 + per-thread sqlite3* |
| 连接管理 | SQLAlchemy 连接池(`check_same_thread=False`) | thread_local 独占连接 |
| 写冲突处理 | SQLAlchemy 自动重试 | busy_timeout=5000 |
| 事务控制 | `conn.commit()` 显式提交 | auto-commit(每条隐式事务) |
| GIL 影响 | Python GIL 限制真正并行 | C++ 无 GIL,真并行 |
**结论**:db2 的并发性优于 Python 版——无 GIL 限制、无连接池锁开销、per-thread 连接零共享。在当前项目规模下(日志写入 + 业务 CRUD,峰值百级 QPS)完全够用。若未来写入量级跃升至千级 TPS,需考虑:① 日志写入改批量事务 ② 清理线程错峰执行 ③ 或迁移至 PostgreSQL。
---
## 复审结论
P0/P1/P2/P3 级问题已全部修复(11/11)。当前版本安全基线已超越 Python 版(C++ 版新增了 schema 缓存失效、空 values 校验、未知运算符校验、limit/offset 非负校验、WHERE/IN 输入规模上限,Python 版尚无),可用于生产部署。