fix(security): re-validate every redirect hop in unfurl against SSRF guard (LC-150) #183
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-150-unfurl-redirect-ssrf"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Closes the unfurl redirect SSRF from the LC-148 audit (finding S4).
Problem
get_unfurlresolved the initial URL's host and rejected non-globally-routable IPs, then fetched withreqwest::redirect::Policy::limited(3). reqwest follows redirects itself and connects to each target without re-running the host check, so an attacker-controlled page could302the fetcher tohttp://169.254.169.254/(cloud metadata) orhttp://127.0.0.1/and defeat the pre-flight entirely. AuthUser-gated, so any logged-in user could trigger it.Fix
redirect::Policy::none()and follow redirects manually in a loop.http/https(a redirect can jump tofile://etc.) and that the host resolves to only globally-routable addresses, before connecting.MAX_REDIRECTS = 3(matches the prior limit).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_routableaccept/reject matrix for v4/v6 (private, loopback, link-local, CGNAT, benchmark, reserved, ULA, IPv4-mapped-v6) andhost_resolves_publicrejecting 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-saasgreen.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_publicis the security invariant and is covered by the predicate tests.Part of the LC-159 post-audit story.
🤖 Generated with Claude Code
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>