From c3ead98c358e125550b581c9df11b2016520e7da Mon Sep 17 00:00:00 2001 From: Simin Yuan <281164480+simin-yuan@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:35:33 +0800 Subject: [PATCH 1/3] Fix socketpair() on platforms without _socket.socketpair Windows has no _socket.socketpair(), so socket.socketpair() falls back to a pure-Python implementation built on an AF_INET loopback pair. mocket replaced socket.socket and socket.socketpair, so that fallback either resolved to MocketSocket or raised AttributeError, and any asyncio event loop created inside Mocketizer() died before the test body ran. Build the pair from true_socket when the C function is missing, take the accepted descriptor through _accept() so it is not wrapped back into MocketSocket, and authenticate the peer the same way CPython's fallback does since the CVE-2024-3219 fix. socket.socket stays patched, so mocking is unchanged for everything except the loopback pair the event loop makes. Refs kiwicom/pytest-recording#212, kiwicom/pytest-recording#213. --- mocket/socket.py | 68 ++++++++++++++++++++++++++++- tests/test_socketpair.py | 92 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/test_socketpair.py diff --git a/mocket/socket.py b/mocket/socket.py index a23887e..13e3e2d 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -107,6 +107,69 @@ def mock_inet_pton(address_family: int, ip_string: str) -> bytes: return bytes("\x7f\x00\x00\x01", "utf-8") +def _loopback_socketpair( + family: int = socket.AF_INET, + socktype: int = socket.SOCK_STREAM, + proto: int = 0, +) -> tuple[socket.socket, socket.socket]: + """Build a connected pair of real sockets over the IPv4/IPv6 loopback. + + Windows has no ``_socket.socketpair()``; the standard library builds the + pair in Python instead. That code path resolves the module-level ``socket`` + name, which Mocket has already swapped for :class:`MocketSocket`, so the + pair is assembled here from ``true_socket`` to keep it out of the mock + layer. Only the loopback connection used by the event loop ends up here. + """ + if family == socket.AF_INET: + host = "127.0.0.1" + elif family == socket.AF_INET6: + host = "::1" + else: + raise ValueError( + f"Only AF_INET and AF_INET6 address families are supported: {family}" + ) + if socktype != socket.SOCK_STREAM: + raise ValueError(f"Only SOCK_STREAM socket type is supported: {socktype}") + if proto != 0: + raise ValueError(f"Only protocol zero is supported: {proto}") + + lsock = true_socket(family, socktype, proto) + try: + lsock.bind((host, 0)) + lsock.listen() + addr, port = lsock.getsockname()[:2] + csock = true_socket(family, socktype, proto) + try: + csock.setblocking(False) + with contextlib.suppress(BlockingIOError, InterruptedError): + csock.connect((addr, port)) + csock.setblocking(True) + # accept() would wrap the descriptor back into MocketSocket. + fd, _peer = lsock._accept() + ssock = true_socket(family, socktype, proto, fileno=fd) + except Exception: + csock.close() + raise + finally: + lsock.close() + + # Same check CPython added in its fallback for CVE-2024-3219: the listener + # is reachable from outside the process, so make sure the peer we accepted + # is our own csock and not somebody else that connected first. + try: + if ( + ssock.getsockname() != csock.getpeername() + or csock.getsockname() != ssock.getpeername() + ): + raise ConnectionError("Unexpected peer connection") + except Exception: + ssock.close() + csock.close() + raise + + return ssock, csock + + def mock_socketpair( *args: Any, **kwargs: Any, @@ -125,7 +188,10 @@ def mock_socketpair( """ import _socket - return _socket.socketpair(*args, **kwargs) + if hasattr(_socket, "socketpair"): + return _socket.socketpair(*args, **kwargs) + + return _loopback_socketpair(*args, **kwargs) class MocketSocket: diff --git a/tests/test_socketpair.py b/tests/test_socketpair.py new file mode 100644 index 0000000..2614775 --- /dev/null +++ b/tests/test_socketpair.py @@ -0,0 +1,92 @@ +import asyncio +import socket + +import pytest + +from mocket import Mocketizer +from mocket.socket import MocketSocket + + +def test_socketpair_returns_a_real_connected_pair(): + """socketpair() must keep working while Mocket is enabled. + + On Windows there is no C-level socketpair(), and the standard library + fallback resolves the module-level ``socket`` name, which Mocket has + swapped for MocketSocket. The pair therefore has to be built from the real + socket class. + """ + with Mocketizer(): + a, b = socket.socketpair() + try: + a.send(b"ping") + assert b.recv(4) == b"ping" + b.send(b"pong") + assert a.recv(4) == b"pong" + finally: + a.close() + b.close() + + +def test_asyncio_event_loop_can_be_created(): + """asyncio creates its self-pipe with socketpair() when a loop is built.""" + with Mocketizer(): + loop = asyncio.new_event_loop() + try: + assert loop is not None + finally: + loop.close() + + +def test_socketpair_does_not_disable_the_mock_layer(): + """The loopback pair must not leak past socketpair() into user sockets.""" + with Mocketizer(): + a, b = socket.socketpair() + a.close() + b.close() + + assert socket.socket is MocketSocket + assert not isinstance(a, MocketSocket) + + +def test_mock_socketpair_without_the_c_function(monkeypatch): + """Force the pure-Python path so POSIX CI covers it too.""" + import _socket + + monkeypatch.delattr(_socket, "socketpair", raising=False) + + with Mocketizer(): + a, b = socket.socketpair() + try: + a.send(b"ping") + assert b.recv(4) == b"ping" + finally: + a.close() + b.close() + + loop = asyncio.new_event_loop() + loop.close() + + +def test_loopback_socketpair_rejects_a_foreign_peer(monkeypatch): + """CVE-2024-3219: the listener is reachable, so the peer must be ours.""" + from mocket.socket import _loopback_socketpair + + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + attacker = socket.socket() + attacker.connect(listener.getsockname()) + hijacked, _ = listener.accept() + foreign_peer = hijacked.getpeername() + hijacked_fd = hijacked.detach() + + def fake_accept(self): + return hijacked_fd, foreign_peer + + monkeypatch.setattr(socket.socket, "_accept", fake_accept) + try: + with pytest.raises(ConnectionError, match="Unexpected peer connection"): + _loopback_socketpair() + finally: + listener.close() + attacker.close() From 49f8b59fc2af1f584aebc2aee812a33ec843cb01 Mon Sep 17 00:00:00 2001 From: Simin Yuan <281164480+simin-yuan@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:02:43 +0800 Subject: [PATCH 2/3] Fix foreign-peer test under gevent socket patching tests/test_http_gevent.py patches socket at import time, so the hijack has to be installed on mocket.socket.true_socket. Signed-off-by: Simin Yuan <281164480+simin-yuan@users.noreply.github.com> --- tests/test_socketpair.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/test_socketpair.py b/tests/test_socketpair.py index 2614775..c8902f8 100644 --- a/tests/test_socketpair.py +++ b/tests/test_socketpair.py @@ -68,22 +68,32 @@ def test_mock_socketpair_without_the_c_function(monkeypatch): def test_loopback_socketpair_rejects_a_foreign_peer(monkeypatch): - """CVE-2024-3219: the listener is reachable, so the peer must be ours.""" + """CVE-2024-3219: the listener is reachable, so the peer must be ours. + + The hijack has to be installed on ``mocket.socket.true_socket``, which is + the name ``_loopback_socketpair`` resolves. Patching ``socket.socket`` + instead only works while that name still points at the same class: the + gevent tests in this suite call ``monkey.patch_socket()`` at import time, + which leaves ``socket.socket`` bound to gevent's subclass while + ``true_socket`` keeps the original one, so the hijack silently missed. + """ + from mocket import socket as mocket_socket_module from mocket.socket import _loopback_socketpair - listener = socket.socket() + real = mocket_socket_module.true_socket + listener = real(socket.AF_INET, socket.SOCK_STREAM) listener.bind(("127.0.0.1", 0)) listener.listen(1) - attacker = socket.socket() + attacker = real(socket.AF_INET, socket.SOCK_STREAM) attacker.connect(listener.getsockname()) - hijacked, _ = listener.accept() - foreign_peer = hijacked.getpeername() + hijacked, foreign_peer = listener.accept() hijacked_fd = hijacked.detach() - def fake_accept(self): - return hijacked_fd, foreign_peer + class HijackedListener(real): + def _accept(self): + return hijacked_fd, foreign_peer - monkeypatch.setattr(socket.socket, "_accept", fake_accept) + monkeypatch.setattr(mocket_socket_module, "true_socket", HijackedListener) try: with pytest.raises(ConnectionError, match="Unexpected peer connection"): _loopback_socketpair() From 373984a4cf9d1a5ab66a39f23e856b08b0900acc Mon Sep 17 00:00:00 2001 From: Simin Yuan <281164480+simin-yuan@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:31:03 +0800 Subject: [PATCH 3/3] Fix the foreign-peer test on Linux: accept the real connection first _loopback_socketpair() connects with setblocking(False) -- the same trick CPython's own fallback uses -- and counts on the accept() that follows to wait for the handshake, because only then is getpeername() meaningful. The hijacked _accept() here returned a foreign peer without waiting, so on Linux csock was still in SYN_SENT when the CVE check called getpeername() and the test failed with ENOTCONN instead of the ConnectionError it is about. Windows connects to the loopback synchronously and never showed it. Drain the real connection inside the hijack, then close it. Signed-off-by: Simin Yuan <281164480+simin-yuan@users.noreply.github.com> --- tests/test_socketpair.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_socketpair.py b/tests/test_socketpair.py index c8902f8..1d636f6 100644 --- a/tests/test_socketpair.py +++ b/tests/test_socketpair.py @@ -91,6 +91,20 @@ def test_loopback_socketpair_rejects_a_foreign_peer(monkeypatch): class HijackedListener(real): def _accept(self): + # _loopback_socketpair() connects with setblocking(False), exactly + # like CPython's own fallback, and relies on accept() to wait for + # the handshake before the peer check calls getpeername(). Linux + # leaves that connect in SYN_SENT for a moment, so skipping the + # wait here makes getpeername() raise ENOTCONN instead of the + # ConnectionError this test is about. Drain the real connection + # first; its peer is nobody we care about. + # Socket._accept() is CPython-internal and missing from the type + # stubs, hence the ignore: it is the only way to bypass the + # override above without recursing through accept(). + real_fd, _ = real._accept(self) # type: ignore[attr-defined] + # Wrap it before closing: on Windows a socket descriptor is not + # an OS file descriptor, so os.close() would raise EBADF. + real(fileno=real_fd).close() return hijacked_fd, foreign_peer monkeypatch.setattr(mocket_socket_module, "true_socket", HijackedListener)