Merge bitcoin/bitcoin#36169: http: Use SO_EXCLUSIVEADDRUSE on Windows

bcb09b3f4a qa: Verify HTTP listen port exclusivity (Hodlinator)
af65069fd1 windows: Use SO_EXCLUSIVEADDRUSE over SO_REUSEADDR (Hodlinator)

Pull request description:

  #### Problem

  `HTTPServer::BindAndStartListening()` unconditionally enables `SO_REUSEADDR` before binding the RPC listener. On Windows, a reuse-enabled listener does not reserve the port exclusively: another local process can request `SO_REUSEADDR` and bind to the same port (see https://learn.microsoft.com/en-us/windows/win32/winsock/using-so-reuseaddr-and-so-exclusiveaddruse).

  If the competing socket receives a new connection, it can capture the HTTP Basic `Authorization` header (including the cookie credential) and proxy or issue privileged RPC calls as the victim. This crosses a local-user boundary and can expose wallet-controlling RPC credentials.

  #### Fix

  Have Windows use `SO_EXCLUSIVEADDRUSE` instead which makes the port exclusive to the process which first requests it, while retaining the restart-friendly behavior which `SO_REUSEADDR` enabled. Abort if another process is already bound to the port.

  #### Further context & rationale

  This issue is new in our homegrown HTTP server implementation, since libevent had a guard against setting `SO_REUSEADDR` on Windows, see `evutil_make_listen_socket_reuseable()` d82464a277/evutil.c (L483). libevent does not reference `SO_EXCLUSIVEADDRUSE`.

  Why should we not just avoid `SO_REUSEADDR` on Windows and skip `SO_EXCLUSIVEADDRUSE` like the libevent approach?
  Because setting either option makes the process less prone to failing to bind to a port after having been restarted. Not sure why this wasn't an issue before, maybe the node startup was usually slow enough to time out the port before we tried to re-bind it on Windows.

  ---

  Discovered by Project Loupe.

ACKs for top commit:
  pinheadmz:
    ACK bcb09b3f4a
  sedited:
    utACK bcb09b3f4a
  jeanpablojp:
    tACK bcb09b3f4a

Tree-SHA512: 7f2362cc8399e8c4e95b27b39066d3e591b5aebfc2b562aba10786609456f526f562394818a3d1042d64dabf497548ffabf0757322cfb201454a134321108cf5
This commit is contained in:
merge-script
2026-09-06 10:35:43 +02:00
2 changed files with 25 additions and 0 deletions

View File

@@ -722,6 +722,17 @@ util::Expected<void, std::string> HTTPServer::BindAndStartListening(const CServi
NetworkErrorString(WSAGetLastError()))};
}
#ifdef WIN32
// Prevent another application from binding to the same address and port and
// intercepting RPC credentials.
// SO_REUSEADDR on Windows is non-exclusive so another process could bind to
// the same port.
if (sock->SetSockOpt(SOL_SOCKET, SO_EXCLUSIVEADDRUSE, &SOCKET_OPTION_TRUE, sizeof(SOCKET_OPTION_TRUE)) == SOCKET_ERROR) {
return util::Unexpected{strprintf("Cannot set SO_EXCLUSIVEADDRUSE on %s listen socket: %s",
to.ToStringAddrPort(),
NetworkErrorString(WSAGetLastError()))};
}
#else
// Allow binding if the port is still in TIME_WAIT state after
// the program was closed and restarted.
if (sock->SetSockOpt(SOL_SOCKET, SO_REUSEADDR, &SOCKET_OPTION_TRUE, sizeof(SOCKET_OPTION_TRUE)) == SOCKET_ERROR) {
@@ -730,6 +741,7 @@ util::Expected<void, std::string> HTTPServer::BindAndStartListening(const CServi
to.ToStringAddrPort(),
NetworkErrorString(WSAGetLastError()));
}
#endif
// some systems don't have IPV6_V6ONLY but are always v6only; others do have the option
// and enable it by default or not. Try to enable it, if possible.

View File

@@ -134,6 +134,7 @@ class HTTPBasicsTest (BitcoinTestFramework):
self.node.reuse_http_connections = False
self.check_default_connection()
self.check_socket_exclusivity()
self.check_keepalive_connection()
self.check_close_connection()
self.check_excessive_request_size()
@@ -174,6 +175,18 @@ class HTTPBasicsTest (BitcoinTestFramework):
assert conn.sock_closed()
def check_socket_exclusivity(self):
self.log.info("Checking that another process cannot bind the HTTP listen port")
url = urllib.parse.urlparse(self.node.url)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as competing_listener:
competing_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Ill-configured sockets permit port reuse unless the original
# listener requested exclusive address use.
assert_raises(
OSError,
lambda: competing_listener.bind((url.hostname, url.port)))
def check_keepalive_connection(self):
self.log.info("Checking keep-alive connection persistence")
conn = BitcoinHTTPConnection(self.node)