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
This commit is contained in:
SomberNight
2026-03-25 18:44:56 +00:00
parent bd4439945a
commit d951a3d2f4
2 changed files with 22 additions and 10 deletions
+16 -7
View File
@@ -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