Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
215 changes: 215 additions & 0 deletions crates/core/src/net_guard.rs
Original file line number Diff line number Diff line change
@@ -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<SocketAddr>,
}

/// 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<GuardedTarget, EgressError> {
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<SocketAddr> = (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
Comment on lines +98 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block deprecated IPv6 site-local addresses

When an allowlisted hostname resolves to a deprecated IPv6 site-local address (fec0::/10), this predicate falls through because only ULA and link-local ranges are checked. Those addresses are non-public, so the new guard can allow DNS rebinding to internal IPv6 services on networks that still route site-local space; add an explicit fec0::/10 block.

Useful? React with 👍 / 👎.

|| 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::<Ipv4Addr>().unwrap())
}
fn v6(s: &str) -> IpAddr {
IpAddr::V6(s.parse::<Ipv6Addr>().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"
));
}
}
108 changes: 76 additions & 32 deletions crates/llm-fallback/src/browser.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>,
}

/// 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<String, String>>,
) -> Result<String, LlmError> {
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<String, LlmError> {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin every validated address with resolve_to_addrs

When an allowlisted host has more than one validated A/AAAA record, ClientBuilder::resolve is a single-address override for a domain, so each iteration replaces the previous override and the client only tries the last address. In dual-stack or multi-A deployments where that last address is unreachable, the fallback fetch fails even though another validated address was available; pass the whole target.addrs slice via resolve_to_addrs instead.

Useful? React with 👍 / 👎.

}
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::<Vec<_>>()
.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::<Vec<_>>()
.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,
}
}
13 changes: 9 additions & 4 deletions crates/llm-fallback/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))
})?;

Expand Down
Loading