feat: Add LSP plugin unit tests and frontend common initialization library
Some checks failed
Some checks failed
- Introduced `dstalk_lsp_plugin_test` for testing LSP plugin functionalities including `lsp_trim`, `lsp_frame_message`, and `lsp_parse_content_length`. - Created `dstalk_frontend_common` static library to encapsulate shared initialization logic for frontend components (CLI, GUI, Web). - Implemented configuration file discovery and service querying in `dstalk_frontend_init`. - Added internal headers for LSP and Anthropic plugins to facilitate unit testing. - Established a mailroom system for asynchronous message passing between stateless agents, enhancing coordination and context management.
This commit is contained in:
@@ -35,6 +35,54 @@ namespace asio = boost::asio;
|
||||
namespace ssl = boost::asio::ssl;
|
||||
using tcp = asio::ip::tcp;
|
||||
|
||||
// ============================================================
|
||||
// 安全常量和输入验证辅助函数 / Security constants and input-validation helpers
|
||||
// ============================================================
|
||||
static constexpr size_t MAX_HEADER_KEY_LENGTH = 256;
|
||||
static constexpr size_t MAX_HEADER_VALUE_LENGTH = 8192;
|
||||
|
||||
/// 如果字符串包含任何控制字符(< 0x20 或 0x7F DEL),返回 true / Return true if the string contains any control character (< 0x20 or 0x7F DEL).
|
||||
static bool contains_control_chars(const char* s) {
|
||||
if (!s) return false;
|
||||
for (const char* p = s; *p; ++p) {
|
||||
unsigned char c = static_cast<unsigned char>(*p);
|
||||
if (c < 0x20u || c == 0x7Fu) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 基本端口/服务名验证。拒绝空值,对数字端口进行范围检查,
|
||||
/// 对符号服务名要求字母数字加连字符(RFC 6335)/ Basic port / service-name validation.
|
||||
/// Rejects empty, bounds-checks numeric ports, and requires alphanumeric+hyphen
|
||||
/// for symbolic service names (RFC 6335).
|
||||
static bool is_valid_port(const char* port) {
|
||||
if (!port || !*port) return false;
|
||||
bool all_digits = true;
|
||||
for (const char* p = port; *p; ++p) {
|
||||
if (static_cast<unsigned char>(*p) < '0' ||
|
||||
static_cast<unsigned char>(*p) > '9') {
|
||||
all_digits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (all_digits) {
|
||||
if (std::strlen(port) > 5) return false; // > 65535
|
||||
long p = std::atol(port);
|
||||
return p > 0 && p <= 65535;
|
||||
}
|
||||
// 服务名:字母数字加连字符(RFC 6335);最长15个字符 / Service name: alphanumeric plus hyphen (RFC 6335); max 15 chars
|
||||
for (const char* p = port; *p; ++p) {
|
||||
unsigned char c = static_cast<unsigned char>(*p);
|
||||
if (!((c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return std::strlen(port) <= 15;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 全局状态 / Global state
|
||||
// ============================================================
|
||||
@@ -42,8 +90,11 @@ static const dstalk_host_api_t* g_host = nullptr;
|
||||
static dstalk_config_service_t* g_config_svc = nullptr;
|
||||
|
||||
// ============================================================
|
||||
// 极简 JSON 头解析器 / Minimal JSON header parser
|
||||
// 极简 JSON 头解析器(含安全长度限制)/ Minimal JSON header parser (with security length limits)
|
||||
// 将 {"key1":"value1","key2":"value2"} 解析到 unordered_map / Parses {"key1":"value1","key2":"value2"} into unordered_map
|
||||
// 强制 MAX_HEADER_KEY_LENGTH (256) 和 MAX_HEADER_VALUE_LENGTH (8192) 限制,
|
||||
// 防止恶意输入导致资源耗尽 / Enforces MAX_HEADER_KEY_LENGTH (256) and MAX_HEADER_VALUE_LENGTH
|
||||
// (8192) to prevent resource exhaustion from malicious input.
|
||||
// ============================================================
|
||||
// 将扁平 JSON 对象中的字符串键值对解析到 unordered_map / Parse a flat JSON object of string key-value pairs into an unordered_map.
|
||||
static std::unordered_map<std::string, std::string> parse_headers_json(const char* json) {
|
||||
@@ -55,31 +106,53 @@ static std::unordered_map<std::string, std::string> parse_headers_json(const cha
|
||||
enum { OUTSIDE, IN_KEY, AFTER_KEY, IN_VALUE } state = OUTSIDE;
|
||||
std::string current_key;
|
||||
std::string current_value;
|
||||
bool key_too_long = false;
|
||||
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
char c = s[i];
|
||||
switch (state) {
|
||||
case OUTSIDE:
|
||||
if (c == '"') { state = IN_KEY; current_key.clear(); }
|
||||
if (c == '"') { state = IN_KEY; current_key.clear(); key_too_long = false; }
|
||||
break;
|
||||
case IN_KEY:
|
||||
if (c == '"') { state = AFTER_KEY; }
|
||||
else if (c == '\\' && i + 1 < s.size()) { current_key += s[++i]; }
|
||||
else { current_key += c; }
|
||||
else if (c == '\\' && i + 1 < s.size()) {
|
||||
if (current_key.size() < MAX_HEADER_KEY_LENGTH)
|
||||
current_key += s[++i];
|
||||
else { ++i; key_too_long = true; }
|
||||
}
|
||||
else {
|
||||
if (current_key.size() < MAX_HEADER_KEY_LENGTH)
|
||||
current_key += c;
|
||||
else
|
||||
key_too_long = true;
|
||||
}
|
||||
break;
|
||||
case AFTER_KEY:
|
||||
if (c == ':') { state = IN_VALUE; current_value.clear(); }
|
||||
// 跳过键和冒号之间的多余字符(例如 ',' 或 '}')/ Skip stray characters between key and colon (e.g. ',' or '}')
|
||||
else if (c == '"' || c == ',' || c == '}') { /* 保持 AFTER_KEY 状态,忽略 / stay in AFTER_KEY, ignore */ }
|
||||
break;
|
||||
case IN_VALUE:
|
||||
if (c == '"') {
|
||||
// 读取到闭合引号 / Read until closing quote
|
||||
++i;
|
||||
while (i < s.size() && s[i] != '"') {
|
||||
if (s[i] == '\\' && i + 1 < s.size()) { current_value += s[++i]; }
|
||||
else { current_value += s[i]; }
|
||||
if (s[i] == '\\' && i + 1 < s.size()) {
|
||||
if (current_value.size() < MAX_HEADER_VALUE_LENGTH)
|
||||
current_value += s[++i];
|
||||
else
|
||||
++i; // 跳过转义字符,值已截断 / skip escaped char, value truncated
|
||||
}
|
||||
else {
|
||||
if (current_value.size() < MAX_HEADER_VALUE_LENGTH)
|
||||
current_value += s[i];
|
||||
}
|
||||
++i;
|
||||
}
|
||||
headers[current_key] = current_value;
|
||||
if (!key_too_long) {
|
||||
headers[current_key] = current_value;
|
||||
}
|
||||
state = OUTSIDE;
|
||||
}
|
||||
break;
|
||||
@@ -93,19 +166,76 @@ static std::unordered_map<std::string, std::string> parse_headers_json(const cha
|
||||
// ============================================================
|
||||
struct HttpClientCtx {
|
||||
asio::io_context ioc;
|
||||
ssl::context ssl_ctx{ssl::context::tlsv12_client};
|
||||
ssl::context ssl_ctx{ssl::context::tls_client};
|
||||
int connect_timeout = 30;
|
||||
int request_timeout = 120;
|
||||
|
||||
HttpClientCtx() {
|
||||
ssl_ctx.set_default_verify_paths();
|
||||
// 启用对等证书验证 (CVSS 7.4 修复) / Enable peer certificate verification (CVSS 7.4 fix).
|
||||
// set_default_verify_paths() 加载系统 CA 包;没有 verify_peer
|
||||
// CA 存储不会被查询——任何证书(自签名/过期)都将被接受 / set_default_verify_paths() loads system CA bundle; without verify_peer
|
||||
// the CA store is never consulted — any cert (self-signed/expired) is accepted.
|
||||
// TODO: Windows: set_default_verify_paths() 可能无法定位系统 CA;
|
||||
// 如果验证失败,设置 SSL_CERT_FILE 环境变量或捆绑 cacert.pem / Windows: set_default_verify_paths() may not locate system CAs;
|
||||
// if verification fails, set SSL_CERT_FILE env or bundle a cacert.pem.
|
||||
// TLS 1.2+ 协商(tls_client 允许 TLS 1.2 和 1.3)/ TLS 1.2+ negotiation (tls_client allows TLS 1.2 and 1.3).
|
||||
// 启用针对系统 CA 存储的对等证书验证。在 Windows 上
|
||||
// set_default_verify_paths() 可能无法定位系统 CA;
|
||||
// 检测到这种情况时尝试回退源 / Enable peer certificate verification against system CA store.
|
||||
// On Windows set_default_verify_paths() may not locate system CAs;
|
||||
// we detect that case and try fallback sources.
|
||||
|
||||
boost::system::error_code ec;
|
||||
ssl_ctx.set_default_verify_paths(ec);
|
||||
if (ec) {
|
||||
// 主路径失败——按顺序尝试回退源 / Primary path failed — try fallback sources in order
|
||||
bool loaded = false;
|
||||
|
||||
// 回退 1:SSL_CERT_FILE / SSL_CERT_DIR(OpenSSL 内部已查询,
|
||||
// 但显式 load_verify_file 提供明确的错误码用于报告)/ Fallback 1: SSL_CERT_FILE / SSL_CERT_DIR (already consulted by
|
||||
// OpenSSL internally, but an explicit load_verify_file gives us
|
||||
// a clear error code to report).
|
||||
const char* cert_file = std::getenv("SSL_CERT_FILE");
|
||||
if (cert_file && *cert_file) {
|
||||
ssl_ctx.load_verify_file(cert_file, ec);
|
||||
if (!ec) loaded = true;
|
||||
}
|
||||
if (!loaded) {
|
||||
const char* cert_dir = std::getenv("SSL_CERT_DIR");
|
||||
if (cert_dir && *cert_dir) {
|
||||
ssl_ctx.add_verify_path(cert_dir, ec);
|
||||
if (!ec) loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退 2:http.ca_cert_file 配置项 / Fallback 2: http.ca_cert_file config key
|
||||
if (!loaded && g_config_svc) {
|
||||
const char* cfg_cert = g_config_svc->get("http.ca_cert_file");
|
||||
if (cfg_cert && *cfg_cert) {
|
||||
ssl_ctx.load_verify_file(cfg_cert, ec);
|
||||
if (!ec) loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退 3:捆绑的 cacert.pem,相对于常见安装路径 / Fallback 3: bundled cacert.pem relative to common install paths
|
||||
if (!loaded) {
|
||||
static const char* kBundlePaths[] = {
|
||||
"cacert.pem",
|
||||
"share/cacert.pem",
|
||||
"../share/cacert.pem",
|
||||
"certs/cacert.pem",
|
||||
nullptr
|
||||
};
|
||||
for (int pi = 0; kBundlePaths[pi]; ++pi) {
|
||||
ssl_ctx.load_verify_file(kBundlePaths[pi], ec);
|
||||
if (!ec) { loaded = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
if (g_host) g_host->log(DSTALK_LOG_WARN,
|
||||
"TLS CA certificates not found. "
|
||||
"set_default_verify_paths() failed: %s. "
|
||||
"Set SSL_CERT_FILE=/path/to/cacert.pem or "
|
||||
"http.ca_cert_file in config. "
|
||||
"TLS verification will proceed but may fail at handshake.",
|
||||
ec.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
ssl_ctx.set_verify_mode(ssl::verify_peer);
|
||||
}
|
||||
};
|
||||
@@ -132,6 +262,33 @@ static int do_post_stream(
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ---- 输入验证(安全加固)/ Input validation (security hardening) ----
|
||||
|
||||
// 拒绝 host 和 target 中的控制字符(CRLF 注入防护)/ Reject control characters in host and target (CRLF injection prevention)
|
||||
if (contains_control_chars(host)) {
|
||||
if (g_host) g_host->log(DSTALK_LOG_ERROR,
|
||||
"do_post_stream: host contains control characters");
|
||||
*response_body = nullptr;
|
||||
*status_code = -1;
|
||||
return -1;
|
||||
}
|
||||
if (contains_control_chars(target)) {
|
||||
if (g_host) g_host->log(DSTALK_LOG_ERROR,
|
||||
"do_post_stream: target contains control characters");
|
||||
*response_body = nullptr;
|
||||
*status_code = -1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 验证端口:必须是数字 1-65535 或有效的服务名 / Validate port: must be numeric 1-65535 or a valid service name
|
||||
if (!is_valid_port(port)) {
|
||||
if (g_host) g_host->log(DSTALK_LOG_ERROR,
|
||||
"do_post_stream: invalid port '%s'", port);
|
||||
*response_body = nullptr;
|
||||
*status_code = -1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 初始化输出 / Initialize output
|
||||
*response_body = nullptr;
|
||||
*status_code = -1;
|
||||
|
||||
Reference in New Issue
Block a user