Skip to content

Commit 78752c9

Browse files
authored
Align SSL startup retries with libpq and fix rejection diagnostics (#1366)
An `ErrorResponse` to `SSLRequest` is currently reported as an SSL refusal and transport fallback only handles a narrow set of authorization errors. Follow libpq transport-selection behavior and improve the SSL exchange diagnostics. Be careful around trusting pre-TLS server text or exposing SQLSTATE (see CVE-2024-10977). Make it so that alternative transports in allow/prefer retries are only tried _before_ authentication succeeds, not after. While here, align asyncpg with libpq and require `ssl` to be set to a mandatory TLS mode (or explicit context) when `direct_tls` is specified. Fixes #1317. Closes #1346. Closes #1348.
1 parent 5bbd737 commit 78752c9

9 files changed

Lines changed: 782 additions & 271 deletions

File tree

‎asyncpg/_testbase/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ def setUpClass(cls):
402402
host = '127.0.0.1'
403403
cls.proxy = fuzzer.TCPFuzzingProxy(
404404
backend_host=host,
405-
backend_port=conn_spec['port'],
405+
backend_port=int(conn_spec['port']),
406406
)
407407
cls.proxy.start()
408408

‎asyncpg/_testbase/fuzzer.py‎

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77

88
import asyncio
9+
import contextlib
910
import socket
1011
import threading
1112
import typing
@@ -34,6 +35,7 @@ def __init__(self, *, listening_addr: str='127.0.0.1',
3435
self.connections = {}
3536
self.sock = None
3637
self.listen_task = None
38+
self.connection_factory = Connection
3739

3840
async def _wait(self, work):
3941
work_task = asyncio.ensure_future(work)
@@ -98,11 +100,13 @@ async def _main(self, started_event):
98100
try:
99101
await self.listen_task
100102
finally:
103+
tasks = list(self.connections.values())
101104
for c in list(self.connections):
102105
c.close()
103-
await asyncio.sleep(0.01)
106+
await asyncio.gather(*tasks, return_exceptions=True)
104107
if hasattr(self.loop, 'remove_reader'):
105-
self.loop.remove_reader(self.sock.fileno())
108+
with contextlib.suppress(NotImplementedError):
109+
self.loop.remove_reader(self.sock.fileno())
106110
self.sock.close()
107111

108112
async def listen(self):
@@ -119,7 +123,7 @@ async def listen(self):
119123
except StopServer:
120124
break
121125

122-
conn = Connection(client_sock, backend_sock, self)
126+
conn = self.connection_factory(client_sock, backend_sock, self)
123127
conn_task = self.loop.create_task(conn.handle())
124128
self.connections[conn] = conn_task
125129

@@ -141,13 +145,13 @@ def reset(self):
141145
self.restore_connectivity()
142146

143147
def _close_connection(self, connection):
144-
conn_task = self.connections.pop(connection, None)
148+
conn_task = self.connections.get(connection)
145149
if conn_task is not None:
146150
conn_task.cancel()
147151

148152
def close_all_connections(self):
149153
for conn in list(self.connections):
150-
self.loop.call_soon_threadsafe(self._close_connection, conn)
154+
self.loop.call_soon_threadsafe(conn.close)
151155

152156

153157
class Connection:
@@ -161,6 +165,27 @@ def __init__(self, client_sock, backend_sock, proxy):
161165
self.proxy_to_backend_task = None
162166
self.proxy_from_backend_task = None
163167
self.is_closed = False
168+
self.client_reader = None
169+
self.client_writer = None
170+
171+
async def prepare(self):
172+
"""Optionally negotiate or inject faults before forwarding bytes."""
173+
return True
174+
175+
async def use_client_stream(self, ssl_context=None):
176+
"""Wrap the accepted socket in a stream, optionally with direct TLS."""
177+
self.client_reader = asyncio.StreamReader()
178+
protocol = asyncio.StreamReaderProtocol(self.client_reader)
179+
transport, _ = await self.loop.connect_accepted_socket(
180+
lambda: protocol, self.client_sock, ssl=ssl_context)
181+
self.client_writer = asyncio.StreamWriter(
182+
transport, protocol, self.client_reader, self.loop)
183+
184+
async def start_tls(self, ssl_context):
185+
writer = self.client_writer
186+
writer._transport = await self.loop.start_tls(
187+
writer.transport, writer._protocol, ssl_context, server_side=True)
188+
writer._protocol._over_ssl = True
164189

165190
def close(self):
166191
if self.is_closed:
@@ -170,48 +195,72 @@ def close(self):
170195

171196
if self.proxy_to_backend_task is not None:
172197
self.proxy_to_backend_task.cancel()
173-
self.proxy_to_backend_task = None
174198

175199
if self.proxy_from_backend_task is not None:
176200
self.proxy_from_backend_task.cancel()
177-
self.proxy_from_backend_task = None
178201

179202
self.proxy._close_connection(self)
180203

181204
async def handle(self):
182-
self.proxy_to_backend_task = asyncio.ensure_future(
183-
self.proxy_to_backend())
205+
try:
206+
if not await self.prepare():
207+
return
184208

185-
self.proxy_from_backend_task = asyncio.ensure_future(
186-
self.proxy_from_backend())
209+
self.proxy_to_backend_task = asyncio.ensure_future(
210+
self.proxy_to_backend())
211+
self.proxy_from_backend_task = asyncio.ensure_future(
212+
self.proxy_from_backend())
187213

188-
try:
189214
await asyncio.wait(
190215
[self.proxy_to_backend_task, self.proxy_from_backend_task],
191216
return_when=asyncio.FIRST_COMPLETED)
192217

218+
except ConnectionError:
219+
pass
220+
193221
finally:
222+
# Relay completion can schedule close() while cleanup is awaiting
223+
# its children. Do not let that callback cancel cleanup itself.
224+
self.is_closed = True
194225
if self.proxy_to_backend_task is not None:
195226
self.proxy_to_backend_task.cancel()
196227

197228
if self.proxy_from_backend_task is not None:
198229
self.proxy_from_backend_task.cancel()
199230

231+
await asyncio.gather(*(
232+
task for task in (self.proxy_to_backend_task,
233+
self.proxy_from_backend_task)
234+
if task is not None
235+
), return_exceptions=True)
236+
200237
# Asyncio fails to properly remove the readers and writers
201238
# when the task doing recv() or send() is cancelled, so
202239
# we must remove the readers and writers manually before
203240
# closing the sockets.
204-
self.loop.remove_reader(self.client_sock.fileno())
205-
self.loop.remove_writer(self.client_sock.fileno())
206-
self.loop.remove_reader(self.backend_sock.fileno())
207-
self.loop.remove_writer(self.backend_sock.fileno())
208-
209-
self.client_sock.close()
210-
self.backend_sock.close()
241+
sockets = [self.backend_sock]
242+
if self.client_writer is None:
243+
sockets.append(self.client_sock)
244+
else:
245+
self.client_writer.close()
246+
with contextlib.suppress(ConnectionError):
247+
await self.client_writer.wait_closed()
248+
for sock in sockets:
249+
if sock.fileno() < 0:
250+
continue
251+
# ProactorEventLoop has no reader/writer registration API.
252+
with contextlib.suppress(NotImplementedError):
253+
self.loop.remove_reader(sock.fileno())
254+
self.loop.remove_writer(sock.fileno())
255+
sock.close()
256+
self.proxy.connections.pop(self, None)
211257

212258
async def _read(self, sock, n):
213-
read_task = asyncio.ensure_future(
214-
self.loop.sock_recv(sock, n))
259+
if sock is self.client_sock and self.client_reader is not None:
260+
read = self.client_reader.read(n)
261+
else:
262+
read = self.loop.sock_recv(sock, n)
263+
read_task = asyncio.ensure_future(read)
215264
conn_event_task = asyncio.ensure_future(
216265
self.connectivity_loss.wait())
217266

@@ -230,10 +279,16 @@ async def _read(self, sock, n):
230279
read_task.cancel()
231280
if not conn_event_task.done():
232281
conn_event_task.cancel()
282+
await asyncio.gather(read_task, conn_event_task,
283+
return_exceptions=True)
233284

234285
async def _write(self, sock, data):
235-
write_task = asyncio.ensure_future(
236-
self.loop.sock_sendall(sock, data))
286+
if sock is self.client_sock and self.client_writer is not None:
287+
self.client_writer.write(data)
288+
write = self.client_writer.drain()
289+
else:
290+
write = self.loop.sock_sendall(sock, data)
291+
write_task = asyncio.ensure_future(write)
237292
conn_event_task = asyncio.ensure_future(
238293
self.connectivity_loss.wait())
239294

@@ -252,6 +307,8 @@ async def _write(self, sock, data):
252307
write_task.cancel()
253308
if not conn_event_task.done():
254309
conn_event_task.cancel()
310+
await asyncio.gather(write_task, conn_event_task,
311+
return_exceptions=True)
255312

256313
async def proxy_to_backend(self):
257314
buf = None

0 commit comments

Comments
 (0)