diff --git a/dnfs2/.gitignore b/dnfs2/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/dnfs2/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/dnfs2/Makefile b/dnfs2/Makefile new file mode 100644 index 0000000..6bca29e --- /dev/null +++ b/dnfs2/Makefile @@ -0,0 +1,26 @@ +CPP = g++ +CPPFLAGS = -std=c++20 -O3 -Wall -Werror -pipe +INCS = -I ../libhttp/inc -I ../libmsg/inc -I /opt/homebrew/include +LIBS = -L ../libhttp/build -L ../libmsg/build -L /opt/homebrew/lib \ + -lus_http -lus_httpclient -lus_msg -lboost_json \ + -lssl -lcrypto -lcurl -lpthread +RPATH = -Wl,-rpath,'$$ORIGIN/../../libhttp/build' \ + -Wl,-rpath,'$$ORIGIN/../../libmsg/build' \ + -Wl,-rpath,/opt/homebrew/lib + +BUILD = build +TARGET = $(BUILD)/dnfs2 +SRC = src/dnfs2.cpp + +all: $(TARGET) + +$(BUILD): + mkdir -p $(BUILD) + +$(TARGET): $(SRC) | $(BUILD) + $(CPP) $(CPPFLAGS) $(INCS) -o $@ $< $(LIBS) $(RPATH) + +clean: + rm -f $(TARGET) + +.PHONY: all clean diff --git a/dnfs2/README.md b/dnfs2/README.md new file mode 100644 index 0000000..68b748a --- /dev/null +++ b/dnfs2/README.md @@ -0,0 +1,85 @@ +# dnfs2 — C++20 树状网络文件系统 + +dnfs 的 C++ 重实现,接口与 Python dnfs 完全兼容,修复 P0/P1 安全问题。基于 libhttp + libmsg + boost::json。 + +## 架构 + +``` +gateway (验签/转发) + │ Bearer Token + ▼ +dnfs2 :8006 ──pymsg──► db2 :8005 (execute 路由) ──► SQLite + │ ▲ + │ libmsg (RpcClient) + └──HTTP──► ai_review :8007 (内容审核) +``` + +- **HTTP 服务**: libhttp `HttpServer`(Boost.Beast 异步,多线程) +- **Token 验签**: HttpClient POST gateway `/whoami`,fail-closed(503) +- **数据操作**: libmsg `RpcClient` 调 db2 `execute` 路由 +- **内容审核**: HttpClient POST ai_review `/review`,fail-closed +- **异步日志**: libmsg `AsyncQueue` → db2 Logs 表 + +## 接口 + +| 路由 | 鉴权 | 说明 | +|------|------|------| +| POST /get_notes | 否(可选 Token,用于 can_operate) | 返回子节点数组 | +| POST /add_note | 是 | 校验 Token → 父节点存在 → AI 审核 → 插入 | +| POST /delete_note | 是 | 校验 Token → 检查子节点 → 所有权 → 删除 | +| POST /update_note | 是 | 校验 Token → 所有权 → 父节点存在 → AI 审核 → 更新 | + +请求/响应 JSON 格式与 Python dnfs 完全一致,前端零改动。 + +## 配置 + +从 confs 拉取 `dnfs.ini`,环境变量覆盖,命令行参数最高优先级。 + +```ini +[server] +host = 127.0.0.1 +port = 8006 + +[database] +host = 127.0.0.1 +port = 8005 +timeout = 10 + +[ai_review] +url = http://127.0.0.1:8007/review +timeout = 10 + +[gateway] +url = http://127.0.0.1:8443/whoami +timeout = 5 +``` + +环境变量: `CONFS_HOST`, `CONFS_PORT`, `LISTEN_ADDR`, `LISTEN_PORT`, `DB_HOST`, `DB_PORT`, `REVIEW_URL`, `GATEWAY_URL` + +命令行: `--addr`, `--port`, `--threads` + +## 构建 + +```bash +./_set.sh # 编译 +./_run.sh # 运行 +``` + +依赖: libhttp, libmsg, boost::json, OpenSSL, libcurl + +## 安全修复(相比 Python dnfs) + +| 编号 | 问题 | 修复方案 | +|------|------|---------| +| P0-4 | 父节点删除 TOCTOU + 未检查 success | 统一封装 `_call_db()` 检查 `success` 后再访问 `data` | +| P1-6 | 请求体无限制 | `server.setMaxBodySize(10MB)` | +| P1-7 | log_client 配置不一致 | 日志客户端复用主配置 | +| P1-8 | update_note 改 pid 未校验父节点 | pid≠0 时校验父节点存在 | +| P1-9 | db RPC 未启用启动等待 | `RpcClient(retry_on_refused=true)` | +| P1-11 | note_id 无强制类型转换 | 显式 `as_int64()` + 类型校验 | + +## 已知限制(P2,设计决策不修复) + +- P2-12: 错误响应可能泄露信息(新实现已尽量脱敏) +- P2-13: CORS 反射任意 Origin(内部服务绑 127.0.0.1,可接受) +- P2-15: pid 判断已加严格类型校验,但未完全对齐 Python 语义 diff --git a/dnfs2/_run.sh b/dnfs2/_run.sh new file mode 100644 index 0000000..726220e --- /dev/null +++ b/dnfs2/_run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" +exec ./build/dnfs2 "$@" diff --git a/dnfs2/_set.sh b/dnfs2/_set.sh new file mode 100644 index 0000000..d716258 --- /dev/null +++ b/dnfs2/_set.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" +make diff --git a/dnfs2/src/dnfs2.cpp b/dnfs2/src/dnfs2.cpp new file mode 100644 index 0000000..53b1464 --- /dev/null +++ b/dnfs2/src/dnfs2.cpp @@ -0,0 +1,1176 @@ +/** dnfs2 — C++20 树状网络文件系统(对齐 Python dnfs 模块) + * + * 基于 libhttp + libmsg + boost::json 实现,接口与 Python dnfs 完全兼容: + * POST /get_notes 获取子节点列表(无需鉴权) + * POST /add_note 添加节点(需 Token + AI 审核) + * POST /delete_note 删除节点(需 Token + 所有权) + * POST /update_note 更新节点(需 Token + 所有权 + AI 审核) + * + * 安全修复(相比 Python dnfs): + * P0-4 统一封装 _call_db() 检查 success 后再访问 data + * P1-6 server.setMaxBodySize(10MB) 限制请求体大小 + * P1-7 日志客户端复用主配置(confs 拉取的 db host/port) + * P1-8 update_note 改 pid≠0 时校验父节点存在 + * P1-9 RpcClient 启用 retry_on_refused + * P1-11 note_id 显式 as_int64() + 类型校验 + * + * 依赖: libhttp (HttpServer), libmsg (RpcClient/AsyncQueue), boost::json, + * OpenSSL, libcurl + */ + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace json = boost::json; +namespace http = boost::beast::http; +using namespace pmc::net; + +// ============================================================================ +// 常量 +// ============================================================================ + +// 请求体大小上限(对齐 WAF,防 OOM) +static constexpr size_t kMaxBodySize = 10 * 1024 * 1024; + +// 特殊节点 +static constexpr size_t kSpMaxUrl = 2048; +static constexpr size_t kSpMaxCode = 512 * 1024; +static constexpr size_t kSpMaxData = 3500 * 1024; +static constexpr size_t kSpReviewCodePrefix = 4000; + +// 日志 +static constexpr size_t kLogMaxMessage = 8192; +static constexpr size_t kLogMaxExtra = 4096; +static constexpr size_t kLogBatchSize = 50; +static constexpr double kLogFlushInterval = 2.0; +static constexpr size_t kLogQueueMax = 10000; +static constexpr double kLogRpcTimeout = 5.0; + +// HTTP 响应大小上限(对齐 WAF) +static constexpr size_t kHttpMaxResponse = 10 * 1024 * 1024; + +// ============================================================================ +// 工具函数 +// ============================================================================ + +static std::string utc_now() { + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + struct tm tmbuf; + gmtime_r(&t, &tmbuf); + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tmbuf); + return buf; +} + +static int safe_stoi(const std::string& s, const char* field_name) { + try { + size_t pos = 0; + int val = std::stoi(s, &pos); + if (pos != s.size()) { + throw std::runtime_error(std::string("invalid ") + field_name + + ": '" + s + "' (trailing characters)"); + } + return val; + } catch (const std::runtime_error&) { + throw; + } catch (const std::exception& e) { + throw std::runtime_error(std::string("invalid ") + field_name + + ": '" + s + "' (" + e.what() + ")"); + } +} + +// ============================================================================ +// INI 解析(白名单模式,与 db2 一致) +// ============================================================================ + +using IniMap = std::map>; + +static IniMap parse_ini(const std::string& text) { + IniMap result; + std::string section; + std::istringstream ss(text); + std::string line; + while (std::getline(ss, line)) { + auto b = line.find_first_not_of(" \t\r\n"); + if (b == std::string::npos) continue; + line = line.substr(b); + auto e = line.find_last_not_of(" \t\r\n"); + if (e != std::string::npos) line = line.substr(0, e + 1); + + if (line.empty() || line[0] == '#' || line[0] == ';') continue; + if (line.front() == '[' && line.back() == ']') { + section = line.substr(1, line.size() - 2); + continue; + } + auto eq = line.find('='); + if (eq == std::string::npos || section.empty()) continue; + std::string key = line.substr(0, eq); + std::string val = line.substr(eq + 1); + auto trim = [](std::string& s) { + auto bb = s.find_first_not_of(" \t"); + auto ee = s.find_last_not_of(" \t"); + s = (bb == std::string::npos) ? "" : s.substr(bb, ee - bb + 1); + }; + trim(key); trim(val); + result[section][key] = val; + } + return result; +} + +// ============================================================================ +// 配置 +// ============================================================================ + +struct Config { + std::string listen_host = "127.0.0.1"; + int listen_port = 8006; + int threads = 4; + + std::string db_host = "127.0.0.1"; + int db_port = 8005; + double db_timeout = 10.0; + + std::string review_url = "http://127.0.0.1:8007/review"; + double review_timeout = 10.0; + + std::string gateway_url = "http://127.0.0.1:8443/whoami"; + double gateway_timeout = 5.0; + + // confs 引导 + std::string confs_host = "127.0.0.1"; + int confs_port = 8010; +}; + +static Config load_config(int argc, char* argv[]) { + Config cfg; + + // 1. confs 引导地址 + if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h; + if (const char* p = std::getenv("CONFS_PORT")) cfg.confs_port = safe_stoi(p, "CONFS_PORT"); + + // 2. 从 confs 拉取 dnfs.ini(retry_on_refused=true,等待 confs 就绪) + std::string ini_text; + try { + RpcClient confs_client(cfg.confs_host, cfg.confs_port, "dnfs2", + /*timeout=*/5.0, /*retry_on_refused=*/true); + json::value data = confs_client.call("get", {{"name", "dnfs.ini"}}); + if (data.is_object() && data.as_object().contains("content")) { + ini_text = std::string(data.as_object().at("content").as_string()); + } + } catch (const std::exception& e) { + std::fprintf(stderr, "[dnfs2] 从 confs 获取 dnfs.ini 失败: %s\n", e.what()); + std::exit(1); + } + + // 3. 解析 INI + IniMap ini = parse_ini(ini_text); + + // 校验必需配置项 + static const std::pair required_keys[] = { + {"server", "host"}, + {"server", "port"}, + {"database", "host"}, + {"database", "port"}, + {"database", "timeout"}, + {"ai_review", "url"}, + {"ai_review", "timeout"}, + {"gateway", "url"}, + {"gateway", "timeout"}, + }; + 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, "[dnfs2] 缺少必需配置: [%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); + if (si == ini.end()) return def; + auto ki = si->second.find(key); + return (ki == si->second.end()) ? def : ki->second; + }; + + cfg.listen_host = get("server", "host", cfg.listen_host); + cfg.listen_port = safe_stoi(get("server", "port", std::to_string(cfg.listen_port)), "[server] port"); + cfg.db_host = get("database", "host", cfg.db_host); + cfg.db_port = safe_stoi(get("database", "port", std::to_string(cfg.db_port)), "[database] port"); + cfg.db_timeout = std::stod(get("database", "timeout", std::to_string(cfg.db_timeout))); + cfg.review_url = get("ai_review", "url", cfg.review_url); + cfg.review_timeout = std::stod(get("ai_review", "timeout", std::to_string(cfg.review_timeout))); + cfg.gateway_url = get("gateway", "url", cfg.gateway_url); + cfg.gateway_timeout = std::stod(get("gateway", "timeout", std::to_string(cfg.gateway_timeout))); + + // 4. 环境变量覆盖(Docker 兼容) + if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v; + if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = safe_stoi(v, "LISTEN_PORT"); + if (const char* v = std::getenv("DB_HOST")) cfg.db_host = v; + if (const char* v = std::getenv("DB_PORT")) cfg.db_port = safe_stoi(v, "DB_PORT"); + if (const char* v = std::getenv("REVIEW_URL")) cfg.review_url = v; + if (const char* v = std::getenv("GATEWAY_URL")) cfg.gateway_url = v; + + // 5. 命令行参数(最高优先级) + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if ((arg == "--addr" || arg == "-a") && i + 1 < argc) cfg.listen_host = argv[++i]; + else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port"); + else if ((arg == "--threads" || arg == "-t") && i + 1 < argc) cfg.threads = safe_stoi(argv[++i], "--threads"); + } + + // 6. 数值范围校验 + if (cfg.listen_port < 1 || cfg.listen_port > 65535) { + std::fprintf(stderr, "[dnfs2] invalid listen_port: %d (must be 1-65535)\n", cfg.listen_port); + std::exit(1); + } + if (cfg.db_port < 1 || cfg.db_port > 65535) { + std::fprintf(stderr, "[dnfs2] invalid db_port: %d (must be 1-65535)\n", cfg.db_port); + std::exit(1); + } + if (cfg.threads < 1 || cfg.threads > 256) { + std::fprintf(stderr, "[dnfs2] invalid threads: %d (must be 1-256)\n", cfg.threads); + std::exit(1); + } + if (cfg.db_timeout <= 0 || cfg.db_timeout > 60.0) { + std::fprintf(stderr, "[dnfs2] invalid db_timeout: %f (must be in (0, 60])\n", cfg.db_timeout); + std::exit(1); + } + if (cfg.review_timeout <= 0 || cfg.review_timeout > 60.0) { + std::fprintf(stderr, "[dnfs2] invalid review_timeout: %f (must be in (0, 60])\n", cfg.review_timeout); + std::exit(1); + } + if (cfg.gateway_timeout <= 0 || cfg.gateway_timeout > 60.0) { + std::fprintf(stderr, "[dnfs2] invalid gateway_timeout: %f (must be in (0, 60])\n", cfg.gateway_timeout); + std::exit(1); + } + + return cfg; +} + +// ============================================================================ +// 异常类 +// ============================================================================ + +class DnfsExp : public std::runtime_error { +public: + DnfsExp(const std::string& msg, int code) + : std::runtime_error(msg), status_(code) {} + int status() const noexcept { return status_; } +private: + int status_; +}; + +// ============================================================================ +// 特殊节点校验 +// ============================================================================ + +static const std::vector kSpTypes = {"link", "js", "image", "file"}; + +// 识别特殊节点信封:合法信封返回 object;普通内容返回 nullptr; +// 以 {"_sp" 开头但 JSON 非法视为格式错误(抛异常)。 +static json::value parse_sp(const std::string& content) { + if (content.empty()) return nullptr; + + size_t start = content.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) return nullptr; + std::string s = content.substr(start); + if (s.size() < 6 || s.substr(0, 6) != "{\"_sp\"") return nullptr; + + try { + json::value obj = json::parse(s); + if (obj.is_object()) { + const auto& o = obj.as_object(); + if (o.contains("_sp") && o.at("_sp").is_string()) { + std::string sp_type = std::string(o.at("_sp").as_string()); + for (const auto& t : kSpTypes) { + if (sp_type == t) return obj; + } + } + } + return nullptr; + } catch (const std::exception&) { + throw DnfsExp("特殊节点格式非法(JSON 解析失败)", 400); + } +} + +static void validate_sp(const json::object& sp) { + std::string t = std::string(sp.at("_sp").as_string()); + + if (t == "link") { + if (!sp.contains("url") || !sp.at("url").is_string()) + throw DnfsExp("链接节点缺少 url", 400); + std::string url = std::string(sp.at("url").as_string()); + if (url.rfind("http://", 0) != 0 && url.rfind("https://", 0) != 0) + throw DnfsExp("链接节点必须是 http/https URL", 400); + if (url.size() > kSpMaxUrl) + throw DnfsExp("链接过长", 400); + } else if (t == "js") { + if (!sp.contains("code") || !sp.at("code").is_string()) + throw DnfsExp("前端代码不能为空", 400); + std::string code = std::string(sp.at("code").as_string()); + if (code.empty() || code.find_first_not_of(" \t\r\n") == std::string::npos) + throw DnfsExp("前端代码不能为空", 400); + if (code.size() > kSpMaxCode) + throw DnfsExp("前端代码过大(上限 512KB)", 400); + } else if (t == "image") { + if (!sp.contains("data") || !sp.at("data").is_string()) + throw DnfsExp("图片节点缺少 data", 400); + std::string data = std::string(sp.at("data").as_string()); + if (data.rfind("data:image/", 0) != 0) + throw DnfsExp("图片节点 data 必须是 data:image/ 开头的 DataURL", 400); + if (data.size() > kSpMaxData) + throw DnfsExp("图片过大(上限约 2.6MB)", 400); + } else if (t == "file") { + if (!sp.contains("name") || !sp.at("name").is_string()) + throw DnfsExp("文件节点缺少文件名", 400); + std::string name = std::string(sp.at("name").as_string()); + if (name.empty() || name.find_first_not_of(" \t\r\n") == std::string::npos) + throw DnfsExp("文件节点缺少文件名", 400); + if (name.size() > 255) + throw DnfsExp("文件名过长", 400); + if (!sp.contains("data") || !sp.at("data").is_string()) + throw DnfsExp("文件节点缺少数据", 400); + std::string data = std::string(sp.at("data").as_string()); + if (data.empty()) + throw DnfsExp("文件节点缺少数据", 400); + if (data.size() > kSpMaxData) + throw DnfsExp("文件过大(上限约 2.6MB)", 400); + } +} + +// 特殊节点送审文本:剔除 base64 负载 +static std::string sp_review_text(const std::string& title, const json::object& sp) { + std::vector parts; + parts.push_back(title); + + if (sp.contains("note") && sp.at("note").is_string()) { + std::string note = std::string(sp.at("note").as_string()); + if (!note.empty()) parts.push_back(note); + } + + std::string t = std::string(sp.at("_sp").as_string()); + if (t == "link") { + if (sp.contains("url") && sp.at("url").is_string()) + parts.push_back(std::string(sp.at("url").as_string())); + } else if (t == "js") { + if (sp.contains("code") && sp.at("code").is_string()) { + std::string code = std::string(sp.at("code").as_string()); + if (code.size() > kSpReviewCodePrefix) code = code.substr(0, kSpReviewCodePrefix); + parts.push_back(code); + } + } else { + if (sp.contains("name") && sp.at("name").is_string()) + parts.push_back(std::string(sp.at("name").as_string())); + } + + std::string result; + for (size_t i = 0; i < parts.size(); ++i) { + if (i > 0) result += "\n"; + result += parts[i]; + } + return result; +} + +// ============================================================================ +// 日志 +// ============================================================================ + +struct LogEntry { + std::string ts; + std::string service; + std::string level; + std::string message; + std::string trace_id; + std::string extra; +}; + +// ============================================================================ +// HTTP 响应辅助 +// ============================================================================ + +static http::response make_response( + http::status status, + const std::string& body, + const std::string& content_type, + const http::request& req) +{ + http::response res; + res.result(status); + res.version(req.version()); + res.set(http::field::content_type, content_type); + res.keep_alive(req.keep_alive()); + + // 安全响应头 + res.set("X-Frame-Options", "SAMEORIGIN"); + res.set("X-XSS-Protection", "1; mode=block"); + res.set("X-Content-Type-Options", "nosniff"); + res.set("Referrer-Policy", "strict-origin-when-cross-origin"); + res.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + res.set("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; " + "font-src 'self'; connect-src 'self'; frame-ancestors 'self'"); + + res.body() = body; + res.prepare_payload(); + return res; +} + +static http::response make_json( + http::status status, + const std::string& json_str, + const http::request& req) +{ + return make_response(status, json_str, "application/json", req); +} + +static http::response make_json_obj( + http::status status, + const json::object& obj, + const http::request& req) +{ + return make_json(status, json::serialize(obj), req); +} + +static http::response make_error( + http::status status, + const std::string& error_msg, + const http::request& req) +{ + json::object obj; + obj["error"] = error_msg; + return make_json_obj(status, obj, req); +} + +// ============================================================================ +// Dnfs2Server +// ============================================================================ + +class Dnfs2Server { +public: + explicit Dnfs2Server(Config cfg) + : _cfg(std::move(cfg)), + _rpc(_cfg.db_host, _cfg.db_port, "dnfs2", _cfg.db_timeout, + /*retry_on_refused=*/true), + _log_rpc(_cfg.db_host, _cfg.db_port, "dnfs2-log", kLogRpcTimeout, + /*retry_on_refused=*/false) { + + // 初始化异步日志队列 + _log_queue = std::make_unique>( + [this](std::vector&& batch) { _send_log_batch(std::move(batch)); }, + AsyncQueueConfig{kLogBatchSize, kLogFlushInterval, kLogQueueMax, 0.5}, + [](const std::exception& e, std::vector&&) { + std::fprintf(stderr, "[dnfs2] 日志队列异常: %s\n", e.what()); + } + ); + _log_queue->start(); + + std::printf("[dnfs2] 配置来源: confs (dnfs.ini @ %s:%d)\n", + _cfg.confs_host.c_str(), _cfg.confs_port); + std::printf("[dnfs2] Token 验签: gateway %s\n", _cfg.gateway_url.c_str()); + } + + ~Dnfs2Server() { + if (_log_queue) _log_queue->shutdown(); + } + + void run() { + HttpServer server(_cfg.listen_host, static_cast(_cfg.listen_port), + static_cast(_cfg.threads)); + + // 请求体大小限制(P1-6 修复) + server.setMaxBodySize(kMaxBodySize); + + // 注册路由 + server.post("/get_notes", [this](const auto& req, const auto&) { return _handle_get_notes(req); }); + server.post("/add_note", [this](const auto& req, const auto&) { return _handle_add_note(req); }); + server.post("/delete_note", [this](const auto& req, const auto&) { return _handle_delete_note(req); }); + server.post("/update_note", [this](const auto& req, const auto&) { return _handle_update_note(req); }); + + // 信号处理(优雅关闭) + _server = &server; + std::signal(SIGINT, &Dnfs2Server::_signal_handler); + std::signal(SIGTERM, &Dnfs2Server::_signal_handler); + + std::thread stop_watcher([this]() { + while (!_should_stop.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + if (_server) _server->stop(); + }); + + _log("INFO", "dnfs2", "服务启动,监听 http://" + _cfg.listen_host + ":" + + std::to_string(_cfg.listen_port) + + "(db=" + _cfg.db_host + ":" + std::to_string(_cfg.db_port) + + ", ai_review=" + _cfg.review_url + ")"); + + server.run(); + + // 确保停止标志置位(处理 server.run() 非信号退出的情况) + _should_stop.store(true); + if (stop_watcher.joinable()) stop_watcher.join(); + + _log("INFO", "dnfs2", "服务停止"); + if (_log_queue) _log_queue->shutdown(); + } + +private: + Config _cfg; + RpcClient _rpc; // db 调用(retry_on_refused=true,P1-9 修复) + RpcClient _log_rpc; // 日志写入(不重试,失败即丢弃) + + std::unique_ptr> _log_queue; + + HttpServer* _server = nullptr; + static std::atomic _should_stop; + + static void _signal_handler(int) { + _should_stop.store(true); + } + + // ---- 日志 -------------------------------------------------------------- + + void _log(const std::string& level, const std::string& service, + const std::string& message, const std::string& trace_id = "", + const std::string& extra = "") { + LogEntry e; + e.ts = utc_now(); + e.service = service; + e.level = level; + e.message = (message.size() > kLogMaxMessage) ? message.substr(0, kLogMaxMessage) : message; + e.trace_id = trace_id; + e.extra = (extra.size() > kLogMaxExtra) ? extra.substr(0, kLogMaxExtra) : extra; + if (_log_queue) _log_queue->push(std::move(e)); + } + + void _send_log_batch(std::vector&& batch) { + for (auto& e : batch) { + try { + json::object values; + values["ts"] = e.ts; + values["service"] = e.service; + values["level"] = e.level; + values["message"] = e.message; + if (!e.trace_id.empty()) values["trace_id"] = e.trace_id; + else values["trace_id"] = nullptr; + if (!e.extra.empty()) values["extra"] = e.extra; + else values["extra"] = nullptr; + + json::object payload; + payload["action"] = "insert"; + payload["table"] = "Logs"; + payload["values"] = values; + + _log_rpc.call("execute", payload); + } catch (const std::exception& ex) { + std::fprintf(stderr, "[dnfs2] 日志写入失败: %s\n", ex.what()); + } + } + } + + // ---- db 调用封装(P0-4 修复:统一检查 success)-------------------------- + + struct DbResult { + bool success = false; + json::value data; // 成功时为 data 字段 + std::string error; // 失败时为错误信息 + }; + + DbResult _call_db(const json::object& payload) { + try { + json::value data = _rpc.call("execute", payload); + return {true, data, ""}; + } catch (const RpcError& e) { + return {false, nullptr, e.what()}; + } catch (const std::exception& e) { + // 网络错误(连接失败、超时等) + _log("ERROR", "dnfs2", std::string("db 调用失败: ") + e.what()); + throw DnfsExp("数据库服务暂时不可用", 502); + } + } + + // 查询节点是否存在 + bool _note_exists(int64_t id) { + json::object payload; + payload["action"] = "select"; + payload["table"] = "Notes"; + payload["where"] = json::object{{"id", id}}; + payload["limit"] = 1; + + DbResult r = _call_db(payload); + if (!r.success) return false; + return r.data.is_array() && !r.data.as_array().empty(); + } + + // ---- Token 验签(通过 gateway /whoami)--------------------------------- + + struct UserInfo { + std::string user; + bool is_admin = false; + }; + + // 返回 UserInfo;验签失败返回 nullopt;网络异常抛 DnfsExp(503) + std::optional decode_token(const std::string& token) { + if (token.empty()) return std::nullopt; + + HttpClient http; + http.set_max_response_size(kHttpMaxResponse); + http.set_header("Content-Type: application/json"); + http.set_header("Authorization: Bearer " + token); + + std::string response_body; + try { + http.post_stream_sync(_cfg.gateway_url, "{}", + [&response_body](const std::string& chunk) { + response_body += chunk; + }); + } catch (const std::exception& e) { + _log("WARNING", "dnfs2", std::string("Token 验签失败(gateway 异常): ") + e.what()); + throw DnfsExp("认证服务暂时不可用", 503); + } + + long code = http.get_response_code(); + if (code != 200) { + _log("WARNING", "dnfs2", "Token 验签失败(gateway 返回 " + std::to_string(code) + ")"); + throw DnfsExp("认证服务暂时不可用", 503); + } + + json::value data; + try { + data = json::parse(response_body); + } catch (const std::exception&) { + _log("WARNING", "dnfs2", "Token 验签失败(gateway 响应非 JSON)"); + throw DnfsExp("认证服务暂时不可用", 503); + } + + if (!data.is_object()) return std::nullopt; + const auto& obj = data.as_object(); + + if (!obj.contains("login") || !obj.at("login").is_bool() || !obj.at("login").as_bool()) { + return std::nullopt; + } + + UserInfo info; + if (obj.contains("user") && obj.at("user").is_string()) { + info.user = std::string(obj.at("user").as_string()); + } + if (obj.contains("is_admin") && obj.at("is_admin").is_bool()) { + info.is_admin = obj.at("is_admin").as_bool(); + } + return info; + } + + // 从请求头提取 Token + static std::string extract_token(const http::request& req) { + auto it = req.find(http::field::authorization); + if (it == req.end()) return ""; + std::string auth = std::string(it->value()); + if (auth.rfind("Bearer ", 0) == 0) { + return auth.substr(7); + } + return ""; + } + + // ---- AI 内容审核(fail-closed)----------------------------------------- + + bool review_content(const std::string& content) { + HttpClient http; + http.set_max_response_size(kHttpMaxResponse); + http.set_header("Content-Type: application/json"); + + json::object payload; + payload["content"] = content; + std::string body = json::serialize(payload); + + std::string response_body; + try { + http.post_stream_sync(_cfg.review_url, body, + [&response_body](const std::string& chunk) { + response_body += chunk; + }); + } catch (const std::exception& e) { + // fail-closed:网络异常拒绝 + _log("WARNING", "dnfs2", std::string("AI 审核失败: ") + e.what()); + return false; + } + + long code = http.get_response_code(); + if (code != 200) { + _log("WARNING", "dnfs2", "AI 审核返回 " + std::to_string(code)); + return false; + } + + json::value data; + try { + data = json::parse(response_body); + } catch (const std::exception&) { + return false; // fail-closed + } + + if (!data.is_object()) return false; + const auto& obj = data.as_object(); + + // invalid 为 false 或 "false" 时表示内容安全 + if (auto it = obj.find("invalid"); it != obj.end()) { + const auto& v = it->value(); + if (v.is_bool()) return !v.as_bool(); + if (v.is_string()) return std::string(v.as_string()) == "false"; + } + return false; // 未知响应,fail-closed + } + + // ---- JSON 请求体解析 --------------------------------------------------- + + static json::object parse_request_body(const http::request& req) { + const std::string& body = req.body(); + if (body.empty()) { + throw DnfsExp("请求体不能为空", 400); + } + json::value val; + try { + val = json::parse(body); + } catch (const std::exception&) { + throw DnfsExp("请求体不是合法 JSON", 400); + } + if (!val.is_object()) { + throw DnfsExp("请求体必须是 JSON 对象", 400); + } + return val.as_object(); + } + + // 从 JSON 对象获取整数(P1-11 修复:严格类型校验) + static int64_t get_int(const json::object& obj, const std::string& key) { + auto it = obj.find(key); + if (it == obj.end() || !it->value().is_int64()) { + throw DnfsExp("非法参数: " + key + " 必须是整数", 400); + } + return it->value().as_int64(); + } + + static std::string get_string(const json::object& obj, const std::string& key) { + auto it = obj.find(key); + if (it == obj.end() || !it->value().is_string()) { + throw DnfsExp("非法参数: " + key + " 必须是字符串", 400); + } + std::string s = std::string(it->value().as_string()); + if (s.empty()) { + throw DnfsExp("非法参数: " + key + " 不能为空", 400); + } + return s; + } + + // ---- 路由 handler ------------------------------------------------------ + + http::response _handle_get_notes( + const http::request& req) + { + try { + json::object body = parse_request_body(req); + + // pid 参数校验(P2-15:严格类型校验,非空字符串/None 报错) + int64_t pid = 0; + if (auto it = body.find("pid"); it != body.end()) { + if (!it->value().is_int64()) { + throw DnfsExp("非法参数: pid 必须是整数", 400); + } + pid = it->value().as_int64(); + if (pid < 0) { + throw DnfsExp("非法参数: pid 不能为负数", 400); + } + } + + json::object payload; + payload["action"] = "select"; + payload["table"] = "Notes"; + payload["where"] = json::object{{"pid", pid}}; + + DbResult r = _call_db(payload); + if (!r.success) { + _log("ERROR", "dnfs2", "get_notes 查询失败: " + r.error); + json::object err; + err["error"] = "查询失败"; + return make_json_obj(http::status::internal_server_error, err, req); + } + + // 解析当前用户(可选):为每个节点附带 can_operate 标记 + std::optional info = std::nullopt; + std::string token = extract_token(req); + if (!token.empty()) { + try { + info = decode_token(token); + } catch (const DnfsExp&) { + info = std::nullopt; // Token 无效时,所有节点无权 + } + } + + if (r.data.is_array()) { + json::array result = r.data.as_array(); + for (auto& item : result) { + if (item.is_object()) { + json::object& note = item.as_object(); + bool can_operate = false; + if (info) { + if (info->is_admin) { + can_operate = true; + } else if (note.contains("owner") && note.at("owner").is_string()) { + can_operate = std::string(note.at("owner").as_string()) == info->user; + } + } + note["can_operate"] = can_operate; + } + } + _log("INFO", "dnfs2", "get_notes 查询 pid=" + std::to_string(pid) + + " 返回 " + std::to_string(result.size()) + " 条"); + return make_json(http::status::ok, json::serialize(result), req); + } + + json::array empty; + return make_json(http::status::ok, json::serialize(empty), req); + + } catch (const DnfsExp& e) { + _log("WARNING", "dnfs2", std::string("get_notes: ") + e.what()); + return make_error(static_cast(e.status()), e.what(), req); + } catch (const std::exception& e) { + _log("ERROR", "dnfs2", std::string("get_notes 内部错误: ") + e.what()); + return make_error(http::status::internal_server_error, "内部错误", req); + } + } + + http::response _handle_add_note( + const http::request& req) + { + try { + json::object body = parse_request_body(req); + + int64_t pid = get_int(body, "pid"); + std::string title = get_string(body, "title"); + std::string content = get_string(body, "content"); + + if (pid < 0) { + throw DnfsExp("非法参数: pid 不能为负数", 400); + } + + // Token 验签 + std::string token = extract_token(req); + auto info = decode_token(token); + if (!info) { + throw DnfsExp("未授权访问", 401); + } + + // 父节点是否存在 + if (pid != 0 && !_note_exists(pid)) { + throw DnfsExp("父节点不存在", 400); + } + + // 特殊节点校验(管理员专属) + json::value sp = parse_sp(content); + if (!sp.is_null()) { + if (!info->is_admin) { + throw DnfsExp("特殊节点仅管理员可发布", 403); + } + validate_sp(sp.as_object()); + } + + // 内容审核(特殊节点剔除 base64 负载) + std::string review_text_val; + if (!sp.is_null()) { + review_text_val = sp_review_text(title, sp.as_object()); + } else { + review_text_val = title + "\n" + content; + } + if (!review_content(review_text_val)) { + _log("WARNING", "dnfs2", "add_note 内容审核不通过 title=" + title); + throw DnfsExp("内容审核不通过", 400); + } + + // 执行插入 + json::object values; + values["pid"] = pid; + values["owner"] = info->user; + values["title"] = title; + values["content"] = content; + + json::object payload; + payload["action"] = "insert"; + payload["table"] = "Notes"; + payload["values"] = values; + + DbResult r = _call_db(payload); + if (!r.success) { + throw DnfsExp("插入节点失败", 400); + } + + _log("INFO", "dnfs2", "add_note 成功 title=" + title + " pid=" + std::to_string(pid), + "", json::serialize(json::object{{"user", info->user}})); + + json::object result; + result["success"] = true; + return make_json_obj(http::status::ok, result, req); + + } catch (const DnfsExp& e) { + _log("WARNING", "dnfs2", std::string("add_note: ") + e.what()); + return make_error(static_cast(e.status()), e.what(), req); + } catch (const std::exception& e) { + _log("ERROR", "dnfs2", std::string("add_note 内部错误: ") + e.what()); + return make_error(http::status::internal_server_error, "内部错误", req); + } + } + + http::response _handle_delete_note( + const http::request& req) + { + try { + json::object body = parse_request_body(req); + + // P1-11 修复:note_id 显式 as_int64() + 类型校验 + int64_t note_id = get_int(body, "id"); + if (note_id <= 0) { + throw DnfsExp("Note ID is required", 400); + } + + // Token 验签 + std::string token = extract_token(req); + auto info = decode_token(token); + if (!info) { + throw DnfsExp("Warning: no Authorization", 401); + } + + // 检查是否有子节点 + json::object child_payload; + child_payload["action"] = "select"; + child_payload["table"] = "Notes"; + child_payload["where"] = json::object{{"pid", note_id}}; + + DbResult child_r = _call_db(child_payload); + if (!child_r.success || !child_r.data.is_array()) { + _log("ERROR", "dnfs2", "delete_note 查询子节点失败 id=" + std::to_string(note_id) + + " err=" + child_r.error); + throw DnfsExp("查询子节点失败", 500); + } + if (!child_r.data.as_array().empty()) { + _log("WARNING", "dnfs2", "delete_note 存在子节点 id=" + std::to_string(note_id)); + throw DnfsExp("this node has a child", 400); + } + + // 检查所有权 + json::object owner_payload; + owner_payload["action"] = "select"; + owner_payload["table"] = "Notes"; + owner_payload["where"] = json::object{{"id", note_id}}; + + DbResult owner_r = _call_db(owner_payload); + if (!owner_r.success || !owner_r.data.is_array() || owner_r.data.as_array().empty()) { + throw DnfsExp("节点不存在", 404); + } + + if (!info->is_admin) { + const auto& note = owner_r.data.as_array()[0]; + std::string owner; + if (note.is_object() && note.as_object().contains("owner") + && note.as_object().at("owner").is_string()) { + owner = std::string(note.as_object().at("owner").as_string()); + } + if (owner != info->user) { + _log("WARNING", "dnfs2", "delete_note 所有权拒绝 id=" + std::to_string(note_id)); + throw DnfsExp("It's not your note", 403); + } + } + + // 执行删除 + json::object delete_payload; + delete_payload["action"] = "delete"; + delete_payload["table"] = "Notes"; + delete_payload["where"] = json::object{{"id", note_id}}; + + DbResult r = _call_db(delete_payload); + if (!r.success) { + json::object result; + result["success"] = false; + return make_json_obj(http::status::bad_request, result, req); + } + + _log("INFO", "dnfs2", "delete_note 成功 id=" + std::to_string(note_id), + "", json::serialize(json::object{{"user", info->user}})); + + json::object result; + result["success"] = true; + return make_json_obj(http::status::ok, result, req); + + } catch (const DnfsExp& e) { + _log("WARNING", "dnfs2", std::string("delete_note: ") + e.what()); + return make_error(static_cast(e.status()), e.what(), req); + } catch (const std::exception& e) { + _log("ERROR", "dnfs2", std::string("delete_note 内部错误: ") + e.what()); + return make_error(http::status::internal_server_error, "内部错误", req); + } + } + + http::response _handle_update_note( + const http::request& req) + { + try { + json::object body = parse_request_body(req); + + int64_t id = get_int(body, "id"); + int64_t pid = get_int(body, "pid"); + std::string title = get_string(body, "title"); + std::string content = get_string(body, "content"); + + if (id <= 0) { + throw DnfsExp("Invalid param", 400); + } + if (pid < 0) { + throw DnfsExp("Invalid param", 400); + } + + // Token 验签 + std::string token = extract_token(req); + auto info = decode_token(token); + if (!info) { + throw DnfsExp("Warning: no Authorization", 401); + } + + // 检查所有权 + json::object owner_payload; + owner_payload["action"] = "select"; + owner_payload["table"] = "Notes"; + owner_payload["where"] = json::object{{"id", id}}; + + DbResult owner_r = _call_db(owner_payload); + if (!owner_r.success || !owner_r.data.is_array() || owner_r.data.as_array().empty()) { + throw DnfsExp("节点不存在", 404); + } + + if (!info->is_admin) { + const auto& note = owner_r.data.as_array()[0]; + std::string owner; + if (note.is_object() && note.as_object().contains("owner") + && note.as_object().at("owner").is_string()) { + owner = std::string(note.as_object().at("owner").as_string()); + } + if (owner != info->user) { + _log("WARNING", "dnfs2", "update_note 所有权拒绝 id=" + std::to_string(id)); + throw DnfsExp("It's not your note", 403); + } + } + + // P1-8 修复:pid≠0 时校验父节点存在 + if (pid != 0 && !_note_exists(pid)) { + throw DnfsExp("父节点不存在", 400); + } + + // 特殊节点校验 + json::value sp = parse_sp(content); + if (!sp.is_null()) { + if (!info->is_admin) { + throw DnfsExp("特殊节点仅管理员可发布", 403); + } + validate_sp(sp.as_object()); + } + + // 内容审核 + std::string review_text_val; + if (!sp.is_null()) { + review_text_val = sp_review_text(title, sp.as_object()); + } else { + review_text_val = title + "\n" + content; + } + if (!review_content(review_text_val)) { + _log("WARNING", "dnfs2", "update_note 内容审核不通过 id=" + std::to_string(id) + + " title=" + title); + throw DnfsExp("review failed", 400); + } + + // 执行更新 + json::object values; + values["pid"] = pid; + values["title"] = title; + values["content"] = content; + + json::object payload; + payload["action"] = "update"; + payload["table"] = "Notes"; + payload["values"] = values; + payload["where"] = json::object{{"id", id}}; + + DbResult r = _call_db(payload); + if (!r.success) { + json::object result; + result["success"] = false; + return make_json_obj(http::status::bad_request, result, req); + } + + _log("INFO", "dnfs2", "update_note 成功 id=" + std::to_string(id) + " title=" + title, + "", json::serialize(json::object{{"user", info->user}})); + + json::object result; + result["success"] = true; + return make_json_obj(http::status::ok, result, req); + + } catch (const DnfsExp& e) { + _log("WARNING", "dnfs2", std::string("update_note: ") + e.what()); + return make_error(static_cast(e.status()), e.what(), req); + } catch (const std::exception& e) { + _log("ERROR", "dnfs2", std::string("update_note 内部错误: ") + e.what()); + return make_error(http::status::internal_server_error, "内部错误", req); + } + } +}; + +// 静态成员定义 +std::atomic Dnfs2Server::_should_stop{false}; + +// ============================================================================ +// main +// ============================================================================ + +int main(int argc, char* argv[]) { + // curl_global_init 必须在多线程启动前调用(libcurl 要求,防首次 curl_easy_init 竞态) + curl_global_init(CURL_GLOBAL_DEFAULT); + + try { + Config cfg = load_config(argc, argv); + + std::printf("[dnfs2] listen=%s:%d threads=%d\n", + cfg.listen_host.c_str(), cfg.listen_port, cfg.threads); + std::printf("[dnfs2] db=%s:%d ai_review=%s gateway=%s\n", + cfg.db_host.c_str(), cfg.db_port, + cfg.review_url.c_str(), cfg.gateway_url.c_str()); + std::fflush(stdout); + + Dnfs2Server server(std::move(cfg)); + server.run(); + } catch (const std::exception& e) { + std::fprintf(stderr, "[dnfs2] fatal: %s\n", e.what()); + return 1; + } + return 0; +} diff --git a/docs/security/dnfs2_SECURITY_AUDIT_20260808.md b/docs/security/dnfs2_SECURITY_AUDIT_20260808.md new file mode 100644 index 0000000..22de903 --- /dev/null +++ b/docs/security/dnfs2_SECURITY_AUDIT_20260808.md @@ -0,0 +1,219 @@ +# 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 | 全部有效 ✓ | +| 新发现问题 | 4 | 全部修复 ✓ | +| 已知限制(架构性/继承性) | 6 | 记录 | + +--- + +## 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); } +``` + +--- + +## 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-3: 配置的超时时间未使用(P2) + +`review_timeout` 和 `gateway_timeout` 已加载并校验范围,但 HttpClient 硬编码 30s 超时(libhttp 限制,未提供 `set_timeout()` 接口)。实际超时为 30s 而非配置值。 + +### 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 全部有效 ✓ +- 新发现 4 项问题全部修复 ✓ (2 P1 + 2 P2) +- IP/用户名泄露问题作为重写副效果已修复 ✓ +- 残余 6 项已知限制均为架构性/继承性问题,有明确修复路径 +- 相比 Python dnfs(评级 B,多个 P0/P1 未修复),安全基线显著提升 + +**扣分项**: +- L-1/L-2 循环创建和 TOCTOU 未修复(需 db2 事务支持) +- L-3 超时配置未生效(需 libhttp 增强) + +--- + +## 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 ` | + +编译验证: `g++ -std=c++20 -O3 -Wall -Werror` 通过,零错误零警告。