-
Notifications
You must be signed in to change notification settings - Fork 101
Authenticate xdist StatusDB connections #358
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Authenticate xdist StatusDB connections with a per-session token. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| import os | ||
| import platform | ||
| import re | ||
| import secrets | ||
| import socket | ||
| import sys | ||
| import threading | ||
|
|
@@ -595,7 +596,10 @@ def pytest_configure(config): | |
| if is_master(config): | ||
| config.failures_db = ServerStatusDB() | ||
| else: | ||
| config.failures_db = ClientStatusDB(config.workerinput["sock_port"]) | ||
| config.failures_db = ClientStatusDB( | ||
| config.workerinput["sock_port"], | ||
| config.workerinput["statusdb_token"], | ||
| ) | ||
| else: | ||
| config.failures_db = StatusDB() # no-op db | ||
|
|
||
|
|
@@ -624,8 +628,9 @@ def pytest_runtest_logreport(self, report): | |
| ) | ||
|
|
||
| def pytest_configure_node(self, node): | ||
| """Configure xdist hook for node sock_port.""" | ||
| """Configure xdist hook with StatusDB connection details.""" | ||
| node.workerinput["sock_port"] = node.config.failures_db.sock_port | ||
| node.workerinput["statusdb_token"] = node.config.failures_db.token | ||
|
|
||
| def pytest_handlecrashitem(self, crashitem, report, sched): | ||
| """Return the crashitem from pending and collection.""" | ||
|
|
@@ -762,6 +767,7 @@ def _sock_send(self, conn, msg: str): | |
| class ServerStatusDB(SocketDB): | ||
| def __init__(self) -> None: | ||
| super().__init__() | ||
| self.token = secrets.token_hex(32) | ||
| self.sock.bind(("127.0.0.1", 0)) | ||
| self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | ||
|
|
||
|
|
@@ -781,7 +787,12 @@ def run_server(self): | |
| t.start() | ||
|
|
||
| def run_connection(self, conn): | ||
| with suppress(ConnectionError): | ||
| with conn, suppress(ConnectionError): | ||
| authenticated = secrets.compare_digest(self._sock_recv(conn), self.token) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-ASCII or invalid-UTF-8 handshake input kills this thread instead of rejecting the client.
Sending to stderr in the middle of the user's test run, and the client receives Since this handshake is exactly the code now expected to face arbitrary local input, it should compare raw bytes (recv the token undecoded and — Comment created by Claude |
||
| self._sock_send(conn, "1" if authenticated else "0") | ||
| if not authenticated: | ||
| return | ||
|
|
||
| while True: | ||
| op, i, k, v = self._sock_recv(conn).split("|") | ||
| if op == "set": | ||
|
|
@@ -848,9 +859,13 @@ def get_suite_reruns(self) -> int: | |
|
|
||
|
|
||
| class ClientStatusDB(SocketDB): | ||
| def __init__(self, sock_port): | ||
| def __init__(self, sock_port, token): | ||
| super().__init__() | ||
| self.sock.connect(("127.0.0.1", sock_port)) | ||
| self._sock_send(self.sock, token) | ||
| if self._sock_recv(self.sock) != "1": | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A worker can hang forever here (busy-spinning) if the server never answers the handshake.
That is now directly reachable: whenever the server-side The same EOF gap leaves a live spinning thread on the server side: connecting and immediately closing takes
— Comment created by Claude |
||
| self.sock.close() | ||
| raise ConnectionError("StatusDB authentication failed") | ||
|
|
||
| def _set(self, i: str, k: str, v: int): | ||
| self._sock_send(self.sock, "|".join(("set", i, k, str(v)))) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: the handshake buffers unboundedly before authentication, and connections are unlimited.
_sock_recvappends tobufwith no length cap, and every accepted connection gets its own daemon thread with no connection limit — both before any authentication has happened. Any local process can connect and stream bytes without a newline until the controller runs out of memory.Only a local DoS, but capping the pre-auth read at the known token length (64 hex chars) is nearly free and fits the threat model this PR is addressing.
— Comment created by Claude