Skip to content

Commit dea9d5e

Browse files
authored
Fix socketpair() on platforms without _socket.socketpair (#334)
* 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. * 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> * 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> * Test the error paths of the loopback socketpair fallback The fallback only had its happy path covered, so the checks that guard the loopback pair had nothing behind them: - an address family other than AF_INET/AF_INET6, a socket type other than SOCK_STREAM and a non-zero protocol are refused before any socket is created; - when accept() fails after the client has connected, the client half is closed instead of leaked; - when the peer check added for CVE-2024-3219 rejects the connection, both halves are closed; - _socket.socketpair() is used as is when the interpreter has it. The IPv6 case connects over ::1 and is skipped where IPv6 loopback is not available. Signed-off-by: Simin Yuan <281164480+simin-yuan@users.noreply.github.com> --------- Signed-off-by: Simin Yuan <281164480+simin-yuan@users.noreply.github.com>
1 parent 17812a9 commit dea9d5e

2 files changed

Lines changed: 276 additions & 1 deletion

File tree

‎mocket/socket.py‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,69 @@ def mock_inet_pton(address_family: int, ip_string: str) -> bytes:
107107
return bytes("\x7f\x00\x00\x01", "utf-8")
108108

109109

110+
def _loopback_socketpair(
111+
family: int = socket.AF_INET,
112+
socktype: int = socket.SOCK_STREAM,
113+
proto: int = 0,
114+
) -> tuple[socket.socket, socket.socket]:
115+
"""Build a connected pair of real sockets over the IPv4/IPv6 loopback.
116+
117+
Windows has no ``_socket.socketpair()``; the standard library builds the
118+
pair in Python instead. That code path resolves the module-level ``socket``
119+
name, which Mocket has already swapped for :class:`MocketSocket`, so the
120+
pair is assembled here from ``true_socket`` to keep it out of the mock
121+
layer. Only the loopback connection used by the event loop ends up here.
122+
"""
123+
if family == socket.AF_INET:
124+
host = "127.0.0.1"
125+
elif family == socket.AF_INET6:
126+
host = "::1"
127+
else:
128+
raise ValueError(
129+
f"Only AF_INET and AF_INET6 address families are supported: {family}"
130+
)
131+
if socktype != socket.SOCK_STREAM:
132+
raise ValueError(f"Only SOCK_STREAM socket type is supported: {socktype}")
133+
if proto != 0:
134+
raise ValueError(f"Only protocol zero is supported: {proto}")
135+
136+
lsock = true_socket(family, socktype, proto)
137+
try:
138+
lsock.bind((host, 0))
139+
lsock.listen()
140+
addr, port = lsock.getsockname()[:2]
141+
csock = true_socket(family, socktype, proto)
142+
try:
143+
csock.setblocking(False)
144+
with contextlib.suppress(BlockingIOError, InterruptedError):
145+
csock.connect((addr, port))
146+
csock.setblocking(True)
147+
# accept() would wrap the descriptor back into MocketSocket.
148+
fd, _peer = lsock._accept()
149+
ssock = true_socket(family, socktype, proto, fileno=fd)
150+
except Exception:
151+
csock.close()
152+
raise
153+
finally:
154+
lsock.close()
155+
156+
# Same check CPython added in its fallback for CVE-2024-3219: the listener
157+
# is reachable from outside the process, so make sure the peer we accepted
158+
# is our own csock and not somebody else that connected first.
159+
try:
160+
if (
161+
ssock.getsockname() != csock.getpeername()
162+
or csock.getsockname() != ssock.getpeername()
163+
):
164+
raise ConnectionError("Unexpected peer connection")
165+
except Exception:
166+
ssock.close()
167+
csock.close()
168+
raise
169+
170+
return ssock, csock
171+
172+
110173
def mock_socketpair(
111174
*args: Any,
112175
**kwargs: Any,
@@ -125,7 +188,10 @@ def mock_socketpair(
125188
"""
126189
import _socket
127190

128-
return _socket.socketpair(*args, **kwargs)
191+
if hasattr(_socket, "socketpair"):
192+
return _socket.socketpair(*args, **kwargs)
193+
194+
return _loopback_socketpair(*args, **kwargs)
129195

130196

131197
class MocketSocket:

‎tests/test_socketpair.py‎

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import asyncio
2+
import socket
3+
4+
import pytest
5+
6+
from mocket import Mocketizer
7+
from mocket.socket import MocketSocket, _loopback_socketpair, mock_socketpair
8+
9+
10+
def test_socketpair_returns_a_real_connected_pair():
11+
"""socketpair() must keep working while Mocket is enabled.
12+
13+
On Windows there is no C-level socketpair(), and the standard library
14+
fallback resolves the module-level ``socket`` name, which Mocket has
15+
swapped for MocketSocket. The pair therefore has to be built from the real
16+
socket class.
17+
"""
18+
with Mocketizer():
19+
a, b = socket.socketpair()
20+
try:
21+
a.send(b"ping")
22+
assert b.recv(4) == b"ping"
23+
b.send(b"pong")
24+
assert a.recv(4) == b"pong"
25+
finally:
26+
a.close()
27+
b.close()
28+
29+
30+
def test_asyncio_event_loop_can_be_created():
31+
"""asyncio creates its self-pipe with socketpair() when a loop is built."""
32+
with Mocketizer():
33+
loop = asyncio.new_event_loop()
34+
try:
35+
assert loop is not None
36+
finally:
37+
loop.close()
38+
39+
40+
def test_socketpair_does_not_disable_the_mock_layer():
41+
"""The loopback pair must not leak past socketpair() into user sockets."""
42+
with Mocketizer():
43+
a, b = socket.socketpair()
44+
a.close()
45+
b.close()
46+
47+
assert socket.socket is MocketSocket
48+
assert not isinstance(a, MocketSocket)
49+
50+
51+
def test_mock_socketpair_without_the_c_function(monkeypatch):
52+
"""Force the pure-Python path so POSIX CI covers it too."""
53+
import _socket
54+
55+
monkeypatch.delattr(_socket, "socketpair", raising=False)
56+
57+
with Mocketizer():
58+
a, b = socket.socketpair()
59+
try:
60+
a.send(b"ping")
61+
assert b.recv(4) == b"ping"
62+
finally:
63+
a.close()
64+
b.close()
65+
66+
loop = asyncio.new_event_loop()
67+
loop.close()
68+
69+
70+
def test_loopback_socketpair_rejects_a_foreign_peer(monkeypatch):
71+
"""CVE-2024-3219: the listener is reachable, so the peer must be ours.
72+
73+
The hijack has to be installed on ``mocket.socket.true_socket``, which is
74+
the name ``_loopback_socketpair`` resolves. Patching ``socket.socket``
75+
instead only works while that name still points at the same class: the
76+
gevent tests in this suite call ``monkey.patch_socket()`` at import time,
77+
which leaves ``socket.socket`` bound to gevent's subclass while
78+
``true_socket`` keeps the original one, so the hijack silently missed.
79+
"""
80+
from mocket import socket as mocket_socket_module
81+
from mocket.socket import _loopback_socketpair
82+
83+
real = mocket_socket_module.true_socket
84+
listener = real(socket.AF_INET, socket.SOCK_STREAM)
85+
listener.bind(("127.0.0.1", 0))
86+
listener.listen(1)
87+
attacker = real(socket.AF_INET, socket.SOCK_STREAM)
88+
attacker.connect(listener.getsockname())
89+
hijacked, foreign_peer = listener.accept()
90+
hijacked_fd = hijacked.detach()
91+
92+
class HijackedListener(real):
93+
def _accept(self):
94+
# _loopback_socketpair() connects with setblocking(False), exactly
95+
# like CPython's own fallback, and relies on accept() to wait for
96+
# the handshake before the peer check calls getpeername(). Linux
97+
# leaves that connect in SYN_SENT for a moment, so skipping the
98+
# wait here makes getpeername() raise ENOTCONN instead of the
99+
# ConnectionError this test is about. Drain the real connection
100+
# first; its peer is nobody we care about.
101+
# Socket._accept() is CPython-internal and missing from the type
102+
# stubs, hence the ignore: it is the only way to bypass the
103+
# override above without recursing through accept().
104+
real_fd, _ = real._accept(self) # type: ignore[attr-defined]
105+
# Wrap it before closing: on Windows a socket descriptor is not
106+
# an OS file descriptor, so os.close() would raise EBADF.
107+
real(fileno=real_fd).close()
108+
return hijacked_fd, foreign_peer
109+
110+
monkeypatch.setattr(mocket_socket_module, "true_socket", HijackedListener)
111+
try:
112+
with pytest.raises(ConnectionError, match="Unexpected peer connection"):
113+
_loopback_socketpair()
114+
finally:
115+
listener.close()
116+
attacker.close()
117+
118+
119+
def test_mock_socketpair_prefers_the_c_implementation(monkeypatch):
120+
"""When the interpreter has _socket.socketpair() there is nothing to build."""
121+
import _socket
122+
123+
sentinel = (object(), object())
124+
monkeypatch.setattr(
125+
_socket, "socketpair", lambda *args, **kwargs: sentinel, raising=False
126+
)
127+
128+
assert mock_socketpair() is sentinel
129+
130+
131+
def test_loopback_socketpair_works_over_ipv6():
132+
"""AF_INET6 is served from ::1; the pair must be just as usable as IPv4."""
133+
probe = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
134+
try:
135+
probe.bind(("::1", 0))
136+
except OSError:
137+
pytest.skip("IPv6 loopback is not available")
138+
finally:
139+
probe.close()
140+
141+
a, b = _loopback_socketpair(socket.AF_INET6)
142+
try:
143+
a.send(b"ping")
144+
assert b.recv(4) == b"ping"
145+
finally:
146+
a.close()
147+
b.close()
148+
149+
150+
@pytest.mark.parametrize(
151+
("kwargs", "message"),
152+
[
153+
({"family": socket.AF_UNSPEC}, "Only AF_INET and AF_INET6 address families"),
154+
({"socktype": socket.SOCK_DGRAM}, "Only SOCK_STREAM socket type"),
155+
({"proto": 6}, "Only protocol zero"),
156+
],
157+
)
158+
def test_loopback_socketpair_rejects_unsupported_options(kwargs, message):
159+
"""Whatever the loopback fallback cannot honour is refused before binding."""
160+
with pytest.raises(ValueError, match=message):
161+
_loopback_socketpair(**kwargs)
162+
163+
164+
def test_loopback_socketpair_closes_the_client_when_accept_fails(monkeypatch):
165+
"""A failing accept() must not leak the socket that already connected."""
166+
from mocket import socket as mocket_socket_module
167+
168+
real = mocket_socket_module.true_socket
169+
created = []
170+
171+
class FailingListener(real):
172+
def __init__(self, *args, **kwargs):
173+
super().__init__(*args, **kwargs)
174+
created.append(self)
175+
176+
def _accept(self):
177+
raise OSError("accept() failed")
178+
179+
monkeypatch.setattr(mocket_socket_module, "true_socket", FailingListener)
180+
with pytest.raises(OSError, match="accept"):
181+
_loopback_socketpair()
182+
183+
_listener, client = created
184+
assert client.fileno() == -1
185+
186+
187+
def test_loopback_socketpair_closes_both_sockets_on_a_peer_mismatch(monkeypatch):
188+
"""CVE-2024-3219: when the peer is not ours, both halves are closed."""
189+
from mocket import socket as mocket_socket_module
190+
191+
real = mocket_socket_module.true_socket
192+
created = []
193+
194+
class DoctoredPeers(real):
195+
def __init__(self, *args, **kwargs):
196+
super().__init__(*args, **kwargs)
197+
created.append(self)
198+
199+
def getpeername(self):
200+
host, port = super().getpeername()
201+
return host, port + 1
202+
203+
monkeypatch.setattr(mocket_socket_module, "true_socket", DoctoredPeers)
204+
with pytest.raises(ConnectionError, match="Unexpected peer connection"):
205+
_loopback_socketpair()
206+
207+
_listener, client, accepted = created
208+
assert client.fileno() == -1
209+
assert accepted.fileno() == -1

0 commit comments

Comments
 (0)