fix(serverless): block SSRF in job-input downloads - #533
Conversation
|
Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews. |
|
Promptless prepared a documentation update related to this change. Triggered by PR #533 (block SSRF in job-input downloads) Adds a new docs page for the Serverless SDK download helper ( Review: Document SSRF safeguards and env vars for Serverless job-input downloads |
There was a problem hiding this comment.
Pull request overview
This PR hardens serverless job-input URL downloads against SSRF by introducing a centralized “safe fetch” path and routing existing download utilities through it, preventing access to link-local/loopback/private/reserved destinations (notably instance metadata at 169.254.169.254) while also enforcing download size limits.
Changes:
- Added
runpod/serverless/utils/rp_ssrf.pyproviding scheme allowlisting, DNS resolution + global-routability validation, DNS-rebind-resistant pinned-IP dialing, redirect re-validation, and download size caps. - Updated
runpod/serverless/utils/rp_download.pyto usesafe_get()and to stream responses to disk withiter_content_capped()rather than buffering full bodies in memory. - Added SSRF-focused test coverage and updated download tests to mock
safe_get()and make concurrent assertions order-independent.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
runpod/serverless/utils/rp_ssrf.py |
New SSRF-safe fetch implementation (validation, pinning, redirects, size caps). |
runpod/serverless/utils/rp_download.py |
Routes downloads through safe_get() and streams to disk with size enforcement. |
tests/test_serverless/test_utils/test_ssrf.py |
New unit tests for address validation, redirects, size caps, and pinned-IP behavior. |
tests/test_serverless/test_utils/test_download.py |
Updates tests to mock safe_get() and handle concurrent call ordering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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.
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.
64596af to
6d4098b
Compare
|
doesn't the container runtime already do this? |
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.
6d4098b to
8382a63
Compare
|
plus any attacker could just simply not use this library or use any other library of their choice so if this was a security hole this wouldn't be possible to patch in runpod-python. |
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.
8382a63 to
cd5a130
Compare
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.
66ed95d to
34d5558
Compare
Job input carries arbitrary URLs that the worker downloads. Without restriction a job can point the worker at cloud instance-metadata (169.254.169.254) or other non-public addresses (CWE-918). Add runpod/serverless/utils/rp_ssrf.py: an http(s) scheme allowlist, DNS resolution that fails closed on any non-global IP (link-local, loopback, RFC1918, CGNAT, reserved, multicast, IPv6 ULA), a connection adapter that pins the socket to the validated IP to defeat DNS rebinding, per-hop redirect re-validation, and a streamed size cap. Route both rp_download fetch sites through it, and stream file() to disk instead of buffering the whole untrusted body in memory. Blocks raise SSRFError (a ValueError, not a RequestException) so they fail loudly rather than being retried or silently dropped. Configurable via RUNPOD_ALLOW_PRIVATE_DOWNLOAD_URLS (escape hatch, off by default) and RUNPOD_MAX_DOWNLOAD_BYTES (default 5 GiB).
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.
34d5558 to
f0ca9f9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
runpod/serverless/utils/rp_download.py:145
- Parsing Content-Length with int(...) can raise ValueError/TypeError for missing or non-integer headers, which would abort file() even though streaming + capped reads can otherwise proceed safely. Parse defensively and fall back to 0 for chunk sizing.
file_size = int(download_response.headers.get("Content-Length", 0))
chunk_size = calculate_chunk_size(file_size)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
tests/test_serverless/test_utils/test_download.py:178
- These file() tests call runpod.serverless.utils.rp_download.file(), which unconditionally creates the "job_files" directory via os.makedirs(). Because os.makedirs is not patched here, the unit test suite writes to the real filesystem, which can make tests less hermetic/flaky. Patch os.makedirs in this test (or wrap the call in a patch context) to avoid side effects.
@patch("runpod.serverless.utils.rp_download.safe_get")
@patch("builtins.open", new_callable=mock_open)
def test_download_file_with_content_disposition(self, mock_file, mock_get):
"""
Tests file() using filename from Content-Disposition
"""
mock_get.return_value = MockResponse(
b"file content", 200, {"Content-Disposition": 'inline; filename="test_file.txt"'}
)
result = file("http://test.com/file_without_extension")
Content-Length is remote-controlled, so int() on it can raise ValueError and abort an otherwise valid download. In file() this was a new failure path (main never parsed the header there); in download_files_from_urls the ValueError also escaped both the backoff retry and the RequestException handler, failing the whole batch. Parse defensively via parse_content_length(), falling back to 0 so chunk sizing still works. The size limit is unaffected: it is enforced while streaming by iter_content_capped().
Pinning to ips[0] turned a multi-address host into a hard failure when the first address is unroutable from the worker: a dual-stack host whose AAAA sorts first is unreachable on a pod with no IPv6 route, where plain requests would have tried the A record. Walk the validated addresses in resolution order, advancing only on connection errors, so every candidate stays pre-validated while the fallback behavior is preserved. Also patch os.makedirs in the file() tests, which were creating a real job_files/ directory in the working tree.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
runpod/serverless/utils/rp_ssrf.py:162
- PinnedIPAdapter.send() builds the Host header from parsed.hostname, but urllib.parse strips IPv6 brackets. For URLs with an IPv6 literal host (e.g. http://[2001:db8::1]:8080/), this code would emit an invalid Host header like "2001:db8::1:8080" instead of "[2001:db8::1]:8080", which can break requests and any downstream host parsing.
parsed = urlparse(request.url)
authority = parsed.hostname or ""
if parsed.port is not None:
authority = f"{authority}:{parsed.port}"
request.headers["Host"] = authority
|
Two different threat models, and I think we're talking past each other on which one this patch is for. On the container runtime: it blocks RFC1918 egress from worker pods, but not link-local. 169.254.169.254 is reachable from a worker today, which is why cloud-metadata credential theft is the live unmitigated vector here. On "an attacker could just not use this library": agreed, and that case is out of reach of any SDK change. But the attacker in this threat model isn't the handler author — it's whoever submits the job. A third-party handler (Hub template, community worker) calls The malicious-container case you're describing is real and only the network layer can close it — that's the follow-up host-team ticket for adding 169.254.0.0/16 to the egress rules. Defense in depth, not a substitute. |
file() saved whatever body came back, so a 4xx/5xx error page was written as the downloaded file and, when the URL ended in .zip, handed to the extractor. download_files_from_urls already called raise_for_status(); file() never did. Behavior change: file() now raises requests.RequestException on an error status instead of returning a dict pointing at the error page. It has no in-tree callers and is not re-exported from serverless.utils, so this is limited to direct importers.
urlparse strips the brackets from an IPv6-literal URL host, so the adapter
built a malformed Host header ("2606::1:443" instead of "[2606::1]:443"),
which RFC 7230 requires and some servers reject. Extracted the authority
construction into _host_header_for() and covered every host form.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
runpod/serverless/utils/rp_download.py:137
- safe_get() implicitly uses max_download_bytes() when max_bytes is omitted, but the streaming loop later calls max_download_bytes() again. Capture max_bytes once and pass it into safe_get(max_bytes=...) so the declared Content-Length check and the streaming cap are guaranteed to use the same value.
os.makedirs("job_files", exist_ok=True)
with safe_get(file_url, stream=True, timeout=30, headers=HEADERS) as download_response:
# Fail on an error response rather than saving the error page as the
runpod/serverless/utils/rp_download.py:164
- The streaming cap in iter_content_capped() should use the same max_bytes value that was passed to safe_get(), rather than re-reading RUNPOD_MAX_DOWNLOAD_BYTES mid-function.
with open(output_file_path, "wb") as output_file:
for chunk in iter_content_capped(download_response, chunk_size, max_download_bytes()):
output_file.write(chunk)
runpod/serverless/utils/rp_download.py:80
- max_download_bytes() is evaluated separately for safe_get() (implicit default) and iter_content_capped(). If RUNPOD_MAX_DOWNLOAD_BYTES changes at runtime (or callers later pass a custom max_bytes), the header check and streaming cap can disagree. Capture the cap once and pass it to both safe_get(max_bytes=...) and iter_content_capped(...).
This issue also appears in the following locations of the same file:
- line 134
- line 162
@backoff.on_exception(backoff.expo, RequestException, max_tries=3)
def download_file(url: str, path_to_save: str) -> str:
with safe_get(url, stream=True, timeout=5, headers=HEADERS) as response:
response.raise_for_status()
content_disposition = response.headers.get("Content-Disposition")
Pinning only holds when this process opens the socket. Through a proxy the request carries the hostname and the proxy resolves it, so the validated IP is not what gets dialed and the DNS-rebind protection silently stops applying while still appearing to be in force. Refuse such a URL with SSRFError instead. NO_PROXY exclusions are honored, since those hosts are fetched directly, and RUNPOD_ALLOW_PRIVATE_DOWNLOAD_URLS allows the proxied fetch for deployments that accept the trade-off. Chosen over trust_env=False, which would silently ignore a needed proxy and also drop REQUESTS_CA_BUNDLE.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tests/test_serverless/test_utils/test_download.py:117
- The updated download path is intended to fail loudly on SSRF blocks (SSRFError bypasses RequestException/backoff), but test_download.py doesn't currently assert this propagation. Adding a unit test where safe_get raises SSRFError and download_files_from_urls raises will protect this behavior from regressions.
# Test requests exception
mock_get.side_effect = RequestException("Error")
self.assertEqual(
download_files_from_urls(JOB_ID, ["https://example.com/picture.jpg"]),
[None],
)
|
***@***.***
…On July 28, 2026 5:05:26 AM UTC, Copilot ***@***.***> wrote:
@Copilot commented on this pull request.
## Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
<details>
<summary>Comments suppressed due to low confidence (1)</summary>
**tests/test_serverless/test_utils/test_download.py:117**
* The updated download path is intended to fail loudly on SSRF blocks (SSRFError bypasses RequestException/backoff), but test_download.py doesn't currently assert this propagation. Adding a unit test where safe_get raises SSRFError and download_files_from_urls raises will protect this behavior from regressions.
```
# Test requests exception
mock_get.side_effect = RequestException("Error")
self.assertEqual(
download_files_from_urls(JOB_ID, ["https://example.com/picture.jpg"]),
[None],
)
```
</details>
--
Reply to this email directly or view it on GitHub:
#533 (review)
You are receiving this because you are subscribed to this thread.
Message ID: ***@***.***>
|
Summary
Serverless job input carries arbitrary user-supplied URLs that the SDK downloads with no restriction on scheme or destination address. A job can therefore make the worker fetch cloud instance-metadata (
http://169.254.169.254/...) or other non-public addresses — server-side request forgery (CWE-918). Affected sites:download_files_from_urlsandfile()inrunpod/serverless/utils/rp_download.py.The host network layer already blocks RFC1918 egress from worker pods, but not link-local (
169.254.0.0/16), so cloud-metadata credential theft is the live unmitigated vector.Changes
New
runpod/serverless/utils/rp_ssrf.py, routed through by both download sites:http/httpsonly.169.254.169.254, loopback, RFC1918, CGNAT100.64/10, reserved, multicast, IPv6 ULA/link-local, IPv4-mapped IPv6).PinnedIPAdapterdials the pre-validated IP while keeping TLS SNI, certificate verification, and theHostheader bound to the original hostname (never alters cert verification).Content-Lengthand aborts mid-stream past the cap;file()now streams to disk instead of buffering the whole body in memory.Blocks raise
SSRFError(aValueError, not aRequestException) so they fail loudly rather than being retried or silently returned as a failed download.Configuration (secure by default)
RUNPOD_ALLOW_PRIVATE_DOWNLOAD_URLS=true— escape hatch for same-VPC / self-hosted fetches (default off).RUNPOD_MAX_DOWNLOAD_BYTES— per-download byte cap (default 5 GiB).Public API signatures are unchanged; only non-public destinations and oversized downloads change behavior.
Review follow-ups
Addressed after the initial review:
requests.ConnectionError; every candidate stays pre-validated.Content-Length— the header is remote-controlled, soint()on it could raiseValueErrorand abort a valid download. Indownload_files_from_urlsthat error escaped both the backoff retry and theexcept RequestExceptionhandler, failing the whole batch. Parsed defensively now; the size limit is unchanged (enforced while streaming).SSRFError.NO_PROXYexclusions are honored, andRUNPOD_ALLOW_PRIVATE_DOWNLOAD_URLSpermits the proxied fetch. Chosen overtrust_env = False, which would silently ignore a needed proxy and also dropREQUESTS_CA_BUNDLE.urlparsestrips the brackets from an IPv6-literal host, so the pinned adapter emitted a malformedHost(2606::1:443rather than the[2606::1]:443RFC 7230 requires).file()now raises on HTTP errors — it previously saved whatever body came back, so a 4xx/5xx error page was written as the file and, for a.zip, handed to the extractor. This is a behavior change:file()raisesrequests.RequestExceptionon an error status instead of returning a dict. Pre-existing onmain; no in-tree callers and not re-exported fromrunpod.serverless.utils, so it affects only direct importers.Test plan
tests/test_serverless/test_utils/test_ssrf.py: address classification (metadata/RFC1918/CGNAT/IPv6/mapped blocked, public allowed), resolve fail-closed on mixed public+private, scheme rejection, redirect-to-metadata blocked, too-many-redirects, size cap (header + mid-stream), escape hatch, and a real-socket test asserting the adapter dials the pinned IP while preserving theHostheader.test_download.pyto the new fetch path; made the concurrent-download assertion order-independent.ruff checkclean.safe_getagainst a literal metadata IP,file://, and an RFC1918 address all raiseSSRFError; a real public host validates.Follow-up (out of scope)
Adding
169.254.0.0/16(and IPv6) to the host daemon egress rules would close the metadata vector at the network layer as additional defense-in-depth — separate host-team ticket.