Files
dstalk/plugins/network/src/network_plugin.cpp
XiuChengWu 102cd3e141
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
Harden plugin runtime: TLS verify, LSP deadlock, path traversal, ABI exception safety (W14)
W14 addresses the five most critical findings from the W13 plugin audits:

- W14.1 network: enable ssl::verify_peer + SSL_set1_host SNI hostname
  verification (fixes TLS bypass, W13.3 CVSS 7.4); add steady_timer DNS
  timeout and bottom-up catch(...) hardening (engineer-zhou)
- W14.2 lsp: fix reader_loop/stop mutex deadlock via stop_nolock/stop_locked
  split (W13.4); wrap 11 vtable/entry functions in try/catch with cv
  notification on reader exit (engineer-sun)
- W14.3 tools: add is_safe_path() rejecting empty/absolute/.. paths before
  file_io calls (fixes path traversal, W13.5 CVSS 7.5); guard g_tools and
  g_session/g_history under mutex; 9 vtable try/catch (security-cao)
- W14.4 host: add fallback plugin search (../plugins/) so binaries run from
  build/tests/ load current DLLs, resolving the W13.6 R2 stale-DLL false
  alarm (architect-lin)
- W14.5 anthropic+deepseek: wrap 12 ABI boundary functions in try/catch with
  log-guard, preventing exceptions from crossing the C ABI (engineer-chen)

Verified: cmake build 0 error 0 warning, ctest 4/4 pass, smoke R2 now
passes naturally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:03:50 +08:00

371 lines
13 KiB
C++

// MSVC 14.16 (VS 2017) doesn't provide std::to_address (C++20)
#define BOOST_ASIO_DISABLE_STD_TO_ADDRESS
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/version.hpp>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <string>
#include <string_view>
#include <unordered_map>
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
namespace ssl = boost::asio::ssl;
using tcp = asio::ip::tcp;
// ============================================================
// Global state
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
static dstalk_config_service_t* g_config_svc = nullptr;
// ============================================================
// Minimal JSON header parser
// Parses {"key1":"value1","key2":"value2"} into unordered_map
// ============================================================
static std::unordered_map<std::string, std::string> parse_headers_json(const char* json) {
std::unordered_map<std::string, std::string> headers;
if (!json || !*json) return headers;
std::string s(json);
// Very simple state-machine parser for flat string-key/value objects
enum { OUTSIDE, IN_KEY, AFTER_KEY, IN_VALUE } state = OUTSIDE;
std::string current_key;
std::string current_value;
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(); }
break;
case IN_KEY:
if (c == '"') { state = AFTER_KEY; }
else if (c == '\\' && i + 1 < s.size()) { current_key += s[++i]; }
else { current_key += c; }
break;
case AFTER_KEY:
if (c == ':') { state = IN_VALUE; current_value.clear(); }
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]; }
++i;
}
headers[current_key] = current_value;
state = OUTSIDE;
}
break;
}
}
return headers;
}
// ============================================================
// HTTP Client implementation (adapted from dstalk-core HttpClient)
// ============================================================
struct HttpClientCtx {
asio::io_context ioc;
ssl::context ssl_ctx{ssl::context::tlsv12_client};
int connect_timeout = 30;
int request_timeout = 120;
HttpClientCtx() {
ssl_ctx.set_default_verify_paths();
// Enable peer certificate verification (CVSS 7.4 fix).
// 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() may not locate system CAs;
// if verification fails, set SSL_CERT_FILE env or bundle a cacert.pem.
ssl_ctx.set_verify_mode(ssl::verify_peer);
}
};
static int do_post_stream(
const char* host,
const char* port,
const char* target,
const char* body,
const char* headers_json,
dstalk_stream_cb cb,
void* userdata,
char** response_body,
int* status_code)
{
if (!host || !port || !target || !body || !response_body || !status_code) {
if (response_body) *response_body = nullptr;
if (status_code) *status_code = -1;
return -1;
}
// Initialize output
*response_body = nullptr;
*status_code = -1;
// Build C++ lambda from C callback
std::function<bool(const std::string&)> on_line;
if (cb) {
on_line = [cb, userdata](const std::string& line) -> bool {
return cb(line.c_str(), userdata) == 0;
};
}
HttpClientCtx ctx;
// Read timeouts from config if available
if (g_config_svc) {
const char* ct = g_config_svc->get("http.connect_timeout");
const char* rt = g_config_svc->get("http.request_timeout");
if (ct) ctx.connect_timeout = std::atoi(ct);
if (rt) ctx.request_timeout = std::atoi(rt);
if (ctx.connect_timeout <= 0) ctx.connect_timeout = 30;
if (ctx.request_timeout <= 0) ctx.request_timeout = 120;
}
std::string result_body;
int result_code = -1;
try {
tcp::resolver resolver(ctx.ioc);
// DNS resolve with 10-second timeout. Boost.Asio's synchronous
// resolve() runs the io_context internally, so the timer's async_wait
// callback executes during resolve() and calls resolver.cancel() when
// the deadline fires.
asio::steady_timer resolve_timer(ctx.ioc);
resolve_timer.expires_after(std::chrono::seconds(10));
resolve_timer.async_wait([&](const beast::error_code& ec) {
if (!ec) resolver.cancel();
});
beast::error_code resolve_ec;
auto endpoints = resolver.resolve(host, port, resolve_ec);
resolve_timer.cancel();
if (resolve_ec) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: DNS resolve %s:%s failed: %s",
host, port, resolve_ec.message().c_str());
result_body = std::string("DNS resolve failed: ") + resolve_ec.message();
goto done;
}
beast::ssl_stream<beast::tcp_stream> stream(ctx.ioc, ctx.ssl_ctx);
beast::flat_buffer buffer;
// SNI hostname
if (!SSL_set_tlsext_host_name(stream.native_handle(), host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: SNI hostname set failed for %s", host);
result_body = "SNI hostname set failed";
goto done;
}
// Hostname verification: require server certificate CN/SAN to match
// 'host'. This works in conjunction with ssl::verify_peer on the
// context — without it MITM with a valid CA-signed cert for a
// different hostname would still pass.
if (!SSL_set1_host(stream.native_handle(), host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: SSL_set1_host failed for %s", host);
result_body = "SSL_set1_host failed";
goto done;
}
// Connect
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.connect_timeout));
beast::get_lowest_layer(stream).connect(endpoints);
beast::get_lowest_layer(stream).expires_never();
// SSL handshake
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.connect_timeout));
stream.handshake(ssl::stream_base::client);
beast::get_lowest_layer(stream).expires_never();
// Build HTTP POST request
http::request<http::string_body> req{http::verb::post, target, 11};
req.set(http::field::host, host);
req.set(http::field::user_agent, "dstalk/0.1");
req.set(http::field::content_type, "application/json");
req.body() = body;
req.prepare_payload();
// Add extra headers from JSON
auto extra_headers = parse_headers_json(headers_json);
for (const auto& h : extra_headers) {
req.set(h.first, h.second);
}
// Send
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::write(stream, req);
beast::get_lowest_layer(stream).expires_never();
// Read response
http::response_parser<http::string_body> parser;
parser.body_limit(16 * 1024 * 1024);
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_header(stream, buffer, parser);
beast::get_lowest_layer(stream).expires_never();
result_code = parser.get().result_int();
beast::error_code ec;
if (on_line) {
std::string fragment = parser.get().body();
auto emit_lines = [&]() -> bool {
size_t pos = 0;
while (pos < fragment.size()) {
size_t nl = fragment.find('\n', pos);
if (nl == std::string::npos) break;
std::string line = fragment.substr(pos, nl - pos);
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!on_line(line)) return false;
pos = nl + 1;
}
if (pos > 0)
fragment = fragment.substr(pos);
return true;
};
if (!emit_lines()) goto done;
size_t processed = parser.get().body().size();
while (!parser.is_done()) {
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_some(stream, buffer, parser, ec);
if (ec) break;
const std::string& full_body = parser.get().body();
if (full_body.size() > processed) {
std::string_view new_data(full_body.data() + processed,
full_body.size() - processed);
processed = full_body.size();
fragment.append(new_data.data(), new_data.size());
if (!emit_lines()) goto done;
}
}
if (!fragment.empty()) {
if (fragment.back() == '\r')
fragment.pop_back();
if (!fragment.empty())
on_line(fragment);
}
} else {
while (!parser.is_done()) {
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_some(stream, buffer, parser, ec);
if (ec) break;
}
}
result_body = parser.get().body();
beast::get_lowest_layer(stream).cancel();
stream.shutdown(ec);
} catch (const std::exception& e) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: %s", e.what());
result_code = -1;
result_body = e.what();
} catch (...) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: unknown exception (non-std::exception)");
result_code = -1;
result_body = "unknown exception";
}
done:
*status_code = result_code;
if (!result_body.empty()) {
*response_body = g_host->strdup(result_body.c_str());
}
return (result_code >= 200 && result_code < 300) ? 0 : -1;
}
// ============================================================
// Service implementations
// ============================================================
static int http_post_json(
const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
char** response_body, int* status_code)
{
return do_post_stream(host, port, target, body, headers_json,
nullptr, nullptr, response_body, status_code);
}
static int http_post_stream(
const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
dstalk_stream_cb cb, void* userdata,
char** response_body, int* status_code)
{
return do_post_stream(host, port, target, body, headers_json,
cb, userdata, response_body, status_code);
}
static dstalk_http_service_t g_service = {
http_post_json,
http_post_stream
};
// ============================================================
// Plugin lifecycle
// ============================================================
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
// Query config service (declared dependency)
g_config_svc = (dstalk_config_service_t*)host->query_service("config", 1);
return host->register_service("http", 1, &g_service);
}
static void on_shutdown() {
// nothing to clean up
}
static dstalk_plugin_info_t g_info = {
"http", // name
"1.0.0", // version
"HTTP/HTTPS client service using Boost.Beast + OpenSSL", // description
DSTALK_API_VERSION, // api_version
{"config", nullptr}, // dependencies
on_init, // on_init
on_shutdown, // on_shutdown
nullptr // on_event
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}