fix(security): re-validate every redirect hop in unfurl against SSRF guard (LC-150) #183

Merged
nrupard merged 2 commits from feat/lc-150-unfurl-redirect-ssrf into main 2026-05-22 19:51:28 +02:00
Owner

What

Closes the unfurl redirect SSRF from the LC-148 audit (finding S4).

Problem

get_unfurl resolved the initial URL's host and rejected non-globally-routable IPs, then fetched with reqwest::redirect::Policy::limited(3). reqwest follows redirects itself and connects to each target without re-running the host check, so an attacker-controlled page could 302 the fetcher to http://169.254.169.254/ (cloud metadata) or http://127.0.0.1/ and defeat the pre-flight entirely. AuthUser-gated, so any logged-in user could trigger it.

Fix

  • Switch to redirect::Policy::none() and follow redirects manually in a loop.
  • On every hop (initial URL included): re-check the scheme is http/https (a redirect can jump to file:// etc.) and that the host resolves to only globally-routable addresses, before connecting.
  • Relative redirects resolved against the current URL; chain capped at MAX_REDIRECTS = 3 (matches the prior limit).
  • Extracted the per-URL SSRF check into host_resolves_public, shared by the initial fetch and each hop.

Residual DNS-rebinding across the resolve-then-connect gap is unchanged (mitigated by the 5s timeout + 1 MiB cap), and now applies per hop rather than only to the first.

Test

Added a unit-test module (file had none): is_globally_routable accept/reject matrix for v4/v6 (private, loopback, link-local, CGNAT, benchmark, reserved, ULA, IPv4-mapped-v6) and host_resolves_public rejecting IP-literal URLs in private ranges incl. 169.254.169.254. IP literals resolve without DNS, so tests are offline-safe. just check / just test / just test-saas green.

Note: a full redirect-to-loopback integration test isn't feasible here because a localhost test server is itself rejected by the guard (first hop); the loop's per-hop call to host_resolves_public is the security invariant and is covered by the predicate tests.

Part of the LC-159 post-audit story.

🤖 Generated with Claude Code

## What Closes the unfurl redirect SSRF from the LC-148 audit (finding S4). ## Problem `get_unfurl` resolved the initial URL's host and rejected non-globally-routable IPs, then fetched with `reqwest::redirect::Policy::limited(3)`. reqwest follows redirects itself and connects to each target **without** re-running the host check, so an attacker-controlled page could `302` the fetcher to `http://169.254.169.254/` (cloud metadata) or `http://127.0.0.1/` and defeat the pre-flight entirely. AuthUser-gated, so any logged-in user could trigger it. ## Fix - Switch to `redirect::Policy::none()` and follow redirects **manually** in a loop. - On **every** hop (initial URL included): re-check the scheme is `http`/`https` (a redirect can jump to `file://` etc.) and that the host resolves to only globally-routable addresses, before connecting. - Relative redirects resolved against the current URL; chain capped at `MAX_REDIRECTS = 3` (matches the prior limit). - Extracted the per-URL SSRF check into `host_resolves_public`, shared by the initial fetch and each hop. Residual DNS-rebinding across the resolve-then-connect gap is unchanged (mitigated by the 5s timeout + 1 MiB cap), and now applies per hop rather than only to the first. ## Test Added a unit-test module (file had none): `is_globally_routable` accept/reject matrix for v4/v6 (private, loopback, link-local, CGNAT, benchmark, reserved, ULA, IPv4-mapped-v6) and `host_resolves_public` rejecting IP-literal URLs in private ranges incl. `169.254.169.254`. IP literals resolve without DNS, so tests are offline-safe. `just check` / `just test` / `just test-saas` green. Note: a full redirect-to-loopback integration test isn't feasible here because a localhost test server is itself rejected by the guard (first hop); the loop's per-hop call to `host_resolves_public` is the security invariant and is covered by the predicate tests. Part of the LC-159 post-audit story. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(security): re-validate every redirect hop in unfurl against SSRF guard (LC-150)
All checks were successful
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / Kingfisher (push) Successful in 10s
check-secrets / TruffleHog (push) Successful in 10s
check-secrets / Nosey parker (pull_request) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 8s
check-secrets / TruffleHog (pull_request) Successful in 9s
Check / clippy + fmt + tests (pull_request) Successful in 1m20s
02f73c1060
The unfurl handler resolved the initial URL's host and rejected non-globally-routable IPs, then fetched with reqwest's redirect::Policy::limited(3). reqwest follows redirects itself, connecting to each target WITHOUT re-running the host check, so an attacker-controlled page could 302 the fetcher to http://169.254.169.254/ (cloud metadata) or http://127.0.0.1/ and defeat the pre-flight entirely (audit S4). The handler is AuthUser-gated, so any logged-in user could trigger it.

Switch to redirect::Policy::none() and follow redirects manually in a loop that, on every hop (initial URL included), re-checks the scheme is http/https and that the host resolves only to globally-routable addresses before connecting. Relative redirects are resolved against the current URL; the chain is capped at MAX_REDIRECTS (3), matching the prior limit. The per-URL SSRF check is extracted into host_resolves_public so the initial fetch and every hop share one implementation.

Adds a unit-test module (the file previously had none): is_globally_routable accept/reject matrix for v4/v6 including private/loopback/link-local/CGNAT/benchmark/reserved/ULA and IPv4-mapped-v6, plus host_resolves_public rejecting IP-literal URLs in private ranges (incl. 169.254.169.254). IP literals resolve without DNS so the tests are offline-safe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(unfurl): bail on empty redirect Location instead of re-fetching self (LC-150)
All checks were successful
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / TruffleHog (push) Successful in 7s
check-secrets / Nosey parker (pull_request) Successful in 7s
Create release / Create release from merged PR (pull_request) Has been skipped
check-secrets / Kingfisher (push) Successful in 6s
check-secrets / TruffleHog (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 4m44s
ed8e9fdcda
Review follow-up: a 3xx with a missing-or-empty Location header previously fell through to current.join("") (same URL) and re-fetched it until the redirect cap, burning 3 pointless fetches. Trim the Location and treat empty as "nothing to follow" -> empty_preview. Bounded before, just wasteful; this removes the wasted hops. No security change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-150-unfurl-redirect-ssrf 2026-05-22 19:51:28 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!183
No description provided.