Skip to main content

helios_sof/
remote_resolver.rs

1//! Remote reference resolution — configuration and the trusted-server gate.
2//!
3//! This module holds the configuration and the allowlist + SSRF guard for remote
4//! `resolve()`. See the user-facing docs in `book/src/ch06-sql-on-fhir.md`
5//! ("Remote resolution against trusted servers") and the storage-backed follow-up
6//! tracked in issue [#167](https://github.com/HeliosSoftware/hfs/issues/167).
7//!
8//! It contains **no networking**. It only decides *whether* a given reference URL
9//! is eligible to be fetched from a trusted server, under a default-deny policy.
10//! The actual prefetch/fetch stage (Phase 4) consumes [`RemoteResolveConfig`] and
11//! reuses [`is_disallowed_ip`] for the post-DNS rebinding re-check.
12//!
13//! ## Security posture (default-deny, strict allowlist)
14//!
15//! A reference is fetchable only if **all** of the following hold:
16//! 1. Remote resolution is enabled and the allowlist is non-empty.
17//! 2. The reference is an absolute `http`/`https` URL.
18//! 3. It matches an allowlist entry on **scheme + host + port + path-prefix**
19//!    (parsed-URL comparison, never substring — so
20//!    `https://evil/?u=https://trusted.org` does not match `https://trusted.org`).
21//!
22//! Because matching requires *scheme equality*, an `http://` reference can only
23//! match an `http://` allowlist entry — i.e. plaintext is allowed only where an
24//! operator has explicitly opted in (typically a trusted internal/test server).
25//! Likewise, a reference to a private/loopback IP literal can only be fetched if
26//! that exact IP host was explicitly allowlisted; otherwise it fails to match.
27//!
28//! The [`is_blocked_address`] guard runs at the fetch stage on DNS-resolved
29//! addresses (DNS-rebinding defense). By default an allowlisted *hostname* that
30//! resolves to a private/internal address is refused; setting
31//! `SOF_RESOLVE_ALLOW_PRIVATE_ADDRESSES=true`
32//! ([`RemoteResolveConfig::allow_private_addresses`]) permits RFC1918 / IPv6-ULA
33//! targets so references can point at an internal load balancer or reverse proxy
34//! (e.g. Traefik) by hostname. The most dangerous ranges — loopback and
35//! link-local (incl. the cloud-metadata endpoint) — stay blocked either way.
36
37use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
38use std::time::Duration;
39
40use url::Url;
41
42/// Environment variable names (documented in `book/src/ch06-sql-on-fhir.md`).
43pub mod env_keys {
44    pub const ENABLED: &str = "SOF_RESOLVE_REMOTE";
45    pub const ALLOWED_BASE_URLS: &str = "SOF_RESOLVE_ALLOWED_BASE_URLS";
46    pub const TIMEOUT_MS: &str = "SOF_RESOLVE_TIMEOUT_MS";
47    pub const MAX_FETCHES: &str = "SOF_RESOLVE_MAX_FETCHES";
48    pub const MAX_DEPTH: &str = "SOF_RESOLVE_MAX_DEPTH";
49    pub const MAX_RESPONSE_BYTES: &str = "SOF_RESOLVE_MAX_RESPONSE_BYTES";
50    pub const CONCURRENCY: &str = "SOF_RESOLVE_CONCURRENCY";
51    /// Per-host bearer tokens, formatted `host=token,host2=token2`.
52    pub const AUTH: &str = "SOF_RESOLVE_AUTH";
53    /// Allow allowlisted hosts to resolve to private/internal addresses.
54    pub const ALLOW_PRIVATE_ADDRESSES: &str = "SOF_RESOLVE_ALLOW_PRIVATE_ADDRESSES";
55    /// Max entries in the cross-chunk fetched-resource cache (streaming path).
56    pub const CACHE_MAX_ENTRIES: &str = "SOF_RESOLVE_CACHE_MAX_ENTRIES";
57}
58
59const DEFAULT_TIMEOUT_MS: u64 = 5_000;
60const DEFAULT_MAX_FETCHES: usize = 256;
61const DEFAULT_MAX_DEPTH: usize = 1;
62const DEFAULT_MAX_RESPONSE_BYTES: usize = 5_000_000;
63const DEFAULT_CONCURRENCY: usize = 8;
64const DEFAULT_CACHE_MAX_ENTRIES: usize = 10_000;
65
66/// Configuration for remote (trusted-server) `resolve()`.
67///
68/// Defaults are **off**: an empty/default config never permits a fetch. Build one
69/// from the process environment with [`RemoteResolveConfig::from_env`].
70#[derive(Debug, Clone)]
71pub struct RemoteResolveConfig {
72    /// Master switch (`SOF_RESOLVE_REMOTE`). When `false`, nothing is ever fetched.
73    pub enabled: bool,
74    /// Trusted base URLs that references must match to be fetchable.
75    pub allowed_base_urls: Vec<AllowedBaseUrl>,
76    /// Per-request timeout (`SOF_RESOLVE_TIMEOUT_MS`).
77    pub timeout: Duration,
78    /// Hard cap on total fetches per run (`SOF_RESOLVE_MAX_FETCHES`).
79    pub max_fetches: usize,
80    /// Bounded prefetch rounds for chained references (`SOF_RESOLVE_MAX_DEPTH`).
81    pub max_depth: usize,
82    /// Maximum accepted response size in bytes (`SOF_RESOLVE_MAX_RESPONSE_BYTES`).
83    pub max_response_bytes: usize,
84    /// Maximum concurrent fetches (`SOF_RESOLVE_CONCURRENCY`).
85    pub concurrency: usize,
86    /// Optional per-host bearer tokens (`SOF_RESOLVE_AUTH`), keyed by lowercased
87    /// host. Sent only on requests to that exact (already-allowlisted) host.
88    pub bearer_tokens: std::collections::HashMap<String, String>,
89    /// Allow allowlisted *hostnames* to resolve to private/internal addresses —
90    /// RFC1918 (`10/8`, `172.16/12`, `192.168/16`) and IPv6 ULA (`fc00::/7`)
91    /// (`SOF_RESOLVE_ALLOW_PRIVATE_ADDRESSES`, default `false`).
92    ///
93    /// Needed for internal deployments where references point at an internal
94    /// load balancer / reverse proxy (e.g. Traefik) by hostname. Even when `true`,
95    /// the always-blocked ranges (loopback, link-local incl. cloud metadata,
96    /// multicast, broadcast, unspecified, CGNAT, reserved) remain refused — see
97    /// [`is_blocked_address`].
98    pub allow_private_addresses: bool,
99    /// Maximum entries in the cross-chunk fetched-resource cache used by the
100    /// streaming path (`SOF_RESOLVE_CACHE_MAX_ENTRIES`). The cache (LRU, with
101    /// negative caching of misses) lets a reference recurring across chunks be
102    /// fetched once; bounding it keeps streaming memory bounded — evicted-then-
103    /// reused references are re-fetched. Ignored by the single-Bundle path, which
104    /// holds all references in one pass.
105    pub cache_max_entries: usize,
106}
107
108impl Default for RemoteResolveConfig {
109    fn default() -> Self {
110        Self {
111            enabled: false,
112            allowed_base_urls: Vec::new(),
113            timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
114            max_fetches: DEFAULT_MAX_FETCHES,
115            max_depth: DEFAULT_MAX_DEPTH,
116            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
117            concurrency: DEFAULT_CONCURRENCY,
118            bearer_tokens: std::collections::HashMap::new(),
119            allow_private_addresses: false,
120            cache_max_entries: DEFAULT_CACHE_MAX_ENTRIES,
121        }
122    }
123}
124
125impl RemoteResolveConfig {
126    /// Builds the configuration from the process environment.
127    ///
128    /// Malformed numeric values fall back to their defaults (with a `tracing`
129    /// warning); malformed allowlist entries are skipped individually.
130    pub fn from_env() -> Self {
131        Self::from_env_with(|key| std::env::var(key).ok())
132    }
133
134    /// Builds the configuration from an arbitrary environment lookup.
135    ///
136    /// Exposed so the parsing logic can be unit-tested without touching the
137    /// process-global environment.
138    pub fn from_env_with(get: impl Fn(&str) -> Option<String>) -> Self {
139        let enabled = get(env_keys::ENABLED)
140            .map(|v| parse_bool(&v))
141            .unwrap_or(false);
142
143        let allowed_base_urls = get(env_keys::ALLOWED_BASE_URLS)
144            .map(|v| parse_allowlist(&v))
145            .unwrap_or_default();
146
147        let timeout = Duration::from_millis(parse_or_default(
148            get(env_keys::TIMEOUT_MS).as_deref(),
149            env_keys::TIMEOUT_MS,
150            DEFAULT_TIMEOUT_MS,
151        ));
152        let max_fetches = parse_or_default(
153            get(env_keys::MAX_FETCHES).as_deref(),
154            env_keys::MAX_FETCHES,
155            DEFAULT_MAX_FETCHES,
156        );
157        let max_depth = parse_or_default(
158            get(env_keys::MAX_DEPTH).as_deref(),
159            env_keys::MAX_DEPTH,
160            DEFAULT_MAX_DEPTH,
161        );
162        let max_response_bytes = parse_or_default(
163            get(env_keys::MAX_RESPONSE_BYTES).as_deref(),
164            env_keys::MAX_RESPONSE_BYTES,
165            DEFAULT_MAX_RESPONSE_BYTES,
166        );
167        let concurrency = parse_or_default(
168            get(env_keys::CONCURRENCY).as_deref(),
169            env_keys::CONCURRENCY,
170            DEFAULT_CONCURRENCY,
171        )
172        .max(1);
173
174        let bearer_tokens = get(env_keys::AUTH)
175            .map(|v| parse_bearer_tokens(&v))
176            .unwrap_or_default();
177
178        let allow_private_addresses = get(env_keys::ALLOW_PRIVATE_ADDRESSES)
179            .map(|v| parse_bool(&v))
180            .unwrap_or(false);
181
182        let cache_max_entries = parse_or_default(
183            get(env_keys::CACHE_MAX_ENTRIES).as_deref(),
184            env_keys::CACHE_MAX_ENTRIES,
185            DEFAULT_CACHE_MAX_ENTRIES,
186        )
187        .max(1);
188
189        Self {
190            enabled,
191            allowed_base_urls,
192            timeout,
193            max_fetches,
194            max_depth,
195            max_response_bytes,
196            concurrency,
197            bearer_tokens,
198            allow_private_addresses,
199            cache_max_entries,
200        }
201    }
202
203    /// Returns the bearer token configured for `host`, if any (case-insensitive).
204    pub fn bearer_for_host(&self, host: &str) -> Option<&str> {
205        self.bearer_tokens
206            .get(&host.to_ascii_lowercase())
207            .map(String::as_str)
208    }
209
210    /// Whether remote resolution can fetch anything at all (enabled *and* the
211    /// allowlist is non-empty). When `false`, [`Self::fetch_decision`] always denies.
212    pub fn is_active(&self) -> bool {
213        self.enabled && !self.allowed_base_urls.is_empty()
214    }
215
216    /// Decides whether `reference` may be fetched from a trusted server.
217    ///
218    /// This is the single gate enforcing the default-deny policy. It performs no
219    /// I/O and no DNS resolution.
220    pub fn fetch_decision(&self, reference: &str) -> FetchDecision {
221        if !self.enabled {
222            return FetchDecision::Deny(DenyReason::Disabled);
223        }
224
225        let url = match Url::parse(reference) {
226            Ok(u) => u,
227            Err(_) => return FetchDecision::Deny(DenyReason::NotAbsoluteUrl),
228        };
229
230        // `Url::parse` accepts relative-looking inputs as some schemes; require a
231        // real network scheme with a host.
232        match url.scheme() {
233            "http" | "https" => {}
234            _ => return FetchDecision::Deny(DenyReason::UnsupportedScheme),
235        }
236        if url.host_str().is_none() {
237            return FetchDecision::Deny(DenyReason::NotAbsoluteUrl);
238        }
239
240        if self.allowed_base_urls.iter().any(|base| base.matches(&url)) {
241            FetchDecision::Allow
242        } else {
243            FetchDecision::Deny(DenyReason::NotAllowlisted)
244        }
245    }
246}
247
248/// A parsed, normalised trusted base URL.
249///
250/// Matching compares scheme, lowercased host, effective port (scheme default when
251/// absent), and a path prefix anchored on segment boundaries.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct AllowedBaseUrl {
254    scheme: String,
255    host: String,
256    port: u16,
257    /// Path prefix without a trailing slash; empty means "any path on this host".
258    path_prefix: String,
259}
260
261impl AllowedBaseUrl {
262    /// Parses a single allowlist entry such as `https://fhir.example.org/r4`.
263    pub fn parse(raw: &str) -> Result<Self, AllowlistParseError> {
264        let url = Url::parse(raw.trim()).map_err(|_| AllowlistParseError::InvalidUrl)?;
265
266        let scheme = url.scheme().to_string();
267        if scheme != "http" && scheme != "https" {
268            return Err(AllowlistParseError::UnsupportedScheme);
269        }
270
271        let host = url
272            .host_str()
273            .ok_or(AllowlistParseError::MissingHost)?
274            .to_ascii_lowercase();
275
276        let port = url
277            .port_or_known_default()
278            .ok_or(AllowlistParseError::MissingPort)?;
279
280        let path_prefix = url.path().trim_end_matches('/').to_string();
281
282        Ok(Self {
283            scheme,
284            host,
285            port,
286            path_prefix,
287        })
288    }
289
290    /// Returns `true` if `url` falls within this trusted base.
291    fn matches(&self, url: &Url) -> bool {
292        url.scheme() == self.scheme
293            && url
294                .host_str()
295                .map(|h| h.eq_ignore_ascii_case(&self.host))
296                .unwrap_or(false)
297            && url.port_or_known_default() == Some(self.port)
298            && path_prefix_matches(&self.path_prefix, url.path())
299    }
300}
301
302/// Errors from parsing a single allowlist entry.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
304pub enum AllowlistParseError {
305    #[error("not a valid absolute URL")]
306    InvalidUrl,
307    #[error("unsupported scheme (only http/https are allowed)")]
308    UnsupportedScheme,
309    #[error("URL has no host")]
310    MissingHost,
311    #[error("URL has no resolvable port")]
312    MissingPort,
313}
314
315/// Outcome of [`RemoteResolveConfig::fetch_decision`].
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum FetchDecision {
318    /// The reference matches a trusted base and may be fetched.
319    Allow,
320    /// The reference must not be fetched, with the reason.
321    Deny(DenyReason),
322}
323
324impl FetchDecision {
325    /// Convenience: whether the decision is [`FetchDecision::Allow`].
326    pub fn is_allowed(&self) -> bool {
327        matches!(self, FetchDecision::Allow)
328    }
329}
330
331/// Why a reference was denied remote resolution.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum DenyReason {
334    /// Remote resolution is disabled (`SOF_RESOLVE_REMOTE` is not set/true).
335    Disabled,
336    /// The reference is not an absolute URL (e.g. `Patient/1`, `#frag`).
337    NotAbsoluteUrl,
338    /// The URL scheme is not `http`/`https`.
339    UnsupportedScheme,
340    /// The URL did not match any trusted base in the allowlist.
341    NotAllowlisted,
342}
343
344/// Strict SSRF guard: `true` if `ip` must never be the target of a fetch under
345/// the default (most restrictive) policy.
346///
347/// Equivalent to [`is_blocked_address(ip, false)`](is_blocked_address): the union
348/// of the always-blocked ranges and the private/internal ranges. Retained as the
349/// canonical predicate used where private addresses are never acceptable.
350pub fn is_disallowed_ip(ip: IpAddr) -> bool {
351    is_blocked_address(ip, false)
352}
353
354/// SSRF guard parameterised by whether private/internal addresses are permitted.
355///
356/// Applied by the fetch stage to addresses obtained from DNS resolution (to defeat
357/// DNS rebinding): an allowlisted *hostname* that resolves to a blocked address is
358/// refused.
359///
360/// - The **always-blocked** ranges are refused regardless of `allow_private`:
361///   loopback, link-local (incl. the `169.254.169.254` cloud-metadata endpoint),
362///   unspecified, broadcast, multicast, carrier-grade NAT, documentation/test, and
363///   reserved (`0.0.0.0/8`, `240.0.0.0/4`).
364/// - The **private/internal** ranges — RFC1918 (`10/8`, `172.16/12`, `192.168/16`)
365///   and IPv6 ULA (`fc00::/7`) — are refused only when `allow_private` is `false`.
366///   Setting it `true` (via `SOF_RESOLVE_ALLOW_PRIVATE_ADDRESSES`) lets an
367///   allowlisted hostname point at an internal load balancer / reverse proxy.
368pub fn is_blocked_address(ip: IpAddr, allow_private: bool) -> bool {
369    let (always_blocked, private) = match ip {
370        IpAddr::V4(v4) => (is_always_blocked_ipv4(v4), v4.is_private()),
371        IpAddr::V6(v6) => {
372            // IPv4-mapped (`::ffff:a.b.c.d`) addresses are reachable as IPv4.
373            if let Some(mapped) = v6.to_ipv4_mapped() {
374                (is_always_blocked_ipv4(mapped), mapped.is_private())
375            } else {
376                (is_always_blocked_ipv6(v6), is_unique_local_ipv6(v6))
377            }
378        }
379    };
380    always_blocked || (!allow_private && private)
381}
382
383fn is_always_blocked_ipv4(ip: Ipv4Addr) -> bool {
384    let [a, b, _, _] = ip.octets();
385    ip.is_unspecified()
386        || ip.is_loopback()
387        || ip.is_link_local()
388        || ip.is_broadcast()
389        || ip.is_documentation()
390        || ip.is_multicast()
391        // "this network" 0.0.0.0/8
392        || a == 0
393        // carrier-grade NAT 100.64.0.0/10
394        || (a == 100 && (64..=127).contains(&b))
395        // reserved for future use 240.0.0.0/4 (and 255.* broadcast)
396        || a >= 240
397}
398
399fn is_always_blocked_ipv6(ip: Ipv6Addr) -> bool {
400    let first = ip.segments()[0];
401    ip.is_unspecified()
402        || ip.is_loopback()
403        || ip.is_multicast()
404        // link-local unicast fe80::/10
405        || (first & 0xffc0) == 0xfe80
406}
407
408/// IPv6 unique-local addresses `fc00::/7` (the ULA "private" range).
409fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool {
410    (ip.segments()[0] & 0xfe00) == 0xfc00
411}
412
413/// Anchored path-prefix match: `path` must equal `prefix` or continue past it at
414/// a `/` boundary. An empty `prefix` matches any path.
415fn path_prefix_matches(prefix: &str, path: &str) -> bool {
416    if prefix.is_empty() {
417        return true;
418    }
419    let path = path.trim_end_matches('/');
420    path == prefix || path.starts_with(&format!("{prefix}/"))
421}
422
423/// Parses a comma-separated allowlist into trusted base URLs, skipping (and
424/// warning about) malformed entries. Exposed for CLI/server wiring that builds a
425/// [`RemoteResolveConfig`] from flags rather than the environment.
426pub fn parse_allowed_base_urls(csv: &str) -> Vec<AllowedBaseUrl> {
427    parse_allowlist(csv)
428}
429
430/// Parses `host=token,host2=token2` into a per-host bearer map (hosts lowercased).
431fn parse_bearer_tokens(csv: &str) -> std::collections::HashMap<String, String> {
432    csv.split(',')
433        .map(str::trim)
434        .filter(|s| !s.is_empty())
435        .filter_map(|pair| {
436            let (host, token) = pair.split_once('=')?;
437            let host = host.trim();
438            let token = token.trim();
439            if host.is_empty() || token.is_empty() {
440                tracing::warn!(pair, "ignoring malformed {} entry", env_keys::AUTH);
441                return None;
442            }
443            Some((host.to_ascii_lowercase(), token.to_string()))
444        })
445        .collect()
446}
447
448/// Parses a comma-separated allowlist, skipping (and warning about) bad entries.
449fn parse_allowlist(csv: &str) -> Vec<AllowedBaseUrl> {
450    csv.split(',')
451        .map(str::trim)
452        .filter(|s| !s.is_empty())
453        .filter_map(|entry| match AllowedBaseUrl::parse(entry) {
454            Ok(base) => Some(base),
455            Err(err) => {
456                tracing::warn!(
457                    entry,
458                    error = %err,
459                    "ignoring invalid {} entry",
460                    env_keys::ALLOWED_BASE_URLS
461                );
462                None
463            }
464        })
465        .collect()
466}
467
468fn parse_bool(value: &str) -> bool {
469    matches!(
470        value.trim().to_ascii_lowercase().as_str(),
471        "1" | "true" | "yes" | "on"
472    )
473}
474
475fn parse_or_default<T: std::str::FromStr>(value: Option<&str>, key: &str, default: T) -> T {
476    match value {
477        None => default,
478        Some(raw) => match raw.trim().parse() {
479            Ok(parsed) => parsed,
480            Err(_) => {
481                tracing::warn!(key, value = raw, "invalid value; using default");
482                default
483            }
484        },
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use std::collections::HashMap;
492
493    fn cfg(enabled: bool, allow: &[&str]) -> RemoteResolveConfig {
494        RemoteResolveConfig {
495            enabled,
496            allowed_base_urls: allow
497                .iter()
498                .map(|s| AllowedBaseUrl::parse(s).expect("valid test base"))
499                .collect(),
500            ..Default::default()
501        }
502    }
503
504    // ---- Phase 1: configuration parsing -------------------------------------
505
506    #[test]
507    fn default_config_is_off() {
508        let c = RemoteResolveConfig::default();
509        assert!(!c.enabled);
510        assert!(c.allowed_base_urls.is_empty());
511        assert!(!c.is_active());
512        assert_eq!(c.timeout, Duration::from_millis(DEFAULT_TIMEOUT_MS));
513        assert_eq!(c.max_depth, DEFAULT_MAX_DEPTH);
514    }
515
516    #[test]
517    fn from_env_parses_values() {
518        let env: HashMap<&str, &str> = HashMap::from([
519            (env_keys::ENABLED, "true"),
520            (
521                env_keys::ALLOWED_BASE_URLS,
522                "https://fhir.example.org/r4, https://hapi.example.com/baseR4",
523            ),
524            (env_keys::TIMEOUT_MS, "1500"),
525            (env_keys::MAX_FETCHES, "10"),
526            (env_keys::MAX_DEPTH, "3"),
527            (env_keys::CONCURRENCY, "4"),
528        ]);
529        let c = RemoteResolveConfig::from_env_with(|k| env.get(k).map(|s| s.to_string()));
530
531        assert!(c.enabled);
532        assert!(c.is_active());
533        assert_eq!(c.allowed_base_urls.len(), 2);
534        assert_eq!(c.timeout, Duration::from_millis(1500));
535        assert_eq!(c.max_fetches, 10);
536        assert_eq!(c.max_depth, 3);
537        assert_eq!(c.concurrency, 4);
538    }
539
540    #[test]
541    fn from_env_defaults_off_when_unset() {
542        let c = RemoteResolveConfig::from_env_with(|_| None);
543        assert!(!c.enabled);
544        assert!(c.allowed_base_urls.is_empty());
545    }
546
547    #[test]
548    fn from_env_skips_invalid_allowlist_entries() {
549        let env: HashMap<&str, &str> = HashMap::from([
550            (env_keys::ENABLED, "1"),
551            (
552                env_keys::ALLOWED_BASE_URLS,
553                "https://ok.example.org/fhir, not-a-url, ftp://nope.example.org, ",
554            ),
555        ]);
556        let c = RemoteResolveConfig::from_env_with(|k| env.get(k).map(|s| s.to_string()));
557        assert_eq!(c.allowed_base_urls.len(), 1);
558    }
559
560    #[test]
561    fn from_env_bad_numbers_fall_back_to_default() {
562        let env: HashMap<&str, &str> =
563            HashMap::from([(env_keys::TIMEOUT_MS, "abc"), (env_keys::MAX_FETCHES, "")]);
564        let c = RemoteResolveConfig::from_env_with(|k| env.get(k).map(|s| s.to_string()));
565        assert_eq!(c.timeout, Duration::from_millis(DEFAULT_TIMEOUT_MS));
566        assert_eq!(c.max_fetches, DEFAULT_MAX_FETCHES);
567    }
568
569    #[test]
570    fn bool_parsing_is_lenient() {
571        for v in ["1", "true", "TRUE", "Yes", "on"] {
572            assert!(parse_bool(v), "{v} should be true");
573        }
574        for v in ["0", "false", "no", "", "off", "maybe"] {
575            assert!(!parse_bool(v), "{v} should be false");
576        }
577    }
578
579    // ---- Phase 2: allowlist matching ----------------------------------------
580
581    #[test]
582    fn allows_reference_under_trusted_base() {
583        let c = cfg(true, &["https://fhir.example.org/r4"]);
584        assert!(
585            c.fetch_decision("https://fhir.example.org/r4/Patient/123")
586                .is_allowed()
587        );
588        // The base path itself matches.
589        assert!(c.fetch_decision("https://fhir.example.org/r4").is_allowed());
590        // Query strings are irrelevant to the host/path decision.
591        assert!(
592            c.fetch_decision("https://fhir.example.org/r4/Patient/1?_format=json")
593                .is_allowed()
594        );
595    }
596
597    #[test]
598    fn path_prefix_is_anchored_on_segment_boundary() {
599        let c = cfg(true, &["https://fhir.example.org/r4"]);
600        // `/r4extra` must NOT be treated as under `/r4`.
601        assert_eq!(
602            c.fetch_decision("https://fhir.example.org/r4extra/Patient/1"),
603            FetchDecision::Deny(DenyReason::NotAllowlisted)
604        );
605        assert_eq!(
606            c.fetch_decision("https://fhir.example.org/other"),
607            FetchDecision::Deny(DenyReason::NotAllowlisted)
608        );
609    }
610
611    #[test]
612    fn host_scheme_and_port_must_match() {
613        let c = cfg(true, &["https://fhir.example.org/r4"]);
614        // Wrong host.
615        assert_eq!(
616            c.fetch_decision("https://evil.example.org/r4/Patient/1"),
617            FetchDecision::Deny(DenyReason::NotAllowlisted)
618        );
619        // Wrong scheme (only the https base is trusted).
620        assert_eq!(
621            c.fetch_decision("http://fhir.example.org/r4/Patient/1"),
622            FetchDecision::Deny(DenyReason::NotAllowlisted)
623        );
624        // Wrong (explicit) port.
625        assert_eq!(
626            c.fetch_decision("https://fhir.example.org:8443/r4/Patient/1"),
627            FetchDecision::Deny(DenyReason::NotAllowlisted)
628        );
629    }
630
631    #[test]
632    fn substring_smuggling_does_not_match() {
633        let c = cfg(true, &["https://fhir.example.org/r4"]);
634        // Host is evil.com; the trusted URL only appears in the query.
635        assert_eq!(
636            c.fetch_decision("https://evil.com/?u=https://fhir.example.org/r4/Patient/1"),
637            FetchDecision::Deny(DenyReason::NotAllowlisted)
638        );
639        // Userinfo trick: real host is still evil.com.
640        assert_eq!(
641            c.fetch_decision("https://fhir.example.org@evil.com/r4/Patient/1"),
642            FetchDecision::Deny(DenyReason::NotAllowlisted)
643        );
644    }
645
646    #[test]
647    fn explicit_http_base_allows_plaintext() {
648        // Operators may opt into a trusted internal/test http server.
649        let c = cfg(true, &["http://localhost:8080/baseR4"]);
650        assert!(
651            c.fetch_decision("http://localhost:8080/baseR4/Patient/1")
652                .is_allowed()
653        );
654        // https to the same host/port is a different base and is not trusted.
655        assert_eq!(
656            c.fetch_decision("https://localhost:8080/baseR4/Patient/1"),
657            FetchDecision::Deny(DenyReason::NotAllowlisted)
658        );
659    }
660
661    #[test]
662    fn literal_private_ip_requires_explicit_allowlisting() {
663        // Not allowlisted -> denied (this is the SSRF-by-literal-IP case).
664        let c = cfg(true, &["https://fhir.example.org/r4"]);
665        assert_eq!(
666            c.fetch_decision("https://10.0.0.5/r4/Patient/1"),
667            FetchDecision::Deny(DenyReason::NotAllowlisted)
668        );
669        // Explicitly allowlisted private host -> the operator owns that decision.
670        let c2 = cfg(true, &["https://10.0.0.5/r4"]);
671        assert!(
672            c2.fetch_decision("https://10.0.0.5/r4/Patient/1")
673                .is_allowed()
674        );
675    }
676
677    #[test]
678    fn non_absolute_and_unsupported_schemes_are_denied() {
679        let c = cfg(true, &["https://fhir.example.org/r4"]);
680        assert_eq!(
681            c.fetch_decision("Patient/123"),
682            FetchDecision::Deny(DenyReason::NotAbsoluteUrl)
683        );
684        assert_eq!(
685            c.fetch_decision("#contained-1"),
686            FetchDecision::Deny(DenyReason::NotAbsoluteUrl)
687        );
688        assert_eq!(
689            c.fetch_decision("ftp://fhir.example.org/r4/Patient/1"),
690            FetchDecision::Deny(DenyReason::UnsupportedScheme)
691        );
692        assert_eq!(
693            c.fetch_decision("file:///etc/passwd"),
694            FetchDecision::Deny(DenyReason::UnsupportedScheme)
695        );
696    }
697
698    #[test]
699    fn disabled_or_empty_allowlist_denies_everything() {
700        let disabled = cfg(false, &["https://fhir.example.org/r4"]);
701        assert_eq!(
702            disabled.fetch_decision("https://fhir.example.org/r4/Patient/1"),
703            FetchDecision::Deny(DenyReason::Disabled)
704        );
705
706        let empty = cfg(true, &[]);
707        assert!(!empty.is_active());
708        assert_eq!(
709            empty.fetch_decision("https://fhir.example.org/r4/Patient/1"),
710            FetchDecision::Deny(DenyReason::NotAllowlisted)
711        );
712    }
713
714    #[test]
715    fn root_base_matches_any_path_on_host() {
716        let c = cfg(true, &["https://fhir.example.org"]);
717        assert!(
718            c.fetch_decision("https://fhir.example.org/anything/here")
719                .is_allowed()
720        );
721        assert!(c.fetch_decision("https://fhir.example.org/").is_allowed());
722    }
723
724    // ---- Phase 2: SSRF IP guard ---------------------------------------------
725
726    #[test]
727    fn disallowed_ipv4_ranges() {
728        for ip in [
729            "0.0.0.0",
730            "127.0.0.1",
731            "10.0.0.1",
732            "172.16.0.1",
733            "172.31.255.255",
734            "192.168.1.1",
735            "169.254.169.254", // cloud metadata
736            "100.64.0.1",      // CGNAT
737            "192.0.2.1",       // documentation
738            "255.255.255.255",
739            "224.0.0.1", // multicast
740            "240.0.0.1", // reserved
741        ] {
742            assert!(
743                is_disallowed_ip(ip.parse().unwrap()),
744                "{ip} should be disallowed"
745            );
746        }
747    }
748
749    #[test]
750    fn allowed_public_ipv4() {
751        for ip in ["8.8.8.8", "1.1.1.1", "93.184.216.34"] {
752            assert!(
753                !is_disallowed_ip(ip.parse().unwrap()),
754                "{ip} should be allowed"
755            );
756        }
757    }
758
759    #[test]
760    fn disallowed_ipv6_ranges() {
761        for ip in [
762            "::1",                    // loopback
763            "fe80::1",                // link-local
764            "fc00::1",                // unique local
765            "fd12:3456::1",           // unique local
766            "ff02::1",                // multicast
767            "::",                     // unspecified
768            "::ffff:127.0.0.1",       // v4-mapped loopback
769            "::ffff:169.254.169.254", // v4-mapped metadata
770        ] {
771            assert!(
772                is_disallowed_ip(ip.parse().unwrap()),
773                "{ip} should be disallowed"
774            );
775        }
776    }
777
778    #[test]
779    fn allowed_public_ipv6() {
780        for ip in [
781            "2606:4700:4700::1111",
782            "2001:4860:4860::8888",
783            "::ffff:8.8.8.8",
784        ] {
785            assert!(
786                !is_disallowed_ip(ip.parse().unwrap()),
787                "{ip} should be allowed"
788            );
789        }
790    }
791
792    // ---- Phase 5: private-address opt-in (internal load balancers) ----------
793
794    #[test]
795    fn allow_private_permits_rfc1918_and_ula() {
796        // With opt-in, internal LB ranges (RFC1918 + IPv6 ULA) are reachable...
797        for ip in [
798            "10.0.0.5",
799            "172.16.4.4",
800            "192.168.1.10",
801            "fc00::1",
802            "fd12:3456::1",
803        ] {
804            let addr: IpAddr = ip.parse().unwrap();
805            assert!(
806                is_blocked_address(addr, false),
807                "{ip} must be blocked by default"
808            );
809            assert!(
810                !is_blocked_address(addr, true),
811                "{ip} must be permitted when allow_private is set"
812            );
813        }
814    }
815
816    #[test]
817    fn always_blocked_ranges_ignore_allow_private() {
818        // ...but loopback, link-local/metadata, and friends stay blocked even then.
819        for ip in [
820            "127.0.0.1",
821            "169.254.169.254", // cloud metadata
822            "0.0.0.0",
823            "100.64.0.1", // CGNAT
824            "224.0.0.1",  // multicast
825            "240.0.0.1",  // reserved
826            "::1",
827            "fe80::1",          // link-local
828            "::ffff:127.0.0.1", // v4-mapped loopback
829        ] {
830            let addr: IpAddr = ip.parse().unwrap();
831            assert!(
832                is_blocked_address(addr, true),
833                "{ip} must stay blocked even with allow_private"
834            );
835        }
836    }
837
838    #[test]
839    fn public_addresses_allowed_regardless_of_flag() {
840        for ip in ["8.8.8.8", "2606:4700:4700::1111"] {
841            let addr: IpAddr = ip.parse().unwrap();
842            assert!(!is_blocked_address(addr, false));
843            assert!(!is_blocked_address(addr, true));
844        }
845    }
846
847    #[test]
848    fn from_env_parses_allow_private() {
849        let env: HashMap<&str, &str> = HashMap::from([(env_keys::ALLOW_PRIVATE_ADDRESSES, "true")]);
850        let c = RemoteResolveConfig::from_env_with(|k| env.get(k).map(|s| s.to_string()));
851        assert!(c.allow_private_addresses);
852        // Default (unset) stays false.
853        let d = RemoteResolveConfig::from_env_with(|_| None);
854        assert!(!d.allow_private_addresses);
855    }
856}