fix(security): guard redirects against SSRF and bound decompression in the parsers - #461
Merged
runyourempire merged 1 commit intoAug 15, 2026
Conversation
…n the parsers Two defects on the untrusted-input path — data fetched from remote feeds, and files opened from the user's disk. SSRF: the guard was pre-flight only ------------------------------------ `is_internal_url` is wired at ten production sites, but no client in the codebase configured a `redirect::Policy`, so all of them inherited reqwest's default `Policy::limited(10)`. A hostile RSS feed (user-addable) or a hijacked curated-feed domain answering `302 Location: http://127.0.0.1:4446/api/dna` was followed, and the internal body was stored as feed content. - `http_client.rs`: `ssrf_guarded_redirect_policy()` refuses any hop to an internal address; `local_aware_redirect_policy()` permits an internal hop only when the request already started internal, so a user's Ollama at 127.0.0.1 keeps working while a cloud provider cannot redirect inward. `Policy::custom` replaces reqwest's hop limit wholesale, so the policy counts hops itself — without that a redirect loop never terminates. - Applied to all 21 production client builders: 10 strict (shared clients, sources, OSV, model registry, translation, webhooks, settings validation, team sync, fastembed download, live reality check) and 11 local-aware (LLM, Ollama, embeddings, local-server probes, dev frontend gate). - `url_validation.rs`: host extraction moves from a hand-rolled parser to the `url` crate — the same WHATWG parser reqwest uses. The old one split on `/ ? # :` but never `@`, so `http://evil.com@127.0.0.1/` produced a host of `evil.com@127.0.0.1`, failed the IP parse, and was allowed. It also missed the legal obfuscated literals (`http://2130706433/`, `http://0x7f000001/`, `http://127.1/`). Credentials-in-URL are now rejected outright, matching `ipc_guard::validate_url_safe_for_request`. - `sources/rss.rs`, `sources/hackernews.rs`: these fetched feed-supplied URLs guarded only by `starts_with("http")` — a direct SSRF that no redirect policy can reach, because the first request is not a redirect. Both now pre-flight through `validate_not_internal`. Decompression bombs in default-reachable parsers ------------------------------------------------ - `extractors/office.rs`: the 100 MB cap was on the *compressed* file, with no decompressed bound at all. A 100 MB DOCX of low-entropy XML expands to ~100 GB, buffered by `read_docx`/calamine before we see a parse result. `guard_ooxml_bomb` streams every part through `io::sink()` and counts the bytes that actually come out, bounding total size, ratio, and part count. Default-build reachable. - `extractors/archive.rs`: all three ZIP guards read `file.size()` — the header-declared size, which the archive's author controls. `zip` 0.6 builds the DEFLATE reader as `DeflateDecoder::new(take(compressed_size))`, so the declared size bounds nothing: declare 1 KB, ship 45 MB. Sizes and ratios now come from capped reads. TAR gains the input cap it never had, a `Take` on the decompressed stream, and an entry-*scan* counter, because `MAX_FILE_COUNT` only incremented after a successful read and an archive of skippable entries looped once per entry on the watcher thread. - `osv/cache.rs`: the ecosystem download called `.bytes()` — unbounded buffering of a several-hundred-MB body — and `read_to_string` per entry. Now streams to disk under a 1 GiB cap and reads each advisory under 8 MiB. Zip-slip was already handled (`enclosed_name`, ParentDir rejection) and is untouched. Tests ----- Every new test was run against the pre-fix code and observed to fail: - 3 url_validation tests (userinfo bypass, obfuscated literals, credentials) - 2 shared-client redirect tests ("HTTP_CLIENT followed the hop: Ok(200)") - 4 office bomb tests (pre-fix reached "Failed to parse DOCX structure", i.e. after inflating) - 3 archive tests ("bomb payload reached the output (47185977 bytes)") The redirect tests drive a real loopback HTTP fixture serving a real 302; `reqwest::redirect::Attempt` cannot be constructed outside its crate, so a live redirect is the only way to exercise a policy. A companion test asserts the stock reqwest policy *does* follow the hop, so the fixture cannot rot into proving nothing. The two `#[ignore]`d office tests are deleted: besides being ignored, each wrapped its only assertion in `if test_docx.exists()` against a temp path nothing created, so they asserted nothing even when run. Replaced with fixtures built programmatically at test time — no binaries committed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2
runyourempire
enabled auto-merge (squash)
August 15, 2026 16:22
runyourempire
deleted the
fix/ssrf-redirect-policy-and-decompression-bombs
branch
August 15, 2026 16:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects on the untrusted-input path — data fetched from remote feeds, and files opened from the user's disk.
Defect 1 (HIGH) — the SSRF guard was pre-flight only
url_validation::is_internal_urlis a good implementation wired at ten production sites. But no client in the codebase configured aredirect::Policy, so all 21 productionClientBuilders inherited reqwest's defaultPolicy::limited(10).A hostile RSS feed (user-addable) or a hijacked curated-feed domain answering
302 Location: http://127.0.0.1:4446/api/dnawas followed, the pre-flight check never re-ran, and the internal body was stored as feed content (content_enrichment.rs).The fix
http_client.rsgains two policies:ssrf_guarded_redirect_policy()HTTP_CLIENT,PROBE_CLIENT,TEAM_CLIENT,client_builder_with_proxy, crates.io, OSV cache + sync, model registry, translation, settings validation, team-sync scheduler, webhooks, fastembed download, live reality checklocal_aware_redirect_policy()LLMClient, Ollama (x2),ollama_capability, embeddings, local-server probes (x3), calibration check, dev-frontend gateThe local-aware variant exists so a user who deliberately points 4DA at
http://127.0.0.1:11434keeps working, while a cloud provider still cannot redirect its way inward. It mirrors the existingif self.provider.provider != "ollama"exemption atllm.rs:492.Policy::customreplaces reqwest's hop limit wholesale, so the policy counts hops itself — without that a redirect loop never terminates. There is a test for exactly that.Secondary: the hand-rolled URL parser
extract_hostsplit on/ ? # :but never on@, sohttp://evil.com@127.0.0.1/produced a host ofevil.com@127.0.0.1, failedparse::<IpAddr>(), fell through a failing DNS lookup, and returned "not internal". It also missed the WHATWG-legal obfuscated literals (http://2130706433/,http://0x7f000001/,http://127.1/,http://0177.0.0.1/).Host extraction now goes through the
urlcrate — the same parser reqwest uses, so there is no parser differential between validation time and request time. Credentials-in-URL are rejected outright, matching whatipc_guard::validate_url_safe_for_requestalready did.Correction to the brief
The report suggested
rss.rs:483/hackernews.rs:301would "inherit the protection" once the redirect policy landed. They would not. Those two fetch feed-supplied URLs guarded only bystarts_with("http")— a direct SSRF where the first request is not a redirect, so no redirect policy can see it. Both now pre-flight throughvalidate_not_internal.Defect 2 (HIGH) — decompression bombs in default-reachable parsers
extractors/office.rs(default-build reachable). The 100 MB cap was on the compressed file, with no decompressed bound, entry count, or ratio check. A 100 MB DOCX of low-entropy XML expands to ~100 GB, buffered byread_docx/calamine before we ever see a parse result.guard_ooxml_bombnow streams every part throughio::sink()— decompression CPU, no allocation — and counts the bytes that actually come out, bounding total size (250 MB), ratio (200:1), and part count (10,000).extractors/archive.rs(feature-gated). All three ZIP guards readfile.size()— the header-declared size, which the archive's author controls.zip0.6 builds the DEFLATE reader asDeflateDecoder::new(take(compressed_size))(read.rs:277), so the declared size bounds nothing: declare 1 KB, ship 45 MB. Sizes and ratios now come from capped reads.TAR gains the input cap it never had, a
Takeon the decompressed stream (a.tar.gzhad no bound at all), and an entry-scan counter —MAX_FILE_COUNTonly incremented after a successful read, so an archive of skippable entries looped once per entry on the watcher thread while holding the DB mutex.osv/cache.rs. The ecosystem download called.bytes()— unbounded in-memory buffering of a several-hundred-MB body — andread_to_stringper entry. Now streams to disk under a 1 GiB cap and reads each advisory under 8 MiB.Zip-slip is untouched —
enclosed_name()and theParentDirrejection were already correct, and nothing is written to disk.Every new test was proven to fail against the pre-fix code
The existing extractor "integration" tests were decorative:
#[ignore]d and self-neutering, each wrapping its only assertion inif test_docx.exists()against a temp path nothing creates. Those two are deleted. New fixtures are built programmatically at test time — no binaries committed.Each guard was reverted in place and the tests re-run. Observed failures:
blocks_userinfo_masking_loopbackassertion failed: is_internal_url("http://evil.com@127.0.0.1/")blocks_obfuscated_ipv4_literalsassertion failed: is_internal_url("http://2130706433/")validate_not_internal_rejects_credentialsshared_http_client_refuses_redirect_to_loopbackHTTP_CLIENT followed the hop: Ok(200)probe_and_team_clients_refuse_redirect_to_loopbackPROBE_CLIENT followed the hopdocx_decompression_bomb_is_refused_on_sizegot: Failed to parse DOCX structure— i.e. after inflatingdocx_decompression_bomb_is_refused_on_ratioxlsx_decompression_bomb_is_refusedgot: Failed to open Excel workbook— after inflatingooxml_entry_count_bomb_is_refusedzip_entry_lying_about_its_size_is_refusedbomb payload reached the output (47185977 bytes of text)targz_decompressed_stream_is_boundedthe walk ran past the extraction budget to reach the trailing entrytar_input_over_compressed_cap_is_refusedgot: No extractable text content found in archiveThe redirect tests drive a real loopback HTTP fixture serving a real 302 —
reqwest::redirect::Attemptcannot be constructed outside its crate, so a live redirect is the only way to exercise a policy. A companion test asserts the stock reqwest policy does follow the hop, so the fixture cannot silently rot into proving nothing.The ZIP bomb fixture forges the declared uncompressed size at known byte offsets in both the local file header (+22) and the central directory record (+24) — which is exactly what an attacker does by hand.
legitimate_docx_still_extractsis the anti-false-positive test: a genuine DOCX built withdocx-rsmust still parse. Guards that reject everything are not guards.Verification
cargo clippy --lib -- -D warnings— clean, default featurescargo clippy --lib --features experimental -- -D warnings— cleancargo clippy --lib --features archive -- -D warnings— clean (archive.rs only compiles under this feature)cargo fmt --check— cleancargo test --lib— 4356 passed, 0 failed, 8 ignoredcargo test --lib --features archive— 4367 passed, 0 failed, 8 ignoredRebased onto
8c60321b, so this is verified against #433'scalamine0.36 /docx-rs0.4.22.office.rsneeded no API changes for that bump; the rebase applied with no conflicts.The rebase caught one real flake: the ratio fixture at deflate level 1 landed at ~199:1 against a 200:1 threshold — sitting on its own boundary. It now builds at level 9 (~1000:1) and asserts the margin up front, so a future flate2 bump fails with "fixture is no longer a convincing bomb" rather than a confusing parser error.
Note
src-tauri/src/osv/cache.rscrosses the 700-line soft warning (726). Warning only, non-blocking; splitting that module is a separate refactor.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2