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

@@ -81,10 +81,56 @@ LiteConnectionSettings defaultLiteConnectionSettings()
return settings;
}
// Strict dotted-decimal check for the 127.0.0.0/8 loopback block: exactly four numeric octets
// (each 0-255) with the first equal to 127. This must NOT be a prefix match — "127.0.0.1.evil.com"
// and "127.evil.com" are attacker-controlled DNS names that a startsWith("127.") test would wrongly
// treat as loopback, reopening the plaintext-downgrade hole.
static bool isNumericIpv4Loopback(const std::string& h)
{
int parts = 0;
size_t start = 0;
while (true) {
const size_t dot = h.find('.', start);
const std::string seg = h.substr(start, dot == std::string::npos ? std::string::npos : dot - start);
if (seg.empty() || seg.size() > 3) return false;
int v = 0;
for (char c : seg) { if (c < '0' || c > '9') return false; v = v * 10 + (c - '0'); }
if (v > 255) return false;
if (parts == 0 && v != 127) return false; // 127.0.0.0/8 only
++parts;
if (dot == std::string::npos) break;
start = dot + 1;
}
return parts == 4;
}
static bool isLoopbackLiteHostSpec(const std::string& hostPort)
{
std::string h = hostPort;
const size_t term = h.find_first_of("/?#"); // strip path/query/fragment
if (term != std::string::npos) h = h.substr(0, term);
const size_t at = h.rfind('@'); // strip userinfo (user:pass@host)
if (at != std::string::npos) h = h.substr(at + 1);
if (!h.empty() && h.front() == '[') { // [::1]:port (bracketed IPv6)
const size_t close = h.find(']');
h = (close == std::string::npos) ? h : h.substr(1, close - 1);
} else { // host or host:port
const size_t colon = h.find(':');
if (colon != std::string::npos) h = h.substr(0, colon);
}
return h == "localhost" || h == "::1" || isNumericIpv4Loopback(h);
}
bool isLiteServerUrlUsable(const std::string& serverUrl)
{
const std::string normalized = liteTrimCopy(serverUrl);
return startsWith(normalized, "https://") || startsWith(normalized, "http://");
if (startsWith(normalized, "https://")) return true;
// SECURITY: plaintext http:// is a TLS downgrade for lightwalletd traffic (view keys,
// transactions, addresses). Permit it only for loopback (a local dev lightwalletd);
// reject remote plaintext servers instead of silently accepting the downgrade.
if (startsWith(normalized, "http://"))
return isLoopbackLiteHostSpec(normalized.substr(sizeof("http://") - 1));
return false;
}
bool isOfficialLiteServer(const std::string& serverUrl)

View File

@@ -1066,7 +1066,16 @@ LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase)
}
out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase));
secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-encrypted wallet
if (out.ok) {
// Persist the now-encrypted wallet. If the save fails, do NOT report success — the
// on-disk wallet would still be unencrypted, contradicting what the user was told.
const auto saved = bridge_->execute("save", "");
if (!saved.ok) {
out.ok = false;
out.error = "wallet encrypted in memory but saving to disk failed" +
(saved.error.empty() ? std::string() : (": " + saved.error));
}
}
return out;
}
@@ -1084,7 +1093,16 @@ LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase)
}
out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase));
secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-unencrypted wallet
if (out.ok) {
// Persist the now-unencrypted wallet. If the save fails, do NOT report success — the
// on-disk wallet would still be encrypted, contradicting what the user was told.
const auto saved = bridge_->execute("save", "");
if (!saved.ok) {
out.ok = false;
out.error = "wallet decrypted in memory but saving to disk failed" +
(saved.error.empty() ? std::string() : (": " + saved.error));
}
}
return out;
}