fix(fetch_url): optionally trust fake-ip placeholder ranges to avoid SSRF false-positives#2355
fix(fetch_url): optionally trust fake-ip placeholder ranges to avoid SSRF false-positives#2355h3c-hexin wants to merge 1 commit into
Conversation
…SSRF false-positives The DNS-resolution SSRF guard in `validate_dns_resolved_ip` rejects any hostname that resolves into a restricted IP range. Under a transparent-proxy / TUN setup running in `fake-ip` mode, the local resolver maps *every* hostname to a reserved placeholder range (commonly `198.18.0.0/15`), so the guard rejects all outbound `fetch_url` requests even though the proxy will route them correctly. Add a narrowly-scoped, opt-in trust list on `NetworkPolicyDecider`: - `with_trusted_fakeip_cidrs(&["198.18.0.0/15"])` registers IPv4 CIDR ranges to treat as benign fake-ip placeholders (default: empty — no behavior change unless the embedder configures it). - `is_trusted_fakeip_addr(ip)` matches only those configured ranges. `validate_dns_resolved_ip` now allows a resolved IP if it is either in a configured fake-ip range or the host is on the existing trusted-proxy list. Real private/loopback/link-local/cloud-metadata IPs match neither and stay blocked, so this does not widen SSRF exposure for default configurations. Includes CIDR parsing/containment helpers and a unit test covering the placeholder-allowed / real-private-blocked / nothing-configured cases.
There was a problem hiding this comment.
Code Review
This pull request introduces support for configuring trusted IPv4 CIDR ranges as benign fake-IP placeholders (e.g., for TUN/transparent-proxy setups in fake-ip mode) to bypass restricted-IP SSRF blocks. It adds parsing and matching logic in NetworkPolicyDecider and integrates it into the DNS resolution validation. The feedback suggests improving is_trusted_fakeip_addr to handle IPv4-mapped IPv6 addresses, preventing bypass failures in dual-stack or IPv6-preferred environments.
| pub fn is_trusted_fakeip_addr(&self, ip: &IpAddr) -> bool { | ||
| match ip { | ||
| IpAddr::V4(v4) => self | ||
| .trusted_fakeip_cidrs | ||
| .iter() | ||
| .any(|(base, prefix)| ipv4_in_cidr(*v4, *base, *prefix)), | ||
| // fake-ip placeholders are IPv4-only in practice. | ||
| IpAddr::V6(_) => false, | ||
| } | ||
| } |
There was a problem hiding this comment.
In dual-stack or IPv6-preferred environments, DNS resolvers may return IPv4-mapped IPv6 addresses (e.g., ::ffff:198.18.0.5). While is_restricted_ip correctly unwraps these to check them as IPv4, is_trusted_fakeip_addr currently returns false for all IPv6 addresses. This will cause fake-IP bypasses to fail when mapped addresses are returned.
We should handle IPv4-mapped IPv6 addresses by extracting the inner Ipv4Addr using to_ipv4_mapped() before checking the CIDR ranges.
| pub fn is_trusted_fakeip_addr(&self, ip: &IpAddr) -> bool { | |
| match ip { | |
| IpAddr::V4(v4) => self | |
| .trusted_fakeip_cidrs | |
| .iter() | |
| .any(|(base, prefix)| ipv4_in_cidr(*v4, *base, *prefix)), | |
| // fake-ip placeholders are IPv4-only in practice. | |
| IpAddr::V6(_) => false, | |
| } | |
| } | |
| pub fn is_trusted_fakeip_addr(&self, ip: &IpAddr) -> bool { | |
| let v4 = match ip { | |
| IpAddr::V4(v4) => Some(*v4), | |
| IpAddr::V6(v6) => v6.to_ipv4_mapped(), | |
| }; | |
| if let Some(v4) = v4 { | |
| self.trusted_fakeip_cidrs | |
| .iter() | |
| .any(|(base, prefix)| ipv4_in_cidr(v4, *base, *prefix)) | |
| } else { | |
| false | |
| } | |
| } |
| pub fn with_trusted_fakeip_cidrs(mut self, cidrs: &[&str]) -> Self { | ||
| for cidr in cidrs { | ||
| if let Some(parsed) = parse_ipv4_cidr(cidr) { | ||
| self.trusted_fakeip_cidrs.push(parsed); | ||
| } | ||
| } | ||
| self |
There was a problem hiding this comment.
Silent CIDR skip makes misconfiguration invisible
Invalid CIDR strings passed to with_trusted_fakeip_cidrs are silently discarded. If an operator types "198.18.0.0-15" or "198.18.0.0/55" by mistake, every request that resolves into that range will continue to be blocked with no indication that the configuration was rejected. A tracing::warn! (or at minimum a debug log) when a CIDR fails to parse would make these silent drops visible in the existing audit log infrastructure.
Summary
The DNS-resolution SSRF guard in
validate_dns_resolved_iprejects any hostname that resolves into a restricted IP range. Under a transparent-proxy / TUN setup running infake-ipmode, the local resolver maps every hostname into a reserved placeholder range (commonly198.18.0.0/15), so the guard rejects all outboundfetch_urlrequests even though the proxy routes them correctly.This adds a narrowly-scoped, opt-in trust list on
NetworkPolicyDecider:with_trusted_fakeip_cidrs(&["198.18.0.0/15"])registers IPv4 CIDR ranges to treat as benign fake-ip placeholders. Default is empty — no behavior change unless the embedder configures it.is_trusted_fakeip_addr(ip)matches only those configured ranges.validate_dns_resolved_ipnow allows a resolved IP if it is either in a configured fake-ip range or the host is on the existing trusted-proxy list. Real private / loopback / link-local / cloud-metadata IPs match neither and stay blocked, so this does not widen SSRF exposure for default configurations.Includes IPv4 CIDR parse/containment helpers and a unit test covering placeholder-allowed / real-private-blocked / nothing-configured.
Testing
cargo fmt --all -- --checkcargo build -p codewhale-tui(compiles, 42s)cargo clippy --workspace --all-targets --all-featurescargo test --workspace --all-featuresChecklist
trusted_fakeip_cidr_allows_placeholder_but_not_real_private)Greptile Summary
This PR adds an opt-in mechanism to
NetworkPolicyDeciderthat lets embedders declare IPv4 CIDR ranges as benign fake-IP placeholders, preventing the DNS-resolution SSRF guard from rejecting all outbound requests in transparent-proxy / TUNfake-ipsetups. The default configuration is unchanged — no CIDR ranges are trusted unless explicitly registered.with_trusted_fakeip_cidrsregisters CIDR ranges via a builder pattern;is_trusted_fakeip_addrtests a resolved IP against them. Both are IPv4-only, consistent with how fake-IP is implemented in practice.validate_dns_resolved_ipnow allows a resolved IP if it matches a configured fake-IP range or the host is on the pre-existing trusted-proxy list; all other restricted IPs (loopback, private, link-local, cloud-metadata) continue to be blocked under default configuration.Confidence Score: 4/5
Safe to merge for default deployments; the SSRF bypass is entirely opt-in and the CIDR arithmetic is correct.
The core logic is sound — CIDR masking is applied symmetrically, the prefix-0 edge case is handled before any shift, and IPv4-mapped IPv6 addresses are not affected by the new bypass. Both findings are observability/UX concerns rather than functional defects: invalid CIDR strings are silently dropped with no log, and the audit label is the same whether the IP-range path or the host-list path triggered the allow.
The
with_trusted_fakeip_cidrsbuilder innetwork_policy.rsand the updated condition infetch_url.rsare the only files that need a second look.Important Files Changed
trusted_fakeip_cidrsfield, and a builder method + accessor onNetworkPolicyDecider. Logic is correct (prefix-0 edge case handled, mask applied symmetrically to both sides). Minor: invalid CIDRs are silently dropped inwith_trusted_fakeip_cidrswith no log.validate_dns_resolved_ipto allow IPs in configured fake-IP CIDR ranges in addition to the existing host-based trust check. The change is minimal and correct; audit label does not distinguish which condition triggered the bypass.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[fetch_url resolves hostname to IP] --> B{is_restricted_ip?} B -- No --> C[Allow request] B -- Yes --> D{decider configured?} D -- No --> E[Block: SSRF guard] D -- Yes --> F{is_trusted_fakeip_addr?\nIP in configured CIDR ranges} F -- Yes --> G[record_trusted_proxy_fakeip_allow\nAllow request] F -- No --> H{trusts_proxy_fakeip_host?\nHost on explicit proxy list} H -- Yes --> G H -- No --> EComments Outside Diff (1)
crates/tui/src/tools/fetch_url.rs, line 398-403 (link)record_trusted_proxy_fakeip_allowalways writes"TrustedProxyFakeIp-Allow"regardless of which branch of the||triggered the allow. When the IP matches a configured CIDR (new path) vs. the host being on the explicit proxy trust list (old path), the audit log looks identical. This makes it hard to answer in post-incident analysis whether a bypass came from the new range-trust feature or the pre-existing host allowlist. Consider using a separate audit label (e.g."TrustedFakeIpCidr-Allow") for the IP-range case.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Reviews (1): Last reviewed commit: "fix(fetch_url): optionally trust fake-ip..." | Re-trigger Greptile