fix(ssrf): one deadline for the redirect chain, not one per hop - #1124
coderrdodo wants to merge 1 commit into
Conversation
ssrf_safe_request follows redirects itself so it can revalidate each hop. That
turns one session.request() call into up to max_redirects + 1 calls — and the
caller's `timeout=` was passed into every one of them, handing each hop a fresh
full budget.
ClientTimeout(total=N) means N seconds for the operation; aiohttp honours that
across redirects precisely because it follows them inside a single request()
call. Driving the hops by hand silently dropped that guarantee. `timeout: 10`
became "10 seconds per hop", and the hop count is the remote server's choice, so
the caller could no longer predict the ceiling at all.
Measured with the real HttpRequestExecutor against a server that redirects three
times, 1.5s per hop, template asking for timeout=2 max_retries=3:
release 9.01s 6 hops served
this PR's parent 21.05s 12 hops served
with this fix 9.01s 6 hops served
Scaled to the shipped defaults (timeout=10, max_retries=3,
HTTP_REQUEST_MAX_REDIRECTS=3) the parent's worst case is ~123s against
release's ~33s, on the path that runs during a live call while the customer
waits. The three recording downloads are affected the same way, harder: they
pass no timeout at all, so aiohttp's session default (total=300s) applied per
hop — up to 20 minutes on a four-hop chain.
The fix takes the budget once, before the first hop, and gives each hop only
what is left. A caller that supplies no timeout falls back to the session's own
ClientTimeout, which is what makes the recording paths whole.
Two details worth naming:
- Exhausting the budget raises asyncio.TimeoutError, not SSRFError. Callers
already treat a timeout as transient and retryable (http_requester.py:245)
and an SSRFError as a security refusal that must abort. A slow chain is the
former; raising the latter would silently stop retrying legitimate requests.
- _with_total copies the caller's ClientTimeout field by field rather than
using dataclasses.replace: ClientTimeout is a dataclass in some aiohttp
releases and an attrs class in others (3.13.3 is attrs), so replace() raises
TypeError there. connect/sock_read/sock_connect/ceil_threshold are carried
through untouched; only `total` shrinks.
Tests: 5 cases, each against a server whose hops are individually inside the
budget but collectively outside it — the shape no per-hop clock can catch.
Covers the caller-supplied budget, the session-default fallback, a chain that
fits (must not be cut short), total=None (nothing to clamp), and that the other
timeout fields survive while total shrinks between hops.
Sachin's 20 SSRF tests still pass unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2noUah4cXpcpXtFvDu4UP
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
ssrf_safe_request follows redirects itself so it can revalidate each hop. That turns one session.request() call into up to max_redirects + 1 calls — and the caller's
timeout=was passed into every one of them, handing each hop a fresh full budget.ClientTimeout(total=N) means N seconds for the operation; aiohttp honours that across redirects precisely because it follows them inside a single request() call. Driving the hops by hand silently dropped that guarantee.
timeout: 10became "10 seconds per hop", and the hop count is the remote server's choice, so the caller could no longer predict the ceiling at all.Measured with the real HttpRequestExecutor against a server that redirects three times, 1.5s per hop, template asking for timeout=2 max_retries=3:
Scaled to the shipped defaults (timeout=10, max_retries=3, HTTP_REQUEST_MAX_REDIRECTS=3) the parent's worst case is ~123s against release's ~33s, on the path that runs during a live call while the customer waits. The three recording downloads are affected the same way, harder: they pass no timeout at all, so aiohttp's session default (total=300s) applied per hop — up to 20 minutes on a four-hop chain.
The fix takes the budget once, before the first hop, and gives each hop only what is left. A caller that supplies no timeout falls back to the session's own ClientTimeout, which is what makes the recording paths whole.
Two details worth naming:
totalshrinks.Tests: 5 cases, each against a server whose hops are individually inside the budget but collectively outside it — the shape no per-hop clock can catch. Covers the caller-supplied budget, the session-default fallback, a chain that fits (must not be cut short), total=None (nothing to clamp), and that the other timeout fields survive while total shrinks between hops.
Sachin's 20 SSRF tests still pass unchanged.
Claude-Session: https://claude.ai/code/session_01W2noUah4cXpcpXtFvDu4UP