Skip to content

Commit d3eef24

Browse files
authored
fix(psycopg): prevent proxy deadlock on TLS 1.3 post-handshake messages (GoogleCloudPlatform#1494)
1 parent 44ca343 commit d3eef24

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

‎google/cloud/sql/connector/psycopg.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import contextlib
1516
import logging
1617
import os
1718
import platform
@@ -52,15 +53,26 @@ def _forward_bytes(
5253
dst: socket.socket,
5354
src_name: str,
5455
dst_name: str,
56+
*,
57+
nonblocking: bool = False,
5558
) -> bool:
5659
"""Read a chunk of bytes from src and send it to dst.
5760
5861
Returns:
5962
bool: True if bytes were forwarded, False on EOF or OSError.
6063
"""
6164
try:
62-
data = src.recv(_BUFFER_SIZE)
65+
if nonblocking:
66+
src.setblocking(False)
67+
try:
68+
data = src.recv(_BUFFER_SIZE)
69+
finally:
70+
if nonblocking:
71+
with contextlib.suppress(OSError):
72+
src.setblocking(True)
6373
except OSError as e:
74+
if nonblocking and isinstance(e, (ssl.SSLWantReadError, BlockingIOError)):
75+
return True
6476
logger.debug(f"Failed to receive from {src_name} socket: {e}")
6577
return False
6678
if not data:
@@ -118,7 +130,7 @@ def _proxy(local: socket.socket, remote: "ssl.SSLSocket") -> None:
118130
):
119131
return
120132
if key.data == "remote" and not _forward_bytes(
121-
remote, local, "remote", "local"
133+
remote, local, "remote", "local", nonblocking=True
122134
):
123135
return
124136
except OSError as e:

‎tests/unit/test_psycopg.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,3 +813,49 @@ def socket_side_effect(family, type, proto=0, fileno=None):
813813

814814
mock_local_sock.close.assert_called_once()
815815
assert mock_remote_sock.close.call_count >= 1
816+
817+
818+
def test_proxy_tls13_post_handshake_want_read() -> None:
819+
"""Test that _proxy does not deadlock or exit on SSLWantReadError (e.g. TLS 1.3 NewSessionTicket)."""
820+
local_client, local_server = socket.socketpair()
821+
remote_client, remote_server = socket.socketpair()
822+
823+
class MockTLS13SSLSocket:
824+
def __init__(self, sock: socket.socket) -> None:
825+
self._sock = sock
826+
self._want_read_once = True
827+
828+
def pending(self) -> int:
829+
return 0
830+
831+
def recv(self, bufsize: int, flags: int = 0) -> bytes:
832+
if self._want_read_once:
833+
self._want_read_once = False
834+
# Drain the 1-byte dummy ticket wakeup from the underlying socket
835+
self._sock.recv(1)
836+
raise ssl.SSLWantReadError()
837+
return self._sock.recv(bufsize, flags)
838+
839+
def __getattr__(self, name: str) -> Any:
840+
return getattr(self._sock, name)
841+
842+
wrapped_remote = MockTLS13SSLSocket(remote_client)
843+
844+
# Simulate TLS 1.3 NewSessionTicket arriving on remote BEFORE client sends StartupMessage
845+
remote_server.sendall(b"T")
846+
847+
proxy_thread = threading.Thread(
848+
target=_proxy, args=(local_server, wrapped_remote), daemon=True
849+
)
850+
proxy_thread.start()
851+
852+
# Client sends StartupMessage and receives response without deadlocking
853+
local_client.sendall(b"StartupMessage")
854+
assert remote_server.recv(1024) == b"StartupMessage"
855+
remote_server.sendall(b"AuthenticationOk")
856+
assert local_client.recv(1024) == b"AuthenticationOk"
857+
858+
local_client.close()
859+
proxy_thread.join(timeout=2.0)
860+
assert not proxy_thread.is_alive()
861+
remote_server.close()

0 commit comments

Comments
 (0)