warn when a bitcoin core node is neither local nor onion
What changed, and why it matters
This commit adds a warning to Sparrow Wallet when a user connects to a Bitcoin Core node that is neither on their own computer/local network nor a Tor onion address. Previously, users could unknowingly route Bitcoin Core RPC traffic over the public internet, exposing their wallet data and RPC credentials. The change also improves how Sparrow decides whether an address is 'local' and fixes a UI bug where settings panes were recreated each time they were opened. It is a defensive hardening change rather than a fix for an active exploit.
Users should review their Bitcoin Core server configuration and heed the new warning. Operators should prefer local-network nodes, Tor onion services, or VPN/SSH tunnels for remote nodes. Developers should verify the local-address classification logic covers intended deployment scenarios and that the warning is shown reliably.
Security signals we found
New user warning for remote Bitcoin Core RPC connections
Expanded local-address classification to reduce false negatives
Avoids DNS resolution of hostnames when a proxy is configured
Documents that Bitcoin Core RPC credentials are sent in cleartext over HTTP
Documents trust-on-first-use certificate pinning for HTTPS Bitcoin Core RPC
Fixes duplicate UI controller/listener creation in settings pane loading
Evidence from the diff
The patch introduces showRemoteCoreServerWarning() in ServerSettingsController, which warns users if a configured Bitcoin Core server is not a local-network address, a local-only domain suffix (.local/.lan/.home.arpa/.internal), or an onion address. It expands IpAddressMatcher to cover loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16, fe80::/10), and unique local IPv6 (fc00::/7) ranges, and adds local-domain suffix classification without DNS resolution. When a proxy is in use, only IP literals and local-domain names are treated as potentially local; other hostnames are assumed remote to avoid leaking DNS queries. BitcoindTransport is documented to clarify that proxies are used only for onion addresses and that HTTPS uses trust-on-first-use certificate pinning. A separate UI fix caches FXMLLoader instances in SettingsController to prevent duplicate controller/listener creation.
Changed components
Sparrow Wallet settings UI (ServerSettingsController, SettingsController)Bitcoin Core RPC transport (BitcoindTransport)IP address/local-network classification (IpAddressMatcher)Inspect captured patch +147 / −10
### src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java
@@ -23,6 +23,7 @@
import java.net.InetAddress;
import java.net.UnknownHostException;
+import java.util.List;
import java.util.Locale;
/**
@@ -40,10 +41,20 @@
public final class IpAddressMatcher {
private static final Logger log = LoggerFactory.getLogger(IpAddressMatcher.class);
- private static final IpAddressMatcher LOCAL_RANGE_1 = new IpAddressMatcher("10.0.0.0/8");
- private static final IpAddressMatcher LOCAL_RANGE_2 = new IpAddressMatcher("172.16.0.0/12");
- private static final IpAddressMatcher LOCAL_RANGE_3 = new IpAddressMatcher("192.168.0.0/16");
- private static final IpAddressMatcher LOCAL_RANGE_4 = new IpAddressMatcher("100.64.0.0/10");
+ //Names in these domains cannot resolve outside the local network - .local is resolved by link-local multicast (RFC 6762), .home.arpa is reserved for home networks (RFC 8375),
+ //.internal is reserved for private use, and .lan is a suffix commonly assigned by routers
+ private static final List<String> LOCAL_DOMAIN_SUFFIXES = List.of(".local", ".lan", ".home.arpa", ".internal");
+
+ private static final List<IpAddressMatcher> LOCAL_RANGES = List.of(
+ new IpAddressMatcher("10.0.0.0/8"),
+ new IpAddressMatcher("172.16.0.0/12"),
+ new IpAddressMatcher("192.168.0.0/16"),
+ new IpAddressMatcher("100.64.0.0/10"),
+ new IpAddressMatcher("127.0.0.0/8"),
+ new IpAddressMatcher("169.254.0.0/16"),
+ new IpAddressMatcher("::1"),
+ new IpAddressMatcher("fc00::/7"),
+ new IpAddressMatcher("fe80::/10"));
private final int nMaskBits;
private final InetAddress requiredAddress;
@@ -72,8 +83,10 @@ public IpAddressMatcher(String ipAddress) {
}
public boolean matches(String address) {
- InetAddress remoteAddress = parseAddress(address);
+ return matches(parseAddress(address));
+ }
+ public boolean matches(InetAddress remoteAddress) {
if (!requiredAddress.getClass().equals(remoteAddress.getClass())) {
return false;
}
@@ -101,28 +114,36 @@ public boolean matches(String address) {
return true;
}
- private InetAddress parseAddress(String address) {
+ private static InetAddress parseAddress(String address) {
try {
return InetAddress.getByName(address);
} catch(UnknownHostException e) {
throw new IllegalArgumentException("Failed to resolve address: " + address, e);
}
}
+ public static boolean isLocalNetworkName(String host) {
+ String lowerHost = host.toLowerCase(Locale.ROOT);
+ return "localhost".equals(lowerHost) || LOCAL_DOMAIN_SUFFIXES.stream().anyMatch(lowerHost::endsWith);
+ }
+
public static boolean isLocalNetworkAddress(String address) {
try {
- if("localhost".equals(address) || "127.0.0.1".equals(address)) {
+ if(isLocalNetworkName(address)) {
return true;
}
//Matching a hostname against the local ranges requires resolving it, which leaks the name to (and trusts the answer of) the local DNS resolver even when a proxy is configured
- //Only IP literals and mDNS names (which RFC 6762 requires to be resolved via link-local multicast, not upstream DNS) are considered potentially local when using a proxy
- if(AppServices.isUsingProxy() && !InetAddresses.isInetAddress(address) && !address.toLowerCase(Locale.ROOT).endsWith(".local")) {
+ //Only IP literals are considered potentially local when using a proxy, since local network names have already returned above
+ if(AppServices.isUsingProxy() && !InetAddresses.isInetAddress(address)) {
log.info("Avoiding local DNS resolution of " + address + ", assuming it is a non-local address to be resolved by the configured proxy");
return false;
}
- return LOCAL_RANGE_1.matches(address) || LOCAL_RANGE_2.matches(address) || LOCAL_RANGE_3.matches(address) || LOCAL_RANGE_4.matches(address);
+ //Resolve once for all of the ranges, as a hostname lookup may be involved
+ InetAddress inetAddress = parseAddress(address);
+
+ return LOCAL_RANGES.stream().anyMatch(localRange -> localRange.matches(inetAddress));
} catch(IllegalArgumentException e) {
if(AppServices.isUsingProxy()) {
log.info(e.getMessage() + ", assuming it is a non-local address to be resolved by the configured proxy");
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java
@@ -55,13 +55,18 @@ private BitcoindTransport(Server bitcoindServer, String bitcoindWallet) {
@Override
public String pass(String request) throws IOException {
+ //Bitcoin Core RPC is a connection to the user's own node, expected to be on this computer or the local network, or reached over its onion service or a VPN or SSH tunnel.
+ //A configured proxy is therefore applied to onion addresses only - AppServices.getProxy() is also non-null whenever the internal Tor is running, and Tor can reach neither
+ //loopback nor private addresses, while routing a clearnet node through it would expose the RPC credentials below to an exit node.
+ //Configuring a node that is neither local nor onion warns the user on testing the connection or closing the dialog, see ServerSettingsController.
Proxy proxy = AppServices.getProxy();
HttpURLConnection connection = proxy != null && Protocol.isOnionAddress(bitcoindServer) ? (HttpURLConnection)bitcoindUrl.openConnection(proxy) : (HttpURLConnection)bitcoindUrl.openConnection();
if(connection instanceof HttpsURLConnection httpsURLConnection) {
SSLSocketFactory sslSocketFactory = getSSLSocketFactory();
if(sslSocketFactory != null) {
httpsURLConnection.setSSLSocketFactory(sslSocketFactory);
+ //A private node's RPC certificate is necessarily self-signed, so the certificate pinned on first use below authenticates it - there is no hostname to verify
httpsURLConnection.setHostnameVerifier((_, _) -> true);
}
}
@@ -85,6 +90,7 @@ public String pass(String request) throws IOException {
int statusCode = connection.getResponseCode();
+ //Trust on first use, as for non-CA-certified Electrum servers: the certificate presented by the node on the first connection is saved, and required on all connections thereafter
if(connection instanceof HttpsURLConnection httpsConn && Storage.getCertificateFile(bitcoindServer.getHost()) == null) {
try {
Certificate[] certs = httpsConn.getServerCertificates();
### src/main/java/com/sparrowwallet/sparrow/settings/ServerSettingsController.java
@@ -4,6 +4,7 @@
import com.google.common.base.Throwables;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
+import com.google.common.net.InetAddresses;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.policy.PolicyType;
@@ -174,6 +175,8 @@ public class ServerSettingsController extends SettingsDetailController {
private Boolean useProxyOriginal;
+ private boolean coreServerWarningShown;
+
@Override
public void initializeView(Config config) {
EventManager.get().register(this);
@@ -182,6 +185,7 @@ public void initializeView(Config config) {
if(connectionService != null && connectionService.isRunning()) {
connectionService.cancel();
}
+ Platform.runLater(() -> showRemoteCoreServerWarning(config));
});
Platform.runLater(this::setupValidation);
@@ -422,6 +426,7 @@ public void initializeView(Config config) {
testConnection.setVisible(!isConnected);
setTestResultsFont();
testConnection.setOnAction(event -> {
+ showRemoteCoreServerWarning(config);
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.ELLIPSIS_H, null));
testResults.setText("Connecting " + (config.hasServer() ? "to " + config.getServer().getUrl() : "") + "...");
@@ -814,6 +819,53 @@ private void setCoreServerInConfig(Config config) {
}
}
+ private void showRemoteCoreServerWarning(Config config) {
+ Server coreServer = config.getCoreServer();
+ if(!coreServerWarningShown && config.getServerType() == ServerType.BITCOIN_CORE && isRemoteNode(coreServer)) {
+ coreServerWarningShown = true;
+
+ //A host that is not an IP literal is not resolved here, so it can only be reported as unconfirmed
+ String location = InetAddresses.isInetAddress(coreServer.getHost()) ? " is not on" : " could not be confirmed to be on";
+ StringBuilder warning = new StringBuilder("Bitcoin Core at " + coreServer.getHostAndPort() + location + " this computer or your local network.\n");
+ if(AppServices.isUsingProxy()) {
+ String proxy = config.isUseProxy() ? "configured proxy" : "internal Tor proxy";
+ warning.append("\nConnections to Bitcoin Core are made directly, and the " + proxy + " is only used for onion addresses. ");
+ warning.append("Your IP address will be visible to the node.\n");
+ }
+ if(coreServer.getProtocol() == Protocol.HTTP) {
+ warning.append("\nThe RPC credentials and wallet descriptors sent to it are unencrypted, and can be read by anyone on the network path.\n");
+ } else if(Storage.getCertificateFile(coreServer.getHost()) == null) {
+ warning.append("\nThe certificate presented by the node on the first connection will be trusted and required on all connections thereafter.\n");
+ }
+ warning.append("\nConnecting to the Bitcoin Core RPC interface over an untrusted network is not recommended by either Sparrow or Bitcoin Core. ");
+ warning.append("Consider using a node on this computer or your local network, connecting over a Tor onion address, or tunnelling to it over a VPN or SSH.");
+
+ AppServices.showWarningDialog("Remote Bitcoin Core node", warning.toString());
+ }
+ }
+
+ private boolean isRemoteNode(Server coreServer) {
+ if(coreServer == null || coreServer.isOnionAddress()) {
+ return false;
+ }
+
+ String host = coreServer.getHost();
+ if(IpAddressMatcher.isLocalNetworkName(host)) {
+ return false;
+ }
+
+ if(!InetAddresses.isInetAddress(host)) {
+ //Resolving a hostname here would block the user interface thread, and an unresolved host cannot be shown to be local
+ return true;
+ }
+
+ try {
+ return !IpAddressMatcher.isLocalNetworkAddress(host);
+ } catch(IllegalArgumentException e) {
+ return true;
+ }
+ }
+
@NotNull
private ChangeListener<String> getBitcoinAuthListener(Config config) {
return (observable, oldValue, newValue) -> {
### src/main/java/com/sparrowwallet/sparrow/settings/SettingsController.java
@@ -15,12 +15,16 @@
import java.io.IOException;
import java.net.URL;
+import java.util.HashMap;
import java.util.Locale;
+import java.util.Map;
import java.util.ResourceBundle;
public class SettingsController implements Initializable {
private Config config;
+ private final Map<String, FXMLLoader> settingsDetailLoaders = new HashMap<>();
+
@FXML
private ToggleGroup settingsMenu;
@@ -78,13 +82,21 @@ public BooleanProperty reconnectOnClosingProperty() {
FXMLLoader setPreferencePane(String fxmlName) {
settingsPane.getChildren().removeAll(settingsPane.getChildren());
+ //Retain the pane already loaded for this group, so that a controller is not created (and its listeners and event subscriptions added) each time the group is selected
+ FXMLLoader existingDetailLoader = settingsDetailLoaders.get(fxmlName);
+ if(existingDetailLoader != null) {
+ settingsPane.getChildren().add(existingDetailLoader.getRoot());
+ return existingDetailLoader;
+ }
+
try {
FXMLLoader settingsDetailLoader = new FXMLLoader(AppServices.class.getResource("settings/" + fxmlName + ".fxml"));
Node preferenceGroupNode = settingsDetailLoader.load();
SettingsDetailController controller = settingsDetailLoader.getController();
controller.setMasterController(this);
controller.initializeView(config);
settingsPane.getChildren().add(preferenceGroupNode);
+ settingsDetailLoaders.put(fxmlName, settingsDetailLoader);
return settingsDetailLoader;
} catch (IOException e) {
### src/test/java/com/sparrowwallet/sparrow/net/IpAddressMatcherTest.java
@@ -42,6 +42,31 @@ public void classifiesAddressesWithoutProxy() {
assertFalse(IpAddressMatcher.isLocalNetworkAddress("8.8.8.8"));
}
+ @Test
+ public void classifiesLoopbackAndIpv6AddressesWithoutProxy() {
+ Config.get().setUseProxy(false);
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("127.0.0.2"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("169.254.1.1"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("::1"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("fd00::1"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("fc00::1"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("fe80::1"));
+ //Global unicast addresses remain remote, whichever family they are in
+ assertFalse(IpAddressMatcher.isLocalNetworkAddress("2001:4860:4860::8888"));
+ assertFalse(IpAddressMatcher.isLocalNetworkAddress("8.8.8.8"));
+ }
+
+ @Test
+ public void classifiesLoopbackAndIpv6AddressesWithProxy() {
+ Config.get().setUseProxy(true);
+ //IP literals classify without DNS resolution, so a local node must still connect directly when a proxy is configured
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("127.0.0.2"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("::1"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("fd00::1"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("fe80::1"));
+ assertFalse(IpAddressMatcher.isLocalNetworkAddress("2001:4860:4860::8888"));
+ }
+
@Test
public void classifiesIpLiteralsWithProxy() {
Config.get().setUseProxy(true);
@@ -55,6 +80,27 @@ public void classifiesIpLiteralsWithProxy() {
assertFalse(IpAddressMatcher.isLocalNetworkAddress("8.8.8.8"));
}
+ @Test
+ public void classifiesLocalNetworkNamesWithoutProxy() {
+ Config.get().setUseProxy(false);
+ //Names in these domains cannot resolve outside the local network, and are classified without resolving them
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("mynode.local"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("mynode.lan"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("MyNode.Home.Arpa"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("mynode.internal"));
+ }
+
+ @Test
+ public void classifiesLocalNetworkNamesWithProxy() {
+ Config.get().setUseProxy(true);
+ //A local network name must still connect directly when a proxy is configured, as the proxy cannot resolve it
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("mynode.local"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("mynode.lan"));
+ assertTrue(IpAddressMatcher.isLocalNetworkAddress("mynode.internal"));
+ //A local domain suffix elsewhere in the name is not a local network name
+ assertFalse(IpAddressMatcher.isLocalNetworkAddress("mynode.local.example.com"));
+ }
+
@Test
public void assumesHostnamesAreRemoteWithProxy() {
Config.get().setUseProxy(true);Why this scored 47/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.