Skip to main content

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;