fix(security): close Web Push SSRF + LC-152 TOCTOU via shared http_client #246
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-152-shared-http-client-no-bypass"
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?
⚠️ Security fix — Web Push SSRF reachable in shipped versions
🔴 BLOCKING question — release handling
Live user-controllable SSRF-to-cloud-metadata in shipped versions is the severity class where the "next release vs. backported security release" question must be asked and answered deliberately, not defaulted to next-release. Does this warrant a backported security point-release + CVE, or does the next feature release suffice?
Inputs to the decision:
pushManager.subscribe()JS API and choose any endpoint).Secondary fix — LC-152 hostname use-time TOCTOU
The originally-scoped fix.
ssrf::host_resolves_publicwas called before.send()on every guarded path (LC-75 delivery, slash, unfurl, avatar fetch), but reqwest then performed its OWN DNS resolution between the check and the connect. A rapid-flip DNS record could resolve public at the check and private at reqwest's resolve. Closed by routing every outbound call through a customreqwest::dns::Resolve(PublicOnlyResolver) that filters inside reqwest's own resolution path — same resolution, no second resolve.The PublicOnlyResolver alone is not sufficient: reqwest 0.12's connector takes a literal-IP fast path that skips
dns::Resolveentirely. A daemon- or user-supplied URL with a literal private IP (e.g.http://127.0.0.1/,http://10.0.0.1/, or the metadata endpoint) slips past the resolver. The killer test pair surfaced this; the fix is a second layer — URL-input validation in the helper's public API — that runs BEFORE reqwest sees the URL.What ships
New module
server/src/http_client.rs— the only public API for outbound HTTP from this server:The underlying
reqwest::Clients are private to the module. Noserver/src/file can obtain aClientto call.get()/.post()on; the verb-problem in the grep-ban (banning.get()would have massive false positives fromHashMap::getetc.) disappears structurally.Two layers of SSRF guard, each closing what the other can't:
outbound_*async fns. Parses + scheme check +ssrf::host_resolves_public.lookup_hostreturns the IP itself for literal-IP URLs, so this layer catches the literal-IP fast-path bypass that reqwest'sdns::Resolvedoes not see.PublicOnlyResolverinside reqwest's resolution path. For hostnames, filters every resolution to publicly-routable IPs. Same resolution as reqwest's connect, no second resolve. Closes the hostname use-time TOCTOU.Removing either layer reopens the corresponding attack class.
Grep-ban test (
tests/lc152_no_raw_reqwest_in_src.rs) — structural no-bypass enforcement. Walksserver/src/, fails the build onreqwest::Client::new/reqwest::Client::builder/reqwest::ClientBuilder/reqwest::get(/reqwest::blocking::/outbound_unchecked. Exception list is the FULL PATHsrc/http_client.rs(NOT a basename match — a futuresrc/foo/http_client.rswould otherwise silently inherit the exemption). Sibling sanity testallow_list_actually_finds_the_allowed_fileproves the exception list isn't a defanged no-op.5 migrations to the new API:
outgoing.rs(LC-75)Client::builder()+ per-deliveryhost_resolves_publicpre-checkrun_delivery_tick(chat)takes no client; per-deliveryoutbound_post(&t.url)withmark_failed("blocked: non-public URL")on rejectionroutes/unfurl.rsClient::builder()outbound_get(url);empty_preview()on anyOutboundErrorroutes/slash.rsClient::builder()outbound_post(url)withBadRequest("URL is not allowed")on rejectionroutes/api.rs::post_bridge_message(LC-78 avatar fetch spawn)Client::builder(), passed tofetch_and_cachebridge_avatar::fetch_and_cache(&chat, &hash, &url)— no clientpush/mod.rs::ReqwestPushClient(THE UNGUARDED ONE)reqwest::Client::new()field, no SSRF check anywheresend()callsoutbound_post(&url). Hardwired to the helper by design; future refactors can't inject around the filter.Killer test suite — every rejection asserts the SPECIFIC variant
The lesson from the prior round was: bare
is_err()is a false-positive trap because connect-failed / network-unreachable also satisfy it. Every rejection test intests/lc152_resolver_property_pair.rsassertsmatches!(_, Err(OutboundError::HostNotPublic))(or the matching variant), proving the FILTER caught the URL, not the network. Plus a timing bound (< 3s) onrejection_is_fast_no_tcp_attemptproves the URL-input layer rejects BEFORE any TCP connect (a connect timeout would also produceHostNotPublic-shaped error if the resolver path were the only mechanism — the timing assertion guards against that regression). 8 tests, all green.Test plan
cargo checkclean in standalone + saas.just testclean (123 binaries, 0 failed).just test-saas— one timing-dependent flake inretention_sweep::message_exactly_at_cutoff_survives_strict_less_thanunder concurrent-binary load; passes in isolation; unrelated to LC-152.lc152_no_raw_reqwest_in_src) — 2/2 pass. Full-path exception list.lc152_resolver_property_pair) — 8/8 pass. Destination-arrival contract; HostNotPublic variant on literal loopback, literal RFC 1918, the metadata endpoint, literal-IP POST; UnsupportedScheme onfile://; InvalidUrl on malformed; fast-fail < 3 s.lc152_per_site_error_propagation) — 4/4 pass. LC-75 marks-failed terminal not retried; bridge avatar fetch marks failed without panic (private IP + non-http scheme); Web Push send returns Err on the 169.254.169.254 vector without crashing the loop.Deferred — desktop
ureqself-updaterFiled as a follow-up. The desktop crate (separate binary) uses
ureqto fetchLETS_CHAT_UPDATE_URLfor the self-update flow. Same TOCTOU class but narrower exploit surface: the URL is operator-configured at deploy time, not user-supplied. The real concern there is redirect-following (does the updater follow an attacker-controlled redirect to internal hosts), not the initial operator-set URL. Lower urgency than the server-side work; queued behind the LC-152 server fix.Why this can't be split
The 5 migrations + the 2-layer helper + the exception-free grep-ban + the variant-asserting killer suite are one transaction.