Files
blackbird/docs/security/dnfs2_SECURITY_AUDIT_20260808.md
T
qing 117fc0241e fix(libhttp,dnfs2): HttpClient 新增超时接口,dnfs2 启用配置超时
修复 dnfs2 审计 L-3(P2-N5):

libhttp HttpClient 硬编码 CURLOPT_TIMEOUT=30L/CONNECTTIMEOUT=10L,
dnfs2 的 gateway_timeout/review_timeout 配置加载后从未使用,实际超时恒为 30s。

修复:
- libhttp HttpClient 新增 set_timeout/set_connect_timeout 接口
  (默认 30s/10s,向后兼容 gateway/core)
- request_stream_sync 使用成员变量替代硬编码值
- dnfs2 decode_token 设置 gateway_timeout
- dnfs2 review_content 设置 review_timeout

安全评级 A- → A,已知限制 6→5 项。
2026-08-08 01:33:57 +08:00

244 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# dnfs2 安全审计报告
**审计日期**: 2026-08-08
**审计范围**: dnfs2/src/dnfs2.cpp (1180 行)
**对比基准**: dnfs/dnfs.py (723 行)
**审计轮次**: 第 1 轮
**安全评级**: A-
---
## 1. 审计概述
dnfs2 是 Python dnfs 的 C++20 重实现,基于 libhttp + libmsg + boost::json。本轮审计逐行对比 dnfs2 与 dnfs 的实现,验证计划中的 P0/P1 修复有效性,检查新引入的安全问题,并评估接口兼容性。
### 审计结论
| 类别 | 数量 | 状态 |
|------|------|------|
| 计划修复的 P0/P1(继承自 dnfs) | 6 | 全部有效 ✓ |
| 新发现问题 | 5 | 全部修复 ✓ |
| 已知限制(架构性/继承性) | 5 | 记录 |
---
## 2. 计划修复验证(P0/P1 继承自 dnfs)
### P0-4: 统一封装 _call_db() 检查 success — ✓ 已修复
**dnfs 问题**: 多处直接访问 `notes['data']` 而不检查 `success`,db 返回错误时引发 KeyError。
**dnfs2 修复**: 统一封装 `_call_db()` 返回 `DbResult{success, data, error}`,所有调用方先检查 `success` 再访问 `data`。
验证点:
- `_call_db()` 捕获 `RpcError` 返回 `{false, nullptr, err}`
- `_handle_get_notes`: `if (!r.success)` → 返回 500
- `_handle_add_note`: `if (!r.success)` → 抛 DnfsExp
- `_handle_delete_note`: 子节点检查和所有权检查均先判断 `success`
- `_handle_update_note`: 同上
### P1-6: 请求体大小限制 — ✓ 已修复
**dnfs 问题**: Flask/Waitress 无请求体大小限制。
**dnfs2 修复**: `server.setMaxBodySize(kMaxBodySize)` (10MB),对齐 WAF。
### P1-7: 日志客户端配置一致性 — ✓ 已修复
**dnfs 问题**: log_client 使用独立配置,可能与主配置不一致。
**dnfs2 修复**: `_log_rpc` 直接使用 `_cfg.db_host` / `_cfg.db_port`,与主 `_rpc` 同源配置。
### P1-8: update_note 改 pid 校验父节点存在 — ✓ 已修复
**dnfs 问题**: update_note 改 pid 时不校验新父节点是否存在。
**dnfs2 修复**: `if (pid != 0 && !_note_exists(pid))` → 抛 DnfsExp("父节点不存在", 400)。
### P1-9: RpcClient 启用 retry_on_refused — ✓ 已修复
**dnfs 问题**: db 服务未启动时 dnfs 直接失败退出。
**dnfs2 修复**: `RpcClient(..., retry_on_refused=true)`,等待 db 就绪。
### P1-11: note_id 强制类型转换 — ✓ 已修复
**dnfs 问题**: `note_id = data.get('id')` 无类型校验,字符串/None 引发后续错误。
**dnfs2 修复**: `get_int()` 使用 `is_int64()` 严格校验,非整数直接返回 400。
---
## 3. 新发现问题
### P1-N1: stop_watcher 分离线程 use-after-free 竞态 — ✓ 已修复
**位置**: `run()` 方法,原 L522-528
**问题**: `stop_watcher` 线程被 `detach()`,`server.run()` 返回后局部变量 `server` 被析构。stop_watcher 在下一次 200ms 轮询时调用 `_server->stop()` → 访问已析构对象(CWE-416)。
时序分析:
1. 信号 → `_should_stop = true`
2. stop_watcher: 调用 `_server->stop()`
3. 主线程: `server.run()` 返回 → 局部 `server` 析构
4. stop_watcher: 200ms 后循环 → `_server->stop()` → **use-after-free**
**修复**: 改为 `join()`,`server.run()` 返回后设置 `_should_stop=true` 并 `join()` stop_watcher 线程,确保线程在 `server` 析构前退出。
```cpp
// 修复后
server.run();
_should_stop.store(true);
if (stop_watcher.joinable()) stop_watcher.join();
```
### P1-N2: curl_global_init 未调用,多线程首次 curl_easy_init 竞态 — ✓ 已修复
**位置**: `main()` / `decode_token()` / `review_content()`
**问题**: HttpClient 构造函数调用 `curl_easy_init()` 但从未调用 `curl_global_init()`。libcurl 文档明确指出:首次 `curl_easy_init()` 会隐式调用 `curl_global_init()`,但此隐式调用**非线程安全**。dnfs2 使用多线程 HttpServer,多个 worker 线程可能同时首次创建 HttpClient → 竞态条件(CWE-362)。
**修复**: 在 `main()` 最开始显式调用 `curl_global_init(CURL_GLOBAL_DEFAULT)`,在多线程启动前完成全局初始化。
```cpp
int main(int argc, char* argv[]) {
curl_global_init(CURL_GLOBAL_DEFAULT);
// ...
}
```
### P2-N3: delete_note 子节点查询失败时错误消息误导 — ✓ 已修复
**位置**: `_handle_delete_note()`,原 L968-972
**问题**: 原代码将 db 查询失败和"存在子节点"合并为同一判断,db 故障时返回 `"this node has a child"` (400),客户端可能误判为永久性条件。
```cpp
// 修复前
if (!child_r.success || !child_r.data.is_array() || !child_r.data.as_array().empty()) {
throw DnfsExp("this node has a child", 400);
}
// 修复后:分离 db 错误和子节点检查
if (!child_r.success || !child_r.data.is_array()) {
throw DnfsExp("查询子节点失败", 500); // db 故障返回 500
}
if (!child_r.data.as_array().empty()) {
throw DnfsExp("this node has a child", 400); // 有子节点返回 400
}
```
### P2-N4: threads 无上限校验 — ✓ 已修复
**位置**: `load_config()` L257-260
**问题**: 仅校验 `threads >= 1`,无上限。用户可设置 `--threads 10000` 创建过多线程。项目约定 workers 范围 [0, 256]。
**修复**: 添加上限 256,与项目约定一致。
```cpp
if (cfg.threads < 1 || cfg.threads > 256) { ... exit(1); }
```
### P2-N5: HttpClient 超时硬编码,配置值未生效 — ✓ 已修复
**位置**: `libhttp/src/HttpClient.cpp` L92-93(原)、`dnfs2/src/dnfs2.cpp`
**问题**: HttpClient `request_stream_sync` 硬编码 `CURLOPT_TIMEOUT=30L` / `CURLOPT_CONNECTTIMEOUT=10L`,无接口可覆盖。dnfs2 的 `review_timeout` 和 `gateway_timeout` 配置加载并校验范围后从未使用,实际超时恒为 30s。
**修复**:
1. libhttp `HttpClient` 新增 `set_timeout()` / `set_connect_timeout()` 接口(默认值 30s/10s,向后兼容 gateway/core)
2. `request_stream_sync` 使用成员变量替代硬编码值
3. dnfs2 `decode_token()` 设置 `gateway_timeout`,`review_content()` 设置 `review_timeout`
```cpp
// libhttp/HttpClient.hpp
void set_timeout(long seconds);
long get_timeout() const;
void set_connect_timeout(long seconds);
long get_connect_timeout() const;
long timeout_ = 30; // 默认 30s
long connect_timeout_ = 10; // 默认 10s
// dnfs2 decode_token
http.set_timeout(static_cast<long>(_cfg.gateway_timeout));
// dnfs2 review_content
http.set_timeout(static_cast<long>(_cfg.review_timeout));
```
---
## 4. 已知限制(不修复,架构性/继承性)
### L-1: update_note 允许创建循环(P1,继承自 dnfs)
update_note 允许将节点的 pid 改为任意已存在的节点,包括自身或自身后代,可创建循环。这会导致树结构损坏。修复需要图遍历检测后代关系,超出本次范围。Python dnfs 有相同问题。
### L-2: delete_note TOCTOU 竞态(P0 级残余,继承自 dnfs)
delete_note 在"检查无子节点"和"执行删除"之间存在 TOCTOU 窗口,并发请求可在检查后添加子节点。修复需要 db2 支持事务或原子条件删除。dnfs2 已通过 `_call_db()` 统一检查 success 修复了"未检查 success"部分,但 TOCTOU 窗口仍存在。
### L-4: 无速率限制(P2,继承自 dnfs)
无请求频率限制,恶意用户可高频调用 add_note 填充数据库。应由 gateway/WAF 层防护。
### L-5: title/content 无最大长度校验(P2)
仅校验非空,无长度上限(受 body size 10MB 间接限制)。超长 title 会导致 AI 审核和数据库存储性能问题。Python dnfs 有相同问题。
### L-6: AI 审核服务不可用时阻断所有写入(P2,设计决策)
`review_content()` 采用 fail-closed 策略:ai_review 不可达时返回 false,所有 add_note/update_note 被拒绝。这是安全优先的设计决策,但可能导致服务不可用。
---
## 5. 接口兼容性分析
### 响应格式兼容 — ✓
| 路由 | dnfs 响应 | dnfs2 响应 | 兼容 |
|------|-----------|------------|------|
| get_notes | `jsonify(result)` 200 | `json::serialize(result)` 200 | ✓ |
| add_note 成功 | `{'success': true}` 200 | `{"success": true}` 200 | ✓ |
| add_note 失败 | `{'error': msg}` 400 | `{"error": msg}` 400 | ✓ |
| delete_note 成功 | `{'success': true}` 200 | `{"success": true}` 200 | ✓ |
| delete_note 失败 | `{'success': false}` 或 `{'error': msg}` | 同上 | ✓ |
| update_note | 同 delete_note | 同上 | ✓ |
### 错误消息差异(P3,不影响功能)
dnfs2 的参数校验错误消息更具体(如 `"非法参数: pid 必须是整数"` vs dnfs 的 `"非法参数"`),但前端仅展示给用户,不影响逻辑。
### 安全改进(P2-12 部分修复)
dnfs 的错误消息泄露 IP 和用户名(如 `"未授权访问,从192.168.1.1"`),dnfs2 移除了这些泄露:
| 场景 | dnfs 消息 | dnfs2 消息 |
|------|-----------|------------|
| 未授权 | `未授权访问,从{ip}` | `未授权访问` |
| 特殊节点非管理员 | `特殊节点仅管理员可发布,用户{user},从{ip}` | `特殊节点仅管理员可发布` |
| 审核不通过 | `内容审核不通过,用户{user},从{ip}` | `内容审核不通过` |
---
## 6. 安全评级
### 评级: A
**理由**:
- 计划修复的 6 项 P0/P1 全部有效 ✓
- 新发现 5 项问题全部修复 ✓ (2 P1 + 3 P2)
- IP/用户名泄露问题作为重写副效果已修复 ✓
- 残余 5 项已知限制均为架构性/继承性问题,有明确修复路径
- 相比 Python dnfs(评级 B,多个 P0/P1 未修复),安全基线显著提升
**扣分项**:
- L-1/L-2 循环创建和 TOCTOU 未修复(需 db2 事务支持)
---
## 7. 修复文件清单
| 文件 | 修改内容 |
|------|---------|
| dnfs2/src/dnfs2.cpp | P1-N1: stop_watcher join 替代 detach |
| dnfs2/src/dnfs2.cpp | P1-N2: main() 添加 curl_global_init |
| dnfs2/src/dnfs2.cpp | P2-N3: delete_note 分离 db 错误和子节点检查 |
| dnfs2/src/dnfs2.cpp | P2-N4: threads 上限 256 |
| dnfs2/src/dnfs2.cpp | 新增 `#include <curl/curl.h>` |
| libhttp/inc/us/HttpClient.hpp | P2-N5: 新增 set_timeout/set_connect_timeout 接口 + 成员变量 |
| libhttp/src/HttpClient.cpp | P2-N5: 新增 set_timeout/set_connect_timeout 实现,request_stream_sync 使用成员变量 |
| dnfs2/src/dnfs2.cpp | P2-N5: decode_token 设置 gateway_timeout,review_content 设置 review_timeout |
编译验证: `g++ -std=c++20 -O3 -Wall -Werror` 通过,零错误零警告(libhttp + dnfs2 均重新编译)。