in GUI mode, only start a limited minimal RPC server
What changed, and why it matters
This commit reduces the attack surface of Electrum's built-in remote-control server when running in normal GUI mode. Previously, the RPC server exposed many wallet-related commands; now, in GUI mode on mainnet, it only exposes two safe commands ('ping' and 'gui'). The change is a hardening measure, not a fix for a known active exploit. It explicitly leaves the full RPC server enabled for daemon mode and for testnet GUI mode (for development).
Users running Electrum GUI on mainnet should upgrade to a version containing this commit. Users relying on CLI/RPC control of a running GUI instance on mainnet should switch to daemon mode or use testnet for development, as the full RPC server is no longer available in mainnet GUI. Review whether the residual `gui` RPC's URI handling is sufficiently restricted, as the commit author flagged this as a TODO.
Security signals we found
Reduction of RPC attack surface in GUI mode
Unencrypted localhost RPC with config-stored password
Windows localhost TCP exposure of RPC server
wallet.unlock stores wallet password in GUI memory (noted by author as residual risk)
Hardcoded minimal mode for mainnet GUI; opt-out via testnet only
Evidence from the diff
The patch adds an only_minimal_jsonrpc flag to CommandsServer and Daemon. When true, only ping and gui RPC methods are registered; the full Commands runner and run_cmdline are not exposed. In run_electrum, GUI mode now passes only_minimal_jsonrpc=not constants.net.TESTNET, meaning mainnet GUI gets the minimal server while testnet GUI and daemon mode keep the full server. The commit message explains the threat model: the RPC password is stored in the config file, traffic is unencrypted, and on Windows the server listens on localhost TCP, so a local attacker able to sniff loopback traffic could capture the password and issue commands. The patch mitigates this by limiting what commands are available in the most common GUI scenario.
Changed components
electrum/daemon.pyrun_electrumCommandsServerDaemonRPC serverInspect captured patch +22 / −10
diff --git a/electrum/daemon.py b/electrum/daemon.py
index a8fc2ce..b22c1d1 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 df45836..e769ed4 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)
Why this scored 62/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.