From d951a3d2f4ca95788e101b7bc7d844f92f335a06 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 25 Mar 2026 18:44:56 +0000 Subject: [PATCH 1/2] in GUI mode, only start a limited minimal RPC server To limit attack surface. Context: - both in daemon mode and in GUI mode, we start an RPC server - the RPC server uses HTTP basic auth, with a random password that is saved in the config file - read access to the config file implies access to the RPC server - the traffic is unencrypted - by default the server listens - on Windows, on localhost TCP - all other platform, via unix domain sockets - if an attacker can listen to localhost TCP traffic, and there was traffic - they could see the plaintext RPC password and issue their own commands - e.g. if wireshark was already installed on the system, this might not require root access - the "ping" and "gui" commands are used by everyday operations that affect most users: - "ping" is used when trying to launch a second instance of electrum, to contact the first instance and enforce "singleton" behaviour - "gui" is used for URI handling (`$ xdg-open bitcoin:asdasd`) - many other sensitive commands, that operate on wallets, require *also* the wallet password - but note that wallet.unlock can be used by the user to bypass this and store the wallet password in memory (exposed in GUI) I propose locking down the RPC server when running in GUI mode: - we still start it, as it is used for "ping" and "gui" RPCs, however we disable all other RPCs - we could opt-in enable it, using a config var, except that ofc would not help against an attacker that has filesystem write access to the config file - so I think it's even safer to just "hardcode" disable it: however the functionality is useful for development - I propose we branch based on `constants.net.TESTNET` - an alternative we could branch on that is hard to fake is `is_git_clone` in run_electrum --- electrum/daemon.py | 23 ++++++++++++++++------- run_electrum | 9 ++++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/electrum/daemon.py b/electrum/daemon.py index a8fc2ceb1..b22c1d192 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -286,25 +286,31 @@ class AuthenticatedServer(Logger): class CommandsServer(AuthenticatedServer): - def __init__(self, daemon: 'Daemon', fd): + def __init__(self, daemon: 'Daemon', fd, *, only_minimal_jsonrpc: bool): rpc_user, rpc_password = get_rpc_credentials(daemon.config) AuthenticatedServer.__init__(self, rpc_user, rpc_password) self.daemon = daemon self.fd = fd + self._only_minimal_jsonrpc = only_minimal_jsonrpc self.config = daemon.config sockettype = self.config.RPC_SOCKET_TYPE self.socktype = sockettype if sockettype != 'auto' else get_rpcsock_default_type(self.config) self.sockpath = self.config.RPC_SOCKET_FILEPATH or get_rpcsock_defaultpath(self.config) self.host = self.config.RPC_HOST self.port = self.config.RPC_PORT + self.cmd_runner = Commands(config=self.config, network=self.daemon.network, daemon=self.daemon) self.app = web.Application() self.app.router.add_post("/", self.handle) + # First add always-enabled commands that are also available for "minimal" rpc server. + # - "ping" RPC is needed for the lockfile fd to work. self.register_method('ping', self.ping) + # - "gui" RPC is needed for URI handling. (TODO restrict further: disallow opening arbitrary file paths) self.register_method('gui', self.gui) - self.cmd_runner = Commands(config=self.config, network=self.daemon.network, daemon=self.daemon) - for cmdname in known_commands: - self.register_method(cmdname, getattr(self.cmd_runner, cmdname)) - self.register_method('run_cmdline', self.run_cmdline) + # Add other commands: + if not only_minimal_jsonrpc: + for cmdname in known_commands: + self.register_method(cmdname, getattr(self.cmd_runner, cmdname)) + self.register_method('run_cmdline', self.run_cmdline) def _socket_config_str(self) -> str: if self.socktype == 'unix': @@ -336,7 +342,9 @@ class CommandsServer(AuthenticatedServer): raise Exception(f"impossible socktype ({self.socktype!r})") os.write(self.fd, bytes(repr((self.socktype, addr, time.time())), 'utf8')) os.close(self.fd) - self.logger.info(f"now running and listening. socktype={self.socktype}, addr={addr}") + self.logger.info( + f"now running and listening. socktype={self.socktype}, addr={addr}. " + f"only_minimal_jsonrpc={self._only_minimal_jsonrpc}") async def ping(self): return True @@ -394,6 +402,7 @@ class Daemon(Logger): fd=None, *, listen_jsonrpc: bool = True, + only_minimal_jsonrpc: bool = True, start_network: bool = True, # setting to False allows customising network settings before starting it ): Logger.__init__(self) @@ -423,7 +432,7 @@ class Daemon(Logger): # Setup commands server self.commands_server = None if listen_jsonrpc: - self.commands_server = CommandsServer(self, fd) + self.commands_server = CommandsServer(self, fd, only_minimal_jsonrpc=only_minimal_jsonrpc) asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.commands_server.run()), self.asyncio_loop) @log_exceptions diff --git a/run_electrum b/run_electrum index df45836e1..e769ed474 100755 --- a/run_electrum +++ b/run_electrum @@ -123,6 +123,7 @@ from electrum.commands import get_parser, get_simple_parser, known_commands, Com from electrum import daemon from electrum.util import create_and_start_event_loop, UserFacingException, JsonRPCError from electrum.i18n import set_language +from electrum import constants if TYPE_CHECKING: import threading @@ -509,7 +510,9 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): configure_logging(config) fd = daemon.get_file_descriptor(config) if fd is not None: - d = daemon.Daemon(config, fd, start_network=False) + # When running in GUI mode, only start a limited minimal RPC server, to limit attack surface. + only_minimal_jsonrpc = not constants.net.TESTNET + d = daemon.Daemon(config, fd, start_network=False, only_minimal_jsonrpc=only_minimal_jsonrpc) try: d.run_gui() except BaseException as e: @@ -535,7 +538,7 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): fd = daemon.get_file_descriptor(config) if fd is not None: # run daemon - d = daemon.Daemon(config, fd) + d = daemon.Daemon(config, fd, only_minimal_jsonrpc=False) d.run_daemon() sys_exit(0) else: @@ -577,7 +580,7 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): except Exception as e: _logger.exception("error running command (with daemon)") sys_exit(1) - else: + else: # --offline if cmd.requires_network: print_msg("This command cannot be run offline") sys_exit(1) From 726d3995f437bd4c4c367cc503b90b9aa0bbfa45 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 25 Mar 2026 18:54:13 +0000 Subject: [PATCH 2/2] qt gui: more defensive 'gui' RPC (i.e. URI) handling --- electrum/gui/qt/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 9c151542c..38a8a95b6 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -439,6 +439,14 @@ class ElectrumGui(BaseElectrumGui, Logger): window.activateWindow() if uri: window.show_send_tab() + # Handle URI defensively - local attacker with access to RPC server and config file could get here: + # - tell user something happened + window.notify(_("Updated 'Pay To' field to handle external URI")) + # - clear all fields in Send tab: + # - perhaps user was just filling out the fields, trying to make another payment. + # e.g. if the given URI does not have an amount, we should clear the amount field + window.send_tab.do_clear() + # - update "Pay To" field (and maybe others) window.send_tab.set_payment_identifier(uri) return window