fix(desktop): SSRF-guard the self-updater with per-hop redirect validation (LC-210) #276
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/lc-210-desktop-updater-ssrf-guard"
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?
The desktop self-updater fetched
LETS_CHAT_UPDATE_URL(manifest + binary download) viaureqwith no SSRF guard. The operator sets the initial URL deliberately, so the real risk is redirects: a compromised/MITM'd endpoint, or a DNS/BGP redirect, could send the updater to an internal or attacker host. The cure is per-hop validation.Investigation (item 0, the gate)
Resolver→ option (a) is viable, does not collapse.is_globally_routablestd::net, ~70 lines → trivially portable.welcome.rsserver probe (out of scope - see below).Architecture: manual redirect loop (one mechanism, four refinements)
ureq follows redirects internally with one agent, so unguarded-initial would mean unguarded-hops - it can't express "operator chose the initial URL, but redirect targets must stay public." Setting
.redirects(0)(confirmed from ureq source: a 3xx returns asOkunderredirects=0) and following hops in code resolves all four refinements at once: partial-bypass per-hop policy, explicit per-hop validation, literal-IP independence (everyLocationis URL-validated before fetching), and the LC-152 two-layer pattern (URL-input validation + aPublicOnlyResolveron the guarded agent for the resolve-then-connect TOCTOU).is_globally_routableis ported verbatim fromserver/src/ssrf.rs.Partial-bypass opt-out
LETS_CHAT_UPDATE_URL_ALLOW_PRIVATE=1exempts only the initial URL from the filter (private internal mirror, or the documentedhttp://127.0.0.1test fixture). Redirect targets are always validated - a compromised mirror that 302s to169.254.169.254is still refused. The operator's deliberate choice is the initial URL, not "follow arbitrary redirects from my mirror to anywhere."welcome.rs: the deliberate allow-list
The server-reachability probe stays unguarded (single grep-ban allow-list entry, load-bearing inline rationale): it probes
LETS_CHAT_SERVER_URL, legitimately a LAN/private IP for self-hosting, and the webview loads that URL regardless, so the probe adds no SSRF surface; guarding it would break LAN deployments. The grep-ban forbids raw ureq construction outsidenet_guard.rs; a meta-test asserts each allow-listed file still contains a banned token (a refactor moving the raw ureq elsewhere fails the meta-test, not silently expands the guard).Tests (6, specific-variant assertions - LC-152 property-not-proxy)
302 -> 10.0.0.1refused withNonPublicTarget{Redirect}(URL layer fast-fails before connect = the filter, not network-unreachable);127.0.0.2was validated);5+6. grep-ban + load-bearing meta-test.
Established the desktop test pattern (bin-only crate →
#[cfg(test)]modules) and ajust test-desktoprecipe (desktop tests didn't run before;just testis server-only). CLAUDE.md now records that desktop PRs must runjust test-desktop.Fixed in passing: the
update.rsfile://offline-mirror claim (ureq can't dofile://; already non-functional) - corrected, no feature work.What this PR does NOT close
Update-binary integrity.
apply()downloads andself_replaces with no signature/checksum verification (the manifest carries no per-artifact hash). A redirect to a public attacker host still serves an unverified binary - a supply-chain/signing concern, separate from SSRF. Recommend a follow-up ticket: manifest signing (Ed25519/GPG over version+URL+hash, also prevents rollback/substitution) over a bare per-artifact sha256. Named so "guarded the updater" isn't misread as "secured the update process."Validation
just checkclean (both server modes + desktop compile + clippy-D warnings+ fmt);just test-desktop6/6. Server code untouched.🤖 Generated with Claude Code