diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cbdc97c..a77f1b7 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod adapter; #[cfg(feature = "audit")] pub mod audit; pub mod error; +pub mod net_guard; pub mod oauth; pub mod redact; pub mod router; diff --git a/crates/core/src/net_guard.rs b/crates/core/src/net_guard.rs new file mode 100644 index 0000000..95a2174 --- /dev/null +++ b/crates/core/src/net_guard.rs @@ -0,0 +1,215 @@ +//! Network-egress guard against SSRF (always compiled). +//! +//! Resolves a URL's host and **rejects any resolved IP** in a loopback / private / +//! link-local / ULA / metadata / reserved range, then returns the validated socket +//! addresses so the caller can **pin** them on the HTTP client (resolve-then- +//! connect-to-IP), defeating DNS rebinding. Callers should also disable redirect +//! following (or re-validate each hop) and enforce a host allowlist. + +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum EgressError { + #[error("invalid url")] + BadUrl, + #[error("unsupported url scheme '{0}' (only http/https)")] + BadScheme(String), + #[error("url has no host")] + NoHost, + #[error("dns resolution failed: {0}")] + Resolve(String), + #[error("host did not resolve to any address")] + NoAddr, + #[error("blocked egress to non-public address {0}")] + Blocked(IpAddr), +} + +/// A host that passed the egress check, plus the exact IPs to pin. +#[derive(Debug, Clone)] +pub struct GuardedTarget { + pub host: String, + pub port: u16, + pub addrs: Vec, +} + +/// Parse + validate `url`: http/https only, resolve the host, and reject if **any** +/// resolved address is non-public. On success returns the addresses to pin. +pub fn guard_and_resolve(url: &str) -> Result { + let parsed = reqwest::Url::parse(url).map_err(|_| EgressError::BadUrl)?; + match parsed.scheme() { + "http" | "https" => {} + other => return Err(EgressError::BadScheme(other.to_string())), + } + let host = parsed.host_str().ok_or(EgressError::NoHost)?.to_string(); + let port = parsed.port_or_known_default().ok_or(EgressError::NoHost)?; + + let addrs: Vec = (host.as_str(), port) + .to_socket_addrs() + .map_err(|e| EgressError::Resolve(e.to_string()))? + .collect(); + if addrs.is_empty() { + return Err(EgressError::NoAddr); + } + for a in &addrs { + if is_blocked_ip(&a.ip()) { + return Err(EgressError::Blocked(a.ip())); + } + } + Ok(GuardedTarget { host, port, addrs }) +} + +/// True for any address relais must never connect to for a user-supplied URL: +/// loopback, private (RFC1918), link-local (incl. 169.254.169.254 metadata), CGNAT, +/// benchmarking, reserved/broadcast/unspecified/multicast/documentation; for IPv6: +/// loopback, unspecified, multicast, ULA (fc00::/7), link-local (fe80::/10), and any +/// IPv4-mapped address that is itself blocked. +pub fn is_blocked_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(a) => { + if a.is_loopback() + || a.is_private() + || a.is_link_local() + || a.is_unspecified() + || a.is_broadcast() + || a.is_documentation() + || a.is_multicast() + { + return true; + } + let o = a.octets(); + (o[0] == 100 && (o[1] & 0xc0) == 0x40) // 100.64.0.0/10 CGNAT + || (o[0] == 192 && o[1] == 0 && o[2] == 0) // 192.0.0.0/24 IETF + || (o[0] == 198 && (o[1] & 0xfe) == 18) // 198.18.0.0/15 benchmarking + || o[0] >= 240 // 240.0.0.0/4 reserved + } + IpAddr::V6(a) => { + if a.is_loopback() || a.is_unspecified() || a.is_multicast() { + return true; + } + // Decode embedded IPv4 (IPv4-mapped `::ffff:a.b.c.d` AND IPv4-compatible + // `::a.b.c.d`) and apply the v4 rules, so e.g. `::ffff:169.254.169.254` + // can't smuggle a metadata address. + if let Some(v4) = a.to_ipv4() { + return is_blocked_ip(&IpAddr::V4(v4)); + } + let s = a.segments(); + (s[0] & 0xfe00) == 0xfc00 // fc00::/7 unique-local + || (s[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local + || s[0] == 0x2002 // 2002::/16 6to4 (transition; block) + || (s[0] == 0x2001 && s[1] == 0) // 2001:0::/32 Teredo + || (s[0] == 0x0064 && s[1] == 0xff9b) // 64:ff9b::/96 NAT64 well-known + } + } +} + +/// Does `host` match a cookie `domain` per cookie scoping (exact host, or a +/// subdomain of the domain)? Used to avoid sending stored cookies cross-host. +pub fn host_matches_cookie_domain(host: &str, domain: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let domain = domain + .trim_start_matches('.') + .trim_end_matches('.') + .to_ascii_lowercase(); + if domain.is_empty() { + return false; + } + host == domain || host.ends_with(&format!(".{domain}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + fn v4(s: &str) -> IpAddr { + IpAddr::V4(s.parse::().unwrap()) + } + fn v6(s: &str) -> IpAddr { + IpAddr::V6(s.parse::().unwrap()) + } + + #[test] + fn blocks_private_and_meta_ranges() { + for s in [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", // cloud metadata + "0.0.0.0", + "100.64.0.1", // CGNAT + "198.18.0.1", // benchmarking + "240.0.0.1", // reserved + "255.255.255.255", + ] { + assert!(is_blocked_ip(&v4(s)), "{s} should be blocked"); + } + for s in [ + "::1", + "fc00::1", + "fe80::1", + "::ffff:127.0.0.1", // IPv4-mapped loopback + "::ffff:10.0.0.1", // IPv4-mapped private + "::ffff:169.254.169.254", // IPv4-mapped metadata + "::127.0.0.1", // IPv4-compatible loopback + "::a9fe:a9fe", // IPv4-compatible 169.254.169.254 + "2002:7f00:1::", // 6to4 encoding 127.0.0.1 + "2002:0a00:0001::", // 6to4 encoding 10.0.0.1 + "2001::1", // Teredo + "64:ff9b::a9fe:a9fe", // NAT64 well-known → 169.254.169.254 + ] { + assert!(is_blocked_ip(&v6(s)), "{s} should be blocked"); + } + } + + #[test] + fn allows_public_addresses() { + for s in ["1.1.1.1", "8.8.8.8", "93.184.216.34"] { + assert!(!is_blocked_ip(&v4(s)), "{s} should be allowed"); + } + assert!(!is_blocked_ip(&v6("2606:4700:4700::1111"))); + } + + #[test] + fn guard_blocks_loopback_url_offline() { + // resolvable without network + assert!(matches!( + guard_and_resolve("http://127.0.0.1:8080/x"), + Err(EgressError::Blocked(_)) + )); + assert!(matches!( + guard_and_resolve("http://localhost/x"), + Err(EgressError::Blocked(_)) + )); + } + + #[test] + fn guard_rejects_bad_scheme() { + assert!(matches!( + guard_and_resolve("file:///etc/passwd"), + Err(EgressError::BadScheme(_)) + )); + assert!(matches!( + guard_and_resolve("gopher://x"), + Err(EgressError::BadScheme(_)) + )); + } + + #[test] + fn cookie_domain_scoping() { + assert!(host_matches_cookie_domain("example.com", "example.com")); + assert!(host_matches_cookie_domain( + "api.example.com", + ".example.com" + )); + assert!(host_matches_cookie_domain("api.example.com", "example.com")); + assert!(!host_matches_cookie_domain("evil.com", "example.com")); + assert!(!host_matches_cookie_domain("notexample.com", "example.com")); + assert!(!host_matches_cookie_domain( + "example.com.evil.com", + "example.com" + )); + } +} diff --git a/crates/llm-fallback/src/browser.rs b/crates/llm-fallback/src/browser.rs index f13b33c..ee75079 100644 --- a/crates/llm-fallback/src/browser.rs +++ b/crates/llm-fallback/src/browser.rs @@ -1,50 +1,94 @@ use std::collections::HashMap; +use std::time::Duration; -use crate::LlmError; +use relais_core::net_guard; use tracing::debug; -/// Fetch the raw HTML of a page via a simple HTTP GET, optionally with cookies. -/// -/// This is the MVP approach that avoids requiring Chrome/Chromium to be -/// installed. For pages that rely heavily on client-side JavaScript rendering, -/// a headless browser (e.g., chromiumoxide) can be used as an optional -/// enhancement. +use crate::LlmError; + +/// Cookies plus the domain they were captured for, so they are only ever sent to a +/// matching host. +pub struct CookieScope { + pub domain: String, + pub cookies: HashMap, +} + +/// Fetch the raw HTML of a page via a guarded HTTP GET. /// -/// When `cookies` is `Some`, the key-value pairs are serialised into a single -/// `Cookie` header and attached to the request. This allows authenticated -/// fetches using session cookies imported via `relais auth import-cookies`. -pub async fn fetch_html( - url: &str, - cookies: Option<&HashMap>, -) -> Result { - debug!(url, "fetching HTML via reqwest"); - let client = reqwest::Client::builder() +/// SSRF defenses (H3): the URL host is resolved and validated against +/// [`net_guard`] (private/loopback/metadata IPs refused); the validated IPs are +/// **pinned** on the client to defeat DNS rebinding; redirects are disabled; and the +/// host must be on the `RELAIS_FALLBACK_ALLOW` allowlist (fail-closed). Imported +/// cookies are attached only when the request host matches their stored domain. +pub async fn fetch_html(url: &str, cookie_scope: Option<&CookieScope>) -> Result { + debug!(url, "fetching HTML via reqwest (guarded)"); + + // 1. Egress guard: validate scheme/host and resolve to IPs with no non-public one. + let target = net_guard::guard_and_resolve(url) + .map_err(|e| LlmError::Browser(format!("egress blocked: {e}")))?; + + // 2. Host allowlist (fail-closed): the LLM fallback can reach arbitrary URLs, so + // it only fetches hosts the operator explicitly allows. + if !host_allowed(&target.host) { + return Err(LlmError::Browser(format!( + "host '{}' is not in RELAIS_FALLBACK_ALLOW; refusing to fetch. Set \ + RELAIS_FALLBACK_ALLOW=host1,host2 to enable the LLM fallback for specific hosts", + target.host + ))); + } + + // 3. Pin the validated IPs (anti-rebinding), disable redirects, set a timeout. + let mut builder = reqwest::Client::builder() .user_agent("relais/0.1") + .redirect(reqwest::redirect::Policy::none()) + // Disable system proxies: a proxy would re-resolve the host itself, bypassing + // our IP validation + pinning (SSRF). Connect directly to the validated IPs. + .no_proxy() + .timeout(Duration::from_secs(30)); + for addr in &target.addrs { + builder = builder.resolve(&target.host, *addr); + } + let client = builder .build() .map_err(|e| LlmError::Browser(format!("failed to build HTTP client: {e}")))?; let mut request = client.get(url); - if let Some(cookies) = cookies { - let cookie_str: String = cookies - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join("; "); - debug!(cookie_count = cookies.len(), "injecting cookies into request"); - request = request.header("Cookie", cookie_str); + // 4. Cookies only when the host matches the cookie's stored domain. + if let Some(scope) = cookie_scope { + if net_guard::host_matches_cookie_domain(&target.host, &scope.domain) { + let cookie_str: String = scope + .cookies + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("; "); + debug!(cookie_count = scope.cookies.len(), "injecting cookies"); + request = request.header("Cookie", cookie_str); + } else { + debug!(host = %target.host, domain = %scope.domain, "withholding cookies: host does not match cookie domain"); + } } let resp = request.send().await?; - if !resp.status().is_success() { - return Err(LlmError::Browser(format!( - "HTTP {} when fetching {}", - resp.status(), - url - ))); + // Don't echo the URL (it may carry sensitive query params). + return Err(LlmError::Browser(format!("HTTP {}", resp.status()))); } - let body = resp.text().await?; - Ok(body) + Ok(resp.text().await?) +} + +/// Whether `host` is on `RELAIS_FALLBACK_ALLOW` (comma-separated). Unset/empty ⇒ +/// fail-closed (no host allowed). +fn host_allowed(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + match std::env::var("RELAIS_FALLBACK_ALLOW") { + Ok(list) => list + .split(',') + .map(|s| s.trim().to_ascii_lowercase()) + .filter(|s| !s.is_empty()) + .any(|h| h == host), + Err(_) => false, + } } diff --git a/crates/llm-fallback/src/lib.rs b/crates/llm-fallback/src/lib.rs index bb5f299..97e8cf7 100644 --- a/crates/llm-fallback/src/lib.rs +++ b/crates/llm-fallback/src/lib.rs @@ -126,13 +126,18 @@ impl Adapter for LlmFallbackAdapter { debug!(url, action, "LLM fallback: fetching and extracting"); - // Extract cookies from credentials if present. - let cookies = ctx.credentials.as_ref().and_then(|cred| match &cred.data { - CredentialData::Cookie { cookies, .. } => Some(cookies), + // Extract cookies (with their domain, for host-scoped sending) if present. + let cookie_scope = ctx.credentials.as_ref().and_then(|cred| match &cred.data { + CredentialData::Cookie { + cookies, domain, .. + } => Some(crate::browser::CookieScope { + domain: domain.clone(), + cookies: cookies.clone(), + }), _ => None, }); - let html = fetch_html(url, cookies).await.map_err(|e| { + let html = fetch_html(url, cookie_scope.as_ref()).await.map_err(|e| { AdapterError::Other(anyhow::anyhow!("failed to fetch HTML: {e}")) })?;