fix(rpc): refuse plaintext-remote RPC by default and tighten isLocalHost
F8 (security). Two related fixes to how the wallet decides whether an RPC target is
safe to send Basic-auth credentials to:
- isLocalHost() was matching any host that merely *starts* "127." via
rfind("127.",0)==0, so "127.evil.com" (and "127.0.0.1.attacker", "127.300.0.1",
"1270.0.0.1") were misclassified as loopback and treated as local. It now uses a
strict isExactIPv4Loopback() parser: exactly four 0-255 dot-separated octets with
the first == 127. localhost / ::1 / [::1] handling is unchanged.
- A remote rpchost over plain HTTP (no rpctls=1) previously only produced a
dismissible warning and then sent rpcuser:rpcpassword in cleartext, where a
local-network MITM could capture them. tryConnect() now REFUSES that connection
(clear status line + one-time notification, no creds sent) unless the user opts in
explicitly with rpcallowplaintext=1 in DRAGONX.conf (new
ConnectionConfig::allow_plaintext_remote, parsed in parseConfFile; policy in the
new allowsPlaintextRemote()). Local/embedded daemons and rpctls=1 remotes are
unaffected.
BREAKING: a wallet configured for remote plaintext RPC will stop connecting until
rpcallowplaintext=1 (or rpctls=1) is added to DRAGONX.conf. Must be called out in the
release notes. The Settings-toggle UI is deferred (the conf-key opt-in is the recovery
path; see docs/daemon-startup-hardening.md).
Adds testIsLocalHost and testAllowsPlaintextRemote to test_phase4.cpp; one i18n key
(English) added to i18n.cpp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -321,11 +321,21 @@ void App::tryConnect()
|
||||
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
|
||||
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
|
||||
|
||||
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
|
||||
remote_rpc_plaintext_warning_shown_ = true;
|
||||
ui::Notifications::instance().warning(
|
||||
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
|
||||
10.0f);
|
||||
if (rpc::Connection::usesPlaintextRemote(config) &&
|
||||
!rpc::Connection::allowsPlaintextRemote(config)) {
|
||||
// Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network
|
||||
// MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change
|
||||
// from the old warn-and-proceed: opt in explicitly with rpcallowplaintext=1 in
|
||||
// DRAGONX.conf (or enable TLS with rpctls=1) if the plaintext link is intended.
|
||||
connection_in_progress_ = false;
|
||||
connection_status_ = TR("sb_plaintext_remote_blocked");
|
||||
if (!remote_rpc_plaintext_warning_shown_) {
|
||||
remote_rpc_plaintext_warning_shown_ = true;
|
||||
ui::Notifications::instance().warning(TR("sb_plaintext_remote_blocked"), 20.0f);
|
||||
}
|
||||
VERBOSE_LOGF("[connect #%d] refusing plaintext-remote RPC to %s:%s (set rpcallowplaintext=1 to override)\n",
|
||||
connect_attempt, config.host.c_str(), config.port.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the blocking rpc_->connect() on the worker thread so the UI
|
||||
|
||||
@@ -290,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
config.proxy = value;
|
||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||
config.use_tls = parseBoolValue(value);
|
||||
} else if (key == "rpcallowplaintext") {
|
||||
config.allow_plaintext_remote = parseBoolValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
|
||||
return true;
|
||||
}
|
||||
|
||||
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
|
||||
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
|
||||
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
|
||||
static bool isExactIPv4Loopback(const std::string& host)
|
||||
{
|
||||
int octets = 0, value = 0, digits = 0;
|
||||
bool firstIs127 = false;
|
||||
for (size_t i = 0; i <= host.size(); ++i) {
|
||||
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
|
||||
if (c == '.') {
|
||||
if (digits == 0 || digits > 3 || value > 255) return false;
|
||||
if (octets == 0) firstIs127 = (value == 127);
|
||||
++octets;
|
||||
value = 0;
|
||||
digits = 0;
|
||||
} else if (c >= '0' && c <= '9') {
|
||||
value = value * 10 + (c - '0');
|
||||
++digits;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return octets == 4 && firstIs127;
|
||||
}
|
||||
|
||||
bool Connection::isLocalHost(const std::string& host)
|
||||
{
|
||||
std::string lowered = lowercase(host);
|
||||
@@ -375,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
|
||||
|
||||
return lowered == "localhost" || lowered == "localhost." ||
|
||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
||||
isExactIPv4Loopback(lowered);
|
||||
}
|
||||
|
||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
@@ -383,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
return !config.use_tls && !isLocalHost(config.host);
|
||||
}
|
||||
|
||||
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
|
||||
{
|
||||
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
|
||||
// link to a remote host. Off by default — see usesPlaintextRemote().
|
||||
return config.allow_plaintext_remote;
|
||||
}
|
||||
|
||||
const char* Connection::authSourceName(AuthSource source)
|
||||
{
|
||||
switch (source) {
|
||||
|
||||
@@ -29,6 +29,7 @@ struct ConnectionConfig {
|
||||
std::string proxy; // SOCKS5 proxy for Tor
|
||||
bool use_embedded = true;
|
||||
bool use_tls = false;
|
||||
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
|
||||
AuthSource auth_source = AuthSource::Missing;
|
||||
// Non-empty when autoDetectConfig() could not create the data directory; callers
|
||||
// should surface it and abort the connect rather than proceeding blindly.
|
||||
@@ -132,6 +133,11 @@ public:
|
||||
*/
|
||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
|
||||
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
|
||||
// means the connect is refused.
|
||||
static bool allowsPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
static const char* authSourceName(AuthSource source);
|
||||
|
||||
private:
|
||||
|
||||
@@ -1321,6 +1321,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["loading_stall_title"] = "Taking longer than expected";
|
||||
strings_["loading_stall_body"] = "The daemon has been initializing for %.0fs. This can be normal after an update or on first launch (loading the block index or rescanning) — it will connect automatically once ready.";
|
||||
strings_["loading_stall_hint"] = "Still stuck? Open Settings and use Restart Daemon, or check the Console for details.";
|
||||
strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1.";
|
||||
strings_["sb_dragonxd_running"] = "dragonxd running";
|
||||
strings_["sb_dragonxd_stopping"] = "Stopping dragonxd...";
|
||||
strings_["sb_dragonxd_stopped"] = "dragonxd stopped";
|
||||
|
||||
Reference in New Issue
Block a user