Skip to content

fix(cli): harden source-fetch SSRF redirects + size cap (SP42#43)#51

Merged
schiste merged 1 commit into
mainfrom
issue-43-fetch-hardening
Jun 25, 2026
Merged

fix(cli): harden source-fetch SSRF redirects + size cap (SP42#43)#51
schiste merged 1 commit into
mainfrom
issue-43-fetch-hardening

Conversation

@tieguy

@tieguy tieguy commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The source-fetch boundary had two gaps once citation URLs come from (attacker-influenceable) wiki content:

  • only the initial URL was SSRF-checked; redirects were auto-followed by reqwest, so a public-looking host could bounce to loopback/private/link-local;
  • the 8 MB cap was enforced only via Content-Length, so a chunked / no-length body was read unbounded.

This PR:

  • Disables auto-redirects (Policy::none()) and follows them in a manual loop, re-running check_fetchable_source_url() on every hop's destination before connecting (max 5 hops).
  • Reads the body via Response::chunk(), aborting as soon as accumulated bytes would exceed the cap — covering chunked / no-Content-Length responses. The Content-Length fast-reject is kept for honest servers.
  • Preserves GET-only and default-deny private/loopback behavior. The cap is now a CliHttpClient field (prod = MAX_SOURCE_BYTES) so tests can inject a small value.

Tests use raw loopback servers + reqwest .resolve() (so a floor-passing hostname can reach them while the floor stays active): redirect-to-private is blocked, an oversized chunked body is rejected, and an allowed redirect is followed and its body read.

reqwest's stream feature is not enabled, so the cap uses Response::chunk() rather than bytes_stream() — no new feature flags.

Closes #43

🤖 Generated with Claude Code

CliHttpClient checked only the initial URL and relied on
reqwest::redirect::Policy::limited(5), so a public-looking citation host
could 302-redirect to loopback/private/link-local infrastructure and be
fetched. The 8 MB cap was also enforced only via Content-Length, so a
chunked / no-length response was read unbounded with `.bytes()`.

- Disable auto-redirects (Policy::none()) and follow them in a manual loop,
  re-running check_fetchable_source_url() on every hop's destination before
  connecting (capped at MAX_SOURCE_REDIRECTS=5).
- Read the body via Response::chunk(), aborting as soon as the accumulated
  bytes would exceed the cap — covering chunked / no-Content-Length bodies.
  Content-Length fast-reject is kept for honest servers.
- GET-only and default-deny private/loopback behavior preserved. Cap is now
  a CliHttpClient field (prod = MAX_SOURCE_BYTES) so tests inject a small one.

Tests (raw loopback servers + reqwest .resolve() so a floor-passing host can
reach them): redirect-to-private is blocked, an oversized chunked body is
rejected, and an allowed redirect is followed and its body read.

Closes #43

Co-Authored-By: Claude Opus 4.8 <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.

Citation verify: harden source fetch SSRF and response-size enforcement

2 participants