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>
363 lines
13 KiB
C++
363 lines
13 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "lite_connection_service.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <utility>
|
|
|
|
namespace dragonx {
|
|
namespace wallet {
|
|
|
|
namespace {
|
|
|
|
bool startsWith(const std::string& value, const char* prefix)
|
|
{
|
|
const std::string prefixValue(prefix);
|
|
return value.size() >= prefixValue.size() &&
|
|
value.compare(0, prefixValue.size(), prefixValue) == 0;
|
|
}
|
|
|
|
LiteServerEndpoint trimmedEndpoint(const LiteServerEndpoint& endpoint)
|
|
{
|
|
LiteServerEndpoint normalized = endpoint;
|
|
normalized.url = liteTrimCopy(normalized.url);
|
|
return normalized;
|
|
}
|
|
|
|
std::vector<std::pair<std::size_t, LiteServerEndpoint>> usableConfiguredServers(
|
|
const LiteConnectionSettings& settings)
|
|
{
|
|
std::vector<std::pair<std::size_t, LiteServerEndpoint>> servers;
|
|
for (std::size_t index = 0; index < settings.servers.size(); ++index) {
|
|
auto endpoint = trimmedEndpoint(settings.servers[index]);
|
|
if (!endpoint.enabled || !isLiteServerUrlUsable(endpoint.url)) continue;
|
|
servers.push_back({index, std::move(endpoint)});
|
|
}
|
|
return servers;
|
|
}
|
|
|
|
std::string normalizedChainName(const std::string& chainName)
|
|
{
|
|
const std::string normalized = liteTrimCopy(chainName);
|
|
return normalized.empty() ? kDragonXLiteChainName : normalized;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string liteTrimCopy(const std::string& value)
|
|
{
|
|
auto begin = value.begin();
|
|
while (begin != value.end() && std::isspace(static_cast<unsigned char>(*begin))) ++begin;
|
|
|
|
auto end = value.end();
|
|
while (end != begin && std::isspace(static_cast<unsigned char>(*(end - 1)))) --end;
|
|
|
|
return std::string(begin, end);
|
|
}
|
|
|
|
std::vector<LiteServerEndpoint> defaultDragonXLiteServers()
|
|
{
|
|
return {
|
|
{"https://lite.dragonx.is", "DragonX Lite", true},
|
|
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
|
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
|
|
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
|
|
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
|
|
{"https://lite5.dragonx.is", "DragonX Lite 5", true}
|
|
};
|
|
}
|
|
|
|
LiteConnectionSettings defaultLiteConnectionSettings()
|
|
{
|
|
LiteConnectionSettings settings;
|
|
settings.servers = defaultDragonXLiteServers();
|
|
settings.selectionMode = LiteServerSelectionMode::Sticky;
|
|
settings.stickyServerUrl = kDefaultDragonXLiteServer;
|
|
settings.chainName = kDragonXLiteChainName;
|
|
settings.randomSelectionSeed = 0;
|
|
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);
|
|
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)
|
|
{
|
|
const std::string url = liteTrimCopy(serverUrl);
|
|
for (const auto& s : defaultDragonXLiteServers())
|
|
if (s.url == url) return true;
|
|
return false;
|
|
}
|
|
|
|
const char* liteServerSelectionModeName(LiteServerSelectionMode mode)
|
|
{
|
|
switch (mode) {
|
|
case LiteServerSelectionMode::Sticky:
|
|
return "Sticky";
|
|
case LiteServerSelectionMode::Random:
|
|
return "Random";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
const char* liteConnectionAvailabilityName(LiteConnectionAvailability availability)
|
|
{
|
|
switch (availability) {
|
|
case LiteConnectionAvailability::Ready:
|
|
return "Ready";
|
|
case LiteConnectionAvailability::UnsupportedBuild:
|
|
return "UnsupportedBuild";
|
|
case LiteConnectionAvailability::BackendUnavailable:
|
|
return "BackendUnavailable";
|
|
case LiteConnectionAvailability::BridgeUnavailable:
|
|
return "BridgeUnavailable";
|
|
case LiteConnectionAvailability::NoUsableServer:
|
|
return "NoUsableServer";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
LiteServerSelectionResult selectLiteServer(const LiteConnectionSettings& settings)
|
|
{
|
|
auto usableServers = usableConfiguredServers(settings);
|
|
const std::string stickyServerUrl = liteTrimCopy(settings.stickyServerUrl);
|
|
|
|
if (settings.selectionMode == LiteServerSelectionMode::Sticky &&
|
|
isLiteServerUrlUsable(stickyServerUrl)) {
|
|
auto match = std::find_if(usableServers.begin(), usableServers.end(), [&](const auto& candidate) {
|
|
return candidate.second.url == stickyServerUrl;
|
|
});
|
|
if (match != usableServers.end()) {
|
|
return LiteServerSelectionResult{true, match->second, match->first, false, {}};
|
|
}
|
|
return LiteServerSelectionResult{
|
|
true,
|
|
LiteServerEndpoint{stickyServerUrl, "Custom", true},
|
|
0,
|
|
true,
|
|
{}
|
|
};
|
|
}
|
|
|
|
if (usableServers.empty()) {
|
|
return LiteServerSelectionResult{false, {}, 0, false, "no usable lite servers are configured"};
|
|
}
|
|
|
|
if (settings.selectionMode == LiteServerSelectionMode::Random) {
|
|
const std::size_t selectedIndex = settings.randomSelectionSeed % usableServers.size();
|
|
return LiteServerSelectionResult{
|
|
true,
|
|
usableServers[selectedIndex].second,
|
|
usableServers[selectedIndex].first,
|
|
false,
|
|
{}
|
|
};
|
|
}
|
|
|
|
return LiteServerSelectionResult{true, usableServers.front().second, usableServers.front().first, false, {}};
|
|
}
|
|
|
|
LiteConnectionService::LiteConnectionService(WalletCapabilities capabilities,
|
|
LiteConnectionSettings settings,
|
|
LiteClientBridge bridge)
|
|
: capabilities_(capabilities), settings_(std::move(settings)), bridge_(std::move(bridge))
|
|
{
|
|
}
|
|
|
|
LiteConnectionAvailability LiteConnectionService::availability() const
|
|
{
|
|
if (!isLiteBuild(capabilities_)) return LiteConnectionAvailability::UnsupportedBuild;
|
|
if (!supportsLiteBackend(capabilities_)) return LiteConnectionAvailability::BackendUnavailable;
|
|
if (!bridge_.available()) return LiteConnectionAvailability::BridgeUnavailable;
|
|
if (!selectedServer().ok) return LiteConnectionAvailability::NoUsableServer;
|
|
return LiteConnectionAvailability::Ready;
|
|
}
|
|
|
|
WalletBackendStatus LiteConnectionService::status() const
|
|
{
|
|
const auto currentAvailability = availability();
|
|
if (currentAvailability == LiteConnectionAvailability::NoUsableServer) {
|
|
return statusFor(currentAvailability, selectedServer().error);
|
|
}
|
|
return statusFor(currentAvailability);
|
|
}
|
|
|
|
LiteServerSelectionResult LiteConnectionService::selectedServer() const
|
|
{
|
|
return selectLiteServer(settings_);
|
|
}
|
|
|
|
LiteWalletExistsRequest LiteConnectionService::walletExistsRequest() const
|
|
{
|
|
return LiteWalletExistsRequest{normalizedChainName(settings_.chainName)};
|
|
}
|
|
|
|
LiteServerHealthRequest LiteConnectionService::serverHealthRequest() const
|
|
{
|
|
auto selection = selectedServer();
|
|
if (!selection.ok) return {};
|
|
return LiteServerHealthRequest{selection.server, selection.serverIndex, selection.customServer};
|
|
}
|
|
|
|
LiteWalletExistsCheckResult LiteConnectionService::checkWalletExists()
|
|
{
|
|
LiteWalletExistsCheckResult result;
|
|
result.request = walletExistsRequest();
|
|
|
|
const auto readiness = bridgeReadinessStatus();
|
|
if (readiness.state != WalletBackendState::Disconnected) {
|
|
result.status = readiness;
|
|
result.error = readiness.message;
|
|
return result;
|
|
}
|
|
if (result.request.chainName.empty()) {
|
|
result.status = statusFor(LiteConnectionAvailability::BackendUnavailable, "lite chain name is empty");
|
|
result.error = result.status.message;
|
|
return result;
|
|
}
|
|
|
|
result.attempted = true;
|
|
result.walletExists = bridge_.walletExists(result.request.chainName);
|
|
result.ok = true;
|
|
result.status = readiness;
|
|
return result;
|
|
}
|
|
|
|
LiteServerHealthCheckResult LiteConnectionService::checkSelectedServerHealth()
|
|
{
|
|
LiteServerHealthCheckResult result;
|
|
result.request = serverHealthRequest();
|
|
|
|
const auto readiness = bridgeReadinessStatus();
|
|
if (readiness.state != WalletBackendState::Disconnected) {
|
|
result.status = readiness;
|
|
result.error = readiness.message;
|
|
return result;
|
|
}
|
|
|
|
auto selection = selectedServer();
|
|
if (!selection.ok) {
|
|
result.status = statusFor(LiteConnectionAvailability::NoUsableServer, selection.error);
|
|
result.error = result.status.message;
|
|
return result;
|
|
}
|
|
|
|
result.request = LiteServerHealthRequest{selection.server, selection.serverIndex, selection.customServer};
|
|
result.attempted = true;
|
|
result.serverOnline = bridge_.checkServerOnline(result.request.server.url);
|
|
result.ok = true;
|
|
result.status = readiness;
|
|
return result;
|
|
}
|
|
|
|
WalletBackendStatus LiteConnectionService::statusFor(LiteConnectionAvailability availability,
|
|
const std::string& detail) const
|
|
{
|
|
switch (availability) {
|
|
case LiteConnectionAvailability::Ready:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Disconnected,
|
|
detail.empty() ? "lite connection scaffold ready; wallet lifecycle is not implemented" : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteConnectionAvailability::UnsupportedBuild:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
"lite connection service is unsupported in full-node builds",
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteConnectionAvailability::BackendUnavailable:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
detail.empty() ? "lite backend is not linked" : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteConnectionAvailability::BridgeUnavailable:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
detail.empty() ? bridge_.unavailableReason() : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteConnectionAvailability::NoUsableServer:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Error,
|
|
detail.empty() ? "no usable lite servers are configured" : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
}
|
|
|
|
return WalletBackendStatus{WalletBackendState::Unavailable, "unknown lite connection state", {}, {}, 0.0};
|
|
}
|
|
|
|
WalletBackendStatus LiteConnectionService::bridgeReadinessStatus() const
|
|
{
|
|
if (!isLiteBuild(capabilities_)) return statusFor(LiteConnectionAvailability::UnsupportedBuild);
|
|
if (!supportsLiteBackend(capabilities_)) return statusFor(LiteConnectionAvailability::BackendUnavailable);
|
|
if (!bridge_.available()) return statusFor(LiteConnectionAvailability::BridgeUnavailable);
|
|
return statusFor(LiteConnectionAvailability::Ready);
|
|
}
|
|
|
|
} // namespace wallet
|
|
} // namespace dragonx
|