Skip to content

web_download: re-validate redirects against the SSRF blocklist#82

Merged
davidrhodus merged 1 commit into
mainfrom
fix/web-download-redirect-ssrf
Jul 10, 2026
Merged

web_download: re-validate redirects against the SSRF blocklist#82
davidrhodus merged 1 commit into
mainfrom
fix/web-download-redirect-ssrf

Conversation

@davidrhodus

Copy link
Copy Markdown
Contributor

Summary

Closes the last follow-up from the #79 review. web_download validated the initial URL (added in #79) but then handed it to curl -L / aria2c, which follow redirects at the OS level with no SSRF check. So a model-supplied — or prompt-injected — public URL that 302-redirects to 169.254.169.254 (cloud metadata), 127.0.0.1:6379, or an RFC1918 host slipped straight past the initial-hop guard, and its response was written into a workspace file the model can then read back. web_fetch already re-validated every hop via ssrf_safe_client; web_download did not.

Fix

resolve_download_redirects walks the redirect chain in-process with ssrf_safe_client (whose redirect policy errors on a private hop) and returns only the validated terminal URL — which is what the downloader fetches:

  • The resolving GET never reads the body, so .send() returns on headers alone (a multi-GB file is not downloaded), and a 30 s timeout keeps a slow resolve from hanging the turn.
  • This also covers the HuggingFace path, whose resolve/… URL 302s to a CDN host — the same resolve-then-download two-step hf_hub_download uses. The CDN URL supports range requests, so aria2c's parallel connections still work.

The downloader now runs with redirect following off:

  • curl drops -L, so an unexpected 3xx fails closed via --fail instead of being followed.
  • aria2c has no redirect-disable flag, but since the URL is pre-resolved there is no hop left to follow. A public content host re-redirecting to a private one is the same residual DNS-rebind-class risk validate_url already documents (it resolves-then-connects); this fix inherits that posture rather than widening it.

Test plan

  • New tests: the curl fallback carries no -L/--location and keeps --fail; private/link-local/RFC1918 URLs (169.254.169.254, 127.0.0.1, 10.0.0.1) are rejected before any network I/O on both resolve_download and resolve_download_redirects (literal IPs, so validate_url short-circuits — deterministic, no network).
  • cargo test -p hi-tools: 149 pass; cargo clippy -p hi-tools --all-targets: clean.

🤖 Generated with Claude Code

web_download validated the initial URL but then handed it to `curl -L` /
`aria2c`, which follow redirects at the OS level with no check — so a
model-supplied (or prompt-injected) public URL that 302s to
169.254.169.254, 127.0.0.1, or an RFC1918 host slipped straight past the
initial-hop guard and its response was written into a workspace file the
model can read back. web_fetch already re-validated every hop via
ssrf_safe_client; web_download did not.

Fix: resolve_download_redirects walks the redirect chain in-process with
ssrf_safe_client (whose redirect policy errors on a private hop) and
returns only the validated terminal URL, which is what the downloader
fetches. The GET used to resolve never reads the body, so `.send()`
returns on headers alone — a multi-GB file isn't downloaded — and a 30s
timeout keeps a slow resolve from hanging the turn. This also covers the
HuggingFace path, whose resolve/… URL 302s to a CDN host (the standard
hf_hub_download two-step).

The downloader now runs with redirect following off: curl drops -L so an
unexpected 3xx fails closed via --fail instead of being followed. aria2c
has no redirect-disable flag, but since the URL is pre-resolved there is
no hop left to follow; a public content host re-redirecting to a private
one is the same residual DNS-rebind-class risk validate_url already
documents.

Tests: curl fallback carries no -L/--location and keeps --fail;
private/link-local/RFC1918 URLs are rejected before any network I/O on
both resolve_download and resolve_download_redirects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@davidrhodus
davidrhodus merged commit 09501c6 into main Jul 10, 2026
@davidrhodus
davidrhodus deleted the fix/web-download-redirect-ssrf branch July 10, 2026 17:28
davidrhodus added a commit that referenced this pull request Jul 10, 2026
* Apply rustfmt to the review-fix code (unblock CI)

The CI `fmt · test` job runs `cargo fmt --all --check` before the test
step, and several hand-written lines from the recent review fixes (#79,
#81, #82 and the loop-firing timeout) didn't match rustfmt — mostly long
assert!/match/call expressions that rustfmt reflows across multiple lines.
Format failing blocked the Test step from ever running, so CI showed red
despite a green local suite.

Formatting-only: `git diff -w` shows nothing but line reflows; the four
touched crates' tests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix two clippy errors surfaced on stable 1.97

CI's stable toolchain moved to rust 1.97, whose tightened lints error
(under -D warnings) on two pre-existing spots the 1.93 CI never flagged.
Once the fmt fix let the Test job reach the Clippy step, they blocked it:

- web.rs: redundant `&` in a format! arg (useless_borrows_in_formatting)
- app/run.rs: sort_by comparator that is really a keyed reverse sort
  (unnecessary_sort_by) → sort_by_key(Reverse(..))

Both are behavior-preserving. Verified with the CI toolchain:
`cargo +stable clippy -p hi-agent -p hi-ai -p hi-tools -p hi-tui -p hi
-p hi-eval -- -D warnings` is clean, fmt --check clean, tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant