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:
2026-08-02 11:40:35 -05:00
parent eb69e491b9
commit efb271cb9a
6 changed files with 126 additions and 8 deletions

View File

@@ -2479,6 +2479,50 @@ void testDaemonShutdownPolicy()
EXPECT_TRUE(bootstrap.disconnectRpc);
}
void testIsLocalHost()
{
using dragonx::rpc::Connection;
// Genuine loopback / local hosts.
EXPECT_TRUE(Connection::isLocalHost("127.0.0.1"));
EXPECT_TRUE(Connection::isLocalHost("127.1.2.3"));
EXPECT_TRUE(Connection::isLocalHost("localhost"));
EXPECT_TRUE(Connection::isLocalHost("LocalHost"));
EXPECT_TRUE(Connection::isLocalHost("::1"));
EXPECT_TRUE(Connection::isLocalHost("[::1]"));
// The regression this fix targets: a hostname merely starting "127." is NOT loopback.
EXPECT_TRUE(!Connection::isLocalHost("127.evil.com"));
EXPECT_TRUE(!Connection::isLocalHost("127.0.0.1.attacker.example"));
EXPECT_TRUE(!Connection::isLocalHost("127.300.0.1"));
EXPECT_TRUE(!Connection::isLocalHost("1270.0.0.1"));
EXPECT_TRUE(!Connection::isLocalHost("10.0.0.5"));
EXPECT_TRUE(!Connection::isLocalHost("example.com"));
}
void testAllowsPlaintextRemote()
{
using dragonx::rpc::Connection;
using dragonx::rpc::ConnectionConfig;
ConnectionConfig local;
local.host = "127.0.0.1";
local.use_tls = false;
EXPECT_TRUE(!Connection::usesPlaintextRemote(local)); // local is never "plaintext remote"
ConnectionConfig remote;
remote.host = "10.0.0.5";
remote.use_tls = false;
EXPECT_TRUE(Connection::usesPlaintextRemote(remote)); // remote + no TLS
EXPECT_TRUE(!Connection::allowsPlaintextRemote(remote)); // blocked by default → connect refused
remote.allow_plaintext_remote = true;
EXPECT_TRUE(Connection::allowsPlaintextRemote(remote)); // explicit opt-in
ConnectionConfig remoteTls;
remoteTls.host = "10.0.0.5";
remoteTls.use_tls = true;
EXPECT_TRUE(!Connection::usesPlaintextRemote(remoteTls)); // TLS → not plaintext, never refused
}
void testConnectHasStalled()
{
using dragonx::util::connectHasStalled;
@@ -6758,6 +6802,8 @@ int main()
testPlatformEnsureDirectory();
testVerifySaplingParams();
testConnectHasStalled();
testIsLocalHost();
testAllowsPlaintextRemote();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();
testConsoleTextLayout();