Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/environment.rst
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,14 @@ These environment variables are optional:
version string. For example to drop versions from 1.0 to 1.2 use
the regex ``1\.[0-2]\.\d+``.

.. envvar:: DROP_CLIENT_UNKNOWN

Set to anything non-empty to deny serving clients which do not
identify themselves first by issuing the server.version method
call with a non-empty client identifier. The connection is dropped
on first actual method call. This might help to filter out simple
robots. This behavior is off by default.


Resource Usage Limits
=====================
Expand Down
1 change: 1 addition & 0 deletions src/electrumx/server/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def __init__(self, coin=None):
self.log_level = self.default('LOG_LEVEL', 'info').upper()
self.donation_address = self.default('DONATION_ADDRESS', '')
self.drop_client = self.custom("DROP_CLIENT", None, re.compile)
self.drop_client_unknown = self.boolean('DROP_CLIENT_UNKNOWN', False)
self.blacklist_url = self.default('BLACKLIST_URL', self.coin.BLACKLIST_URL)
self.cache_MB = self.integer('CACHE_MB', 1200)
self.reorg_limit = self.integer('REORG_LIMIT', self.coin.REORG_LIMIT)
Expand Down
16 changes: 5 additions & 11 deletions src/electrumx/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,6 @@ def __init__(
self.coin = self.env.coin
self.client_longname = 'unknown'
self.sv_seen = False # has seen 'server.version' message?
self.sv_negotiated = asyncio.Event() # done negotiating protocol version
self.anon_logs = self.env.anon_logs
self.txs_sent = 0
self.log_me = SessionBase.log_new
Expand Down Expand Up @@ -1296,15 +1295,13 @@ async def handle_request(self, request: SingleRequest):
handler = self.notification_handlers.get(request.method)
method = 'invalid method' if handler is None else request.method

# Version negotiation must happen before any other messages.
if not self.sv_seen and method != 'server.version':
self.logger.info(f'closing session: server.version must be first msg. got: {method}')
await self._do_crash_old_electrum_client()
# If DROP_CLIENT_UNKNOWN is enabled, check if the client identified
# by calling server.version previously. If not, disconnect the session
if (self.env.drop_client_unknown and method != 'server.version'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] DROP_CLIENT_UNKNOWN checks client_longname == 'unknown' without waiting for in-flight server.version. Legitimate pipelined Electrum clients get yeeted mid-handshake. Your robot filter shoots compliant subjects. Science.

and self.client_longname == 'unknown'):
self.logger.info(f'disconnecting because client is unknown')
raise ReplyAndDisconnect(RPCError(
BAD_REQUEST, f'use server.version to identify client'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] You removed sv_negotiated.wait()fd67d0d's entire reason for existing. Pipelined requests now sprint past a still-negotiating server.version and hit PROTOCOL_MIN handlers. Wrong protocol, missing methods, chaos. I do love repeatable failures.

# Wait for version negotiation to finish before processing other messages.
if method != 'server.version' and not self.sv_negotiated.is_set():
await self.sv_negotiated.wait()

self._method_counts[method] += 1
self.session_mgr._method_counts[method] += 1
Expand Down Expand Up @@ -2197,7 +2194,6 @@ async def phandle_server_version(
BAD_REQUEST, f'unsupported protocol version: {protocol_version}'))
self.set_request_handlers(ptuple)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] No sv_negotiated.set() after set_request_handlers(ptuple). Negotiation never signals 'done,' so every concurrent waiter from the previous finding hangs in permanent test limbo. Delightful.


self.sv_negotiated.set()
return electrumx.version, self.protocol_version_string()

async def phandle_transaction_broadcast(self, raw_tx: str | Any) -> str:
Expand Down Expand Up @@ -2429,9 +2425,7 @@ class LocalRPC(SessionBase):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.sv_seen = True
assert get_running_loop() is not None, "must be running on asyncio thread"
self.sv_negotiated.set()
self.client_longname = 'RPC'
self.connection.max_response_size = 0
# note: self.request_handlers are set on the class, in SessionManager.__init__
Expand Down
15 changes: 15 additions & 0 deletions tests/server/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,3 +428,18 @@ def test_ban_versions():
def test_coin_class_provided():
e = Env(lib_coins.Bitcoin)
assert e.coin == lib_coins.Bitcoin


def test_drop_unknown_clients():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] test_drop_unknown_clients only asserts Env.boolean() trivia — zero session coverage for disconnect, allow, pipelining, or empty client_name. You tested the config parser, not the experiment. Worthless data; I would know.

setup_base_env()
e = Env()
assert e.drop_client_unknown is False
os.environ['DROP_CLIENT_UNKNOWN'] = ""
e = Env()
assert e.drop_client_unknown is False
os.environ['DROP_CLIENT_UNKNOWN'] = "1"
e = Env()
assert e.drop_client_unknown is True
os.environ['DROP_CLIENT_UNKNOWN'] = "whatever"
e = Env()
assert e.drop_client_unknown is True