Skip to content

Commit cd5a130

Browse files
committed
fix(serverless): close pinned SSRF sessions to prevent FD leaks
Address PR #533 review feedback. - Tie each single-use SyncClientSession's lifecycle to its response via _bind_session_to_response, so closing the response (with-block or explicit) also closes the session; without it the session's pools/FDs leaked once per download in long-lived workers. - Close the session on redirect hops and on request exceptions; make teardown best-effort so a cleanup failure cannot mask the caller's real error. - Close response and session deterministically in the pinned-IP adapter test. - Add tests for the request-exception path and the error-masking guarantee.
1 parent 346bc52 commit cd5a130

2 files changed

Lines changed: 114 additions & 13 deletions

File tree

‎runpod/serverless/utils/rp_ssrf.py‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import ipaddress
1414
import os
1515
import socket
16+
from contextlib import suppress
1617
from typing import Iterator, List, Optional
1718
from urllib.parse import urljoin, urlparse
1819

@@ -189,6 +190,42 @@ def _build_pinned_session(pinned_ip: str) -> SyncClientSession:
189190
return session
190191

191192

193+
def _close_session_quietly(session: SyncClientSession) -> None:
194+
"""
195+
Close a pinned session best-effort, tolerating teardown errors.
196+
197+
This runs only on cleanup paths (a failed request, a closing response) where
198+
the caller's original exception is the meaningful one. A failure while
199+
closing the session's connection pools must not mask that exception, so it is
200+
suppressed rather than raised. Session.close() is idempotent and does no
201+
actionable I/O, so nothing worth surfacing is lost.
202+
"""
203+
with suppress(Exception):
204+
session.close()
205+
206+
207+
def _bind_session_to_response(response, session: SyncClientSession) -> None:
208+
"""
209+
Tie `session` cleanup to `response.close()`.
210+
211+
Each safe_get() call owns a single-use session; the caller only holds the
212+
response. Closing a requests.Response releases its connection but not the
213+
session (its adapters and connection pools), so without this the session
214+
leaks one set of pooled sockets/FDs per download in a long-lived worker.
215+
Wrapping close() makes `with safe_get(...) as r:` (and any direct
216+
response.close()) tear down both.
217+
"""
218+
original_close = response.close
219+
220+
def close_both(*args, **kwargs):
221+
try:
222+
original_close(*args, **kwargs)
223+
finally:
224+
_close_session_quietly(session)
225+
226+
response.close = close_both
227+
228+
192229
def _enforce_content_length(response, max_bytes: Optional[int]) -> None:
193230
if max_bytes is None:
194231
return
@@ -254,22 +291,32 @@ def safe_get(
254291
ips = resolve_and_validate(parsed.hostname, port)
255292

256293
session = _build_pinned_session(ips[0])
257-
response = session.get(
258-
current,
259-
headers=headers,
260-
allow_redirects=False,
261-
stream=stream,
262-
timeout=timeout,
263-
)
294+
try:
295+
response = session.get(
296+
current,
297+
headers=headers,
298+
allow_redirects=False,
299+
stream=stream,
300+
timeout=timeout,
301+
)
302+
except BaseException:
303+
_close_session_quietly(session)
304+
raise
264305

265306
if response.status_code in _REDIRECT_STATUS:
266307
location = response.headers.get("Location")
267-
response.close()
308+
try:
309+
response.close()
310+
finally:
311+
_close_session_quietly(session)
268312
if not location:
269313
raise SSRFError(f"redirect without Location header: {current}")
270314
current = urljoin(current, location)
271315
continue
272316

317+
# Bind before the cap check so a Content-Length breach (which closes
318+
# the response) also tears down the session.
319+
_bind_session_to_response(response, session)
273320
_enforce_content_length(response, max_bytes)
274321
return response
275322

‎tests/test_serverless/test_utils/test_ssrf.py‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
class _FakeResponse:
2424
"""Minimal stand-in for requests.Response used by safe_get."""
2525

26-
def __init__(self, status_code, headers=None):
26+
def __init__(self, status_code, headers=None, close_exc=None):
2727
self.status_code = status_code
2828
self.headers = headers or {}
2929
self.closed = False
30+
self._close_exc = close_exc
3031

3132
def close(self):
3233
self.closed = True
34+
if self._close_exc is not None:
35+
raise self._close_exc
3336

3437

3538
def _addrinfo(*ips):
@@ -145,6 +148,8 @@ def resolve(host, _port):
145148
with self.assertRaises(SSRFError):
146149
safe_get("https://example.com/image.jpg")
147150

151+
session.close.assert_called_once() # redirect hop must not leak its session
152+
148153
@patch("runpod.serverless.utils.rp_ssrf._build_pinned_session")
149154
@patch("runpod.serverless.utils.rp_ssrf.resolve_and_validate", return_value=["93.184.216.34"])
150155
def test_too_many_redirects(self, _mock_resolve, mock_session):
@@ -167,6 +172,8 @@ def test_content_length_over_cap_rejected(self, _mock_resolve, mock_session):
167172
with self.assertRaises(SSRFError):
168173
safe_get("https://example.com/big.bin", max_bytes=1024)
169174

175+
session.close.assert_called_once() # cap breach must close the pinned session
176+
170177
@patch("runpod.serverless.utils.rp_ssrf._build_pinned_session")
171178
@patch("runpod.serverless.utils.rp_ssrf.resolve_and_validate", return_value=["93.184.216.34"])
172179
def test_returns_response_on_success(self, _mock_resolve, mock_session):
@@ -177,6 +184,52 @@ def test_returns_response_on_success(self, _mock_resolve, mock_session):
177184

178185
self.assertIs(safe_get("https://example.com/ok.jpg", max_bytes=4096), response)
179186

187+
@patch("runpod.serverless.utils.rp_ssrf._build_pinned_session")
188+
@patch("runpod.serverless.utils.rp_ssrf.resolve_and_validate", return_value=["93.184.216.34"])
189+
def test_closing_response_closes_session(self, _mock_resolve, mock_session):
190+
"""The session outlives safe_get() only until the caller closes the response."""
191+
response = _FakeResponse(200, {"Content-Length": "500"})
192+
session = MagicMock()
193+
session.get.return_value = response
194+
mock_session.return_value = session
195+
196+
result = safe_get("https://example.com/ok.jpg", max_bytes=4096)
197+
session.close.assert_not_called() # still open for the caller to stream
198+
199+
result.close()
200+
self.assertTrue(response.closed) # original close still runs
201+
session.close.assert_called_once() # and the session is torn down with it
202+
203+
@patch("runpod.serverless.utils.rp_ssrf._build_pinned_session")
204+
@patch("runpod.serverless.utils.rp_ssrf.resolve_and_validate", return_value=["93.184.216.34"])
205+
def test_request_error_closes_session(self, _mock_resolve, mock_session):
206+
"""A failed GET must tear down the session before the error propagates."""
207+
session = MagicMock()
208+
session.get.side_effect = RequestException("boom")
209+
mock_session.return_value = session
210+
211+
with self.assertRaises(RequestException):
212+
safe_get("https://example.com/x")
213+
214+
session.close.assert_called_once()
215+
216+
@patch("runpod.serverless.utils.rp_ssrf._build_pinned_session")
217+
@patch("runpod.serverless.utils.rp_ssrf.resolve_and_validate", return_value=["93.184.216.34"])
218+
def test_session_close_error_does_not_mask_response_error(self, _mock_resolve, mock_session):
219+
"""Cleanup is best-effort: the caller's close error wins over a session-teardown error."""
220+
response = _FakeResponse(200, {"Content-Length": "500"}, close_exc=RuntimeError("primary"))
221+
session = MagicMock()
222+
session.get.return_value = response
223+
session.close.side_effect = RuntimeError("secondary pool teardown")
224+
mock_session.return_value = session
225+
226+
result = safe_get("https://example.com/ok.jpg", max_bytes=4096)
227+
with self.assertRaises(RuntimeError) as ctx:
228+
result.close()
229+
230+
self.assertEqual(str(ctx.exception), "primary") # response error surfaces
231+
session.close.assert_called_once() # teardown was still attempted
232+
180233

181234
class TestIterContentCapped(unittest.TestCase):
182235
"""Streaming byte counter aborts past the cap even when headers lie."""
@@ -232,10 +285,11 @@ def test_dials_pinned_ip_and_preserves_host_header(self):
232285
thread.start()
233286
try:
234287
# URL host is example.com (won't route to our server); pin to loopback.
235-
session = _build_pinned_session("127.0.0.1")
236-
response = session.get(f"http://example.com:{port}/path", timeout=5)
237-
self.assertEqual(response.status_code, 200)
238-
self.assertEqual(response.content, b"ok")
288+
with _build_pinned_session("127.0.0.1") as session, session.get(
289+
f"http://example.com:{port}/path", timeout=5
290+
) as response:
291+
self.assertEqual(response.status_code, 200)
292+
self.assertEqual(response.content, b"ok")
239293
self.assertEqual(_RecordingHandler.received_host, f"example.com:{port}")
240294
finally:
241295
server.shutdown()

0 commit comments

Comments
 (0)