fraiseql_functions/host/live/http_validator/mod.rs
1//! HTTP request validation with SSRF protection.
2//!
3//! This module validates outbound HTTP requests to prevent Server-Side Request Forgery (SSRF)
4//! attacks by:
5//! - Enforcing a domain allowlist
6//! - Blocking private IP addresses (RFC 1918, loopback, link-local)
7//! - Blocking `IPv6` private ranges
8
9use std::net::IpAddr;
10
11use fraiseql_error::{FraiseQLError, Result};
12
13/// Configuration for HTTP client validation.
14#[derive(Debug, Clone)]
15pub struct HttpClientConfig {
16 /// Allowed domains for outbound requests (glob patterns).
17 ///
18 /// Deny-by-default: an empty list (the default) permits **no** outbound
19 /// hosts — a caller must explicitly opt in to each domain. Glob patterns
20 /// such as `"*.example.com"` are supported; `"*"` allows all hosts and is
21 /// NOT recommended for production.
22 pub allowed_domains: Vec<String>,
23
24 /// Maximum response body size in bytes.
25 pub max_response_bytes: usize,
26
27 /// Connect timeout in milliseconds.
28 pub connect_timeout_ms: u64,
29
30 /// Read timeout in milliseconds.
31 pub read_timeout_ms: u64,
32}
33
34impl Default for HttpClientConfig {
35 fn default() -> Self {
36 Self {
37 // Deny-by-default (fail-closed): no host is allowed until the caller
38 // explicitly populates the allowlist.
39 allowed_domains: vec![],
40 max_response_bytes: 10 * 1024 * 1024, // 10 MB
41 connect_timeout_ms: 5000,
42 read_timeout_ms: 30000,
43 }
44 }
45}
46
47/// Validate an outbound URL for SSRF attacks.
48///
49/// Checks:
50/// 1. Domain is in the allowlist (deny-by-default; supports glob patterns)
51/// 2. Literal IP address in the host is not private/reserved (RFC 1918, 127.0.0.0/8,
52/// 169.254.0.0/16, etc.)
53/// 3. The host's DNS-resolved addresses are not private/reserved — closes the DNS-rebinding hole
54/// where a public name resolves to an internal IP
55/// 4. `IPv6` addresses are not private (loopback, link-local, ULA)
56///
57/// # Arguments
58///
59/// * `url` - The URL to validate
60/// * `config` - HTTP client configuration with allowlist
61///
62/// # Returns
63///
64/// - `Ok(())` if the URL is safe to request
65/// - `Err` if the URL is blocked by allowlist or resolves to a private IP
66///
67/// # Errors
68///
69/// Returns `Err` if the URL is malformed, blocked by the allowlist, fails DNS
70/// resolution, or resolves to a private/reserved IP address.
71pub async fn validate_outbound_url(url: &str, config: &HttpClientConfig) -> Result<()> {
72 // Parse the URL
73 let parsed_url = reqwest::Url::parse(url).map_err(|e| FraiseQLError::Validation {
74 message: format!("invalid URL: {}", e),
75 path: None,
76 })?;
77
78 // Check domain allowlist
79 let host = parsed_url.host_str().ok_or_else(|| FraiseQLError::Validation {
80 message: "URL has no host".to_string(),
81 path: None,
82 })?;
83
84 // Check if host matches allowlist
85 if !is_domain_allowed(host, &config.allowed_domains) {
86 return Err(FraiseQLError::Authorization {
87 message: format!("domain '{}' not in allowlist", host),
88 action: Some("http_request".to_string()),
89 resource: Some(host.to_string()),
90 });
91 }
92
93 // Loopback and metadata hostname aliases, before any lookup. `localhost` was
94 // only ever caught if it happened to resolve to 127.0.0.1, and `api.localhost`
95 // / `localhost.evil.com` were not caught at all — the latter is registrable by
96 // anyone.
97 if let Some(reason) = fraiseql_guard::net::blocked_host_reason(host) {
98 return Err(FraiseQLError::Authorization {
99 message: format!("host '{host}' not allowed: {reason}"),
100 action: Some("http_request".to_string()),
101 resource: Some(host.to_string()),
102 });
103 }
104
105 // Check for private/reserved IPs in a literal-IP host.
106 if let Ok(ip) = parse_ip_from_host(host) {
107 validate_ip(&ip)?;
108 // A literal IP cannot be rebound via DNS; the literal check above is
109 // sufficient and `lookup_host` on a literal would only echo it back.
110 return Ok(());
111 }
112
113 // DNS-rebinding guard: resolve the host and reject if ANY address is
114 // private/reserved. A public name that resolves to an internal IP is the
115 // classic SSRF bypass; checking literal IPs alone does not cover it.
116 let port = parsed_url.port_or_known_default().unwrap_or(443);
117 let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host, port))
118 .await
119 .map_err(|e| FraiseQLError::Validation {
120 message: format!("DNS resolution failed for host '{host}': {e}"),
121 path: None,
122 })?
123 .collect();
124 if addrs.is_empty() {
125 return Err(FraiseQLError::Validation {
126 message: format!("DNS resolved to no addresses for host '{host}'"),
127 path: None,
128 });
129 }
130 for addr in &addrs {
131 validate_ip(&addr.ip())?;
132 }
133
134 Ok(())
135}
136
137/// Check if a host (domain or IP) is in the allowlist.
138/// Supports glob patterns: "*" matches all, "*.example.com" matches subdomains.
139/// Also supports IP addresses (with or without port).
140fn is_domain_allowed(host: &str, allowlist: &[String]) -> bool {
141 for pattern in allowlist {
142 if pattern == "*" {
143 return true;
144 }
145
146 // Extract IP from host (remove port if present)
147 let host_for_comparison = if let Some(colon_pos) = host.rfind(':') {
148 // Only strip port if this looks like host:port (not IPv6)
149 if !host.starts_with('[') {
150 &host[..colon_pos]
151 } else {
152 host
153 }
154 } else {
155 host
156 };
157
158 // Exact match (including IP addresses)
159 if host_for_comparison == pattern || host == pattern {
160 return true;
161 }
162
163 // Simple glob matching: "*.example.com" matches "api.example.com" but NOT "example.com"
164 if let Some(domain) = pattern.strip_prefix("*.") {
165 // Only match if there's a subdomain (must have a dot before the domain)
166 if host_for_comparison.ends_with(&format!(".{}", domain)) {
167 return true;
168 }
169 }
170 }
171
172 false
173}
174
175/// Parse IP address from a host string, handling `IPv6` brackets.
176/// `IPv6` addresses in URLs are bracketed: `[::1]`
177fn parse_ip_from_host(host: &str) -> Result<IpAddr> {
178 // Strip IPv6 brackets if present
179 let clean_host = if host.starts_with('[') && host.ends_with(']') {
180 &host[1..host.len() - 1]
181 } else {
182 host
183 };
184
185 clean_host.parse::<IpAddr>().map_err(|e| FraiseQLError::Validation {
186 message: format!("failed to parse IP address: {}", e),
187 path: None,
188 })
189}
190
191/// Validate that an IP address is not private/reserved.
192///
193/// The ranges are [`fraiseql_guard::net`]'s, shared with every other outbound
194/// guard in the workspace. This function owns only the error mapping.
195///
196/// The `IPv6` arm previously tested `is_loopback`/`is_unique_local`/
197/// `is_unicast_link_local` directly, none of which fire for an `IPv4`-mapped
198/// address, so `::ffff:169.254.169.254` was accepted and a dual-stack socket
199/// carried the request to the metadata service (#802).
200fn validate_ip(ip: &IpAddr) -> Result<()> {
201 if fraiseql_guard::net::is_blocked_ip(ip) {
202 return Err(FraiseQLError::Authorization {
203 message: format!("private/reserved IP address not allowed: {ip}"),
204 action: Some("http_request".to_string()),
205 resource: Some(ip.to_string()),
206 });
207 }
208 Ok(())
209}
210
211#[cfg(test)]
212#[allow(clippy::unwrap_used)] // Reason: tests use unwrap for concise assertions
213mod tests;