Skip to content

fix(ssrf): one deadline for the redirect chain, not one per hop - #1124

Open
coderrdodo wants to merge 1 commit into
juspay:fix/pt-ssrf-egressfrom
coderrdodo:fix/ssrf-redirect-deadline
Open

coderrdodo wants to merge 1 commit into
juspay:fix/pt-ssrf-egressfrom
coderrdodo:fix/ssrf-redirect-deadline

Conversation

@coderrdodo

Copy link
Copy Markdown
Contributor

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.

Claude-Session: https://claude.ai/code/session_01W2noUah4cXpcpXtFvDu4UP

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
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 4b46d65e-c3ca-4211-a4e8-ce767f2ac269

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@swaroopvarma1

Copy link
Copy Markdown
Collaborator

It always seems impossible until it's done.

— Nelson Mandela

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants