fix(security): apply audit remediations to dev (ported + dev-only)

Dev-branch audit (docs/_archive/security-audit-dev-2026-07-22.md) re-found the
master issues (absent on dev) plus new ones in dev-only code. Applied here;
full-node build + test suite green; the subtle fixes were adversarially re-verified.

Ported from the master remediation (adapted to dev's code):
- bootstrap: reject zip-slip / path-traversal archive members (isSafeArchivePath)
  before writing (S2-1). (dev already fail-closes on a missing checksum.)
- xmrig updater: fail closed when a signature is required but no key is pinned (F1-1).
- http_download.httpGetString: 16 MiB hard cap + MAXFILESIZE on the shared
  metadata/price fetch (F1-2 / caps the updater + exchange paths at one site).
- rpc_client: explicit SSL_VERIFYPEER/VERIFYHOST (F4-1) and a 256 MiB response
  cap in WriteCallback (F4-3).
- lite_connection_service: reject remote http:// lite servers, loopback only (L1-1).
  Loopback is matched by a strict dotted-decimal 127.0.0.0/8 check (not a
  startsWith("127.") prefix, which would wrongly accept 127.0.0.1.evil.com), with
  userinfo/fragment stripping.
- lite controller: propagate encrypt/decrypt save() failure instead of reporting
  success (F7-1).
- xmrig_manager: chmod(0600) the pool config before writing secrets (F5-2).
- app: clear the copied secret from the OS clipboard on shutdown (F3b-1).
- export_transactions: neutralize CSV/spreadsheet formula injection (F13-1).
- build pipeline: build-from-source lite backend + remove the self-attested
  CMake signature gate (F15-1); pinned+verified appimagetool (F15-3/4);
  verified Sapling params in setup.sh (F15-6); build.sh exits 0 on success.
  (F14-1 empty-quoted-arg and F8-2 NUL-termination were already fixed on dev.)

Dev-only findings:
- rpc_client.callRaw: scrub the raw buffer + parsed tree (templated scrubJsonSecrets
  for ordered_json) so console dumpprivkey/z_exportkey keys don't linger in freed
  heap (N1-1).
- seed_wallet_creator: wipe the exported mnemonic on the failure path so a discarded
  failed result never carries a live seed (W1-2).
- export_all_keys: write the plaintext key dump 0600 + atomically via
  writeFileAtomically (U1-2).
- chat_database: restrict chat_messages.sqlite and its WAL/SHM sidecars to owner-only (C3-1).

Not done (need a decision, documented in the report):
- Chat header metadata (cid/z/p) rides outside the AEAD (C1/C2) — binding it is a
  wire-protocol change requiring SilentDragonXLite interop review.
- Bootstrap lacks an offline-rooted signature (S2-2 residual) — needs signing infra.
- Console scrollback retains console-typed key-export output in plaintext (N1-1
  residual) — inherent to an echoing console; would need output redaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 12:02:50 -05:00
parent e1870c3b23
commit bcee4bfe72
18 changed files with 289 additions and 155 deletions

View File

@@ -25,9 +25,11 @@ namespace {
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
void scrubJsonSecrets(nlohmann::json& j) {
// Templated so it works on both nlohmann::json and nlohmann::ordered_json (callRaw uses the latter).
template <typename J>
void scrubJsonSecrets(J& j) {
if (j.is_string()) {
auto& s = j.get_ref<std::string&>();
auto& s = j.template get_ref<std::string&>();
if (!s.empty()) sodium_memzero(&s[0], s.size());
} else if (j.is_object() || j.is_array()) {
for (auto& el : j) scrubJsonSecrets(el);
@@ -96,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
// Callback for libcurl to write response data
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t totalSize = size * nmemb;
// Bound accumulation so a hostile/compromised daemon cannot OOM the client with an unbounded
// response body. 256 MiB is far above any legitimate JSON-RPC response yet prevents exhaustion.
static constexpr size_t kMaxRpcResponseBytes = 256u * 1024 * 1024;
if (userp->size() + totalSize > kMaxRpcResponseBytes) return 0; // short count aborts the transfer
userp->append((char*)contents, totalSize);
return totalSize;
}
@@ -202,6 +208,10 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
@@ -508,14 +518,22 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
}
auto& result = oj["result"];
std::string out;
if (result.is_null()) {
return "null";
out = "null";
} else if (result.is_string()) {
// Return the raw string (not JSON-encoded) — caller wraps as needed
return result.get<std::string>();
out = result.get<std::string>();
} else {
return result.dump(4);
out = result.dump(4);
}
// B7: this raw path serves arbitrary console commands including dumpprivkey / z_exportkey,
// whose response carries plaintext key material. Zero the raw buffer and the parsed tree so
// the secret does not linger in freed heap (matching callSecret). The single returned copy is
// the caller's to manage.
scrubJsonSecrets(oj);
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
return out;
}
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)