fraiseql-functions 2.13.1

Serverless functions runtime for FraiseQL — WASM and Deno backends
Documentation
//! HTTP request validation with SSRF protection.
//!
//! This module validates outbound HTTP requests to prevent Server-Side Request Forgery (SSRF)
//! attacks by:
//! - Enforcing a domain allowlist
//! - Blocking private IP addresses (RFC 1918, loopback, link-local)
//! - Blocking `IPv6` private ranges

use std::net::IpAddr;

use fraiseql_error::{FraiseQLError, Result};

/// Configuration for HTTP client validation.
#[derive(Debug, Clone)]
pub struct HttpClientConfig {
    /// Allowed domains for outbound requests (glob patterns).
    ///
    /// Deny-by-default: an empty list (the default) permits **no** outbound
    /// hosts — a caller must explicitly opt in to each domain. Glob patterns
    /// such as `"*.example.com"` are supported; `"*"` allows all hosts and is
    /// NOT recommended for production.
    pub allowed_domains: Vec<String>,

    /// Maximum response body size in bytes.
    pub max_response_bytes: usize,

    /// Connect timeout in milliseconds.
    pub connect_timeout_ms: u64,

    /// Read timeout in milliseconds.
    pub read_timeout_ms: u64,
}

impl Default for HttpClientConfig {
    fn default() -> Self {
        Self {
            // Deny-by-default (fail-closed): no host is allowed until the caller
            // explicitly populates the allowlist.
            allowed_domains:    vec![],
            max_response_bytes: 10 * 1024 * 1024, // 10 MB
            connect_timeout_ms: 5000,
            read_timeout_ms:    30000,
        }
    }
}

/// Validate an outbound URL for SSRF attacks.
///
/// Checks:
/// 1. Domain is in the allowlist (deny-by-default; supports glob patterns)
/// 2. Literal IP address in the host is not private/reserved (RFC 1918, 127.0.0.0/8,
///    169.254.0.0/16, etc.)
/// 3. The host's DNS-resolved addresses are not private/reserved — closes the DNS-rebinding hole
///    where a public name resolves to an internal IP
/// 4. `IPv6` addresses are not private (loopback, link-local, ULA)
///
/// # Arguments
///
/// * `url` - The URL to validate
/// * `config` - HTTP client configuration with allowlist
///
/// # Returns
///
/// - `Ok(())` if the URL is safe to request
/// - `Err` if the URL is blocked by allowlist or resolves to a private IP
///
/// # Errors
///
/// Returns `Err` if the URL is malformed, blocked by the allowlist, fails DNS
/// resolution, or resolves to a private/reserved IP address.
pub async fn validate_outbound_url(url: &str, config: &HttpClientConfig) -> Result<()> {
    // Parse the URL
    let parsed_url = reqwest::Url::parse(url).map_err(|e| FraiseQLError::Validation {
        message: format!("invalid URL: {}", e),
        path:    None,
    })?;

    // Check domain allowlist
    let host = parsed_url.host_str().ok_or_else(|| FraiseQLError::Validation {
        message: "URL has no host".to_string(),
        path:    None,
    })?;

    // Check if host matches allowlist
    if !is_domain_allowed(host, &config.allowed_domains) {
        return Err(FraiseQLError::Authorization {
            message:  format!("domain '{}' not in allowlist", host),
            action:   Some("http_request".to_string()),
            resource: Some(host.to_string()),
        });
    }

    // Check for private/reserved IPs in a literal-IP host.
    if let Ok(ip) = parse_ip_from_host(host) {
        validate_ip(&ip)?;
        // A literal IP cannot be rebound via DNS; the literal check above is
        // sufficient and `lookup_host` on a literal would only echo it back.
        return Ok(());
    }

    // DNS-rebinding guard: resolve the host and reject if ANY address is
    // private/reserved. A public name that resolves to an internal IP is the
    // classic SSRF bypass; checking literal IPs alone does not cover it.
    let port = parsed_url.port_or_known_default().unwrap_or(443);
    let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host, port))
        .await
        .map_err(|e| FraiseQLError::Validation {
            message: format!("DNS resolution failed for host '{host}': {e}"),
            path:    None,
        })?
        .collect();
    if addrs.is_empty() {
        return Err(FraiseQLError::Validation {
            message: format!("DNS resolved to no addresses for host '{host}'"),
            path:    None,
        });
    }
    for addr in &addrs {
        validate_ip(&addr.ip())?;
    }

    Ok(())
}

/// Check if a host (domain or IP) is in the allowlist.
/// Supports glob patterns: "*" matches all, "*.example.com" matches subdomains.
/// Also supports IP addresses (with or without port).
fn is_domain_allowed(host: &str, allowlist: &[String]) -> bool {
    for pattern in allowlist {
        if pattern == "*" {
            return true;
        }

        // Extract IP from host (remove port if present)
        let host_for_comparison = if let Some(colon_pos) = host.rfind(':') {
            // Only strip port if this looks like host:port (not IPv6)
            if !host.starts_with('[') {
                &host[..colon_pos]
            } else {
                host
            }
        } else {
            host
        };

        // Exact match (including IP addresses)
        if host_for_comparison == pattern || host == pattern {
            return true;
        }

        // Simple glob matching: "*.example.com" matches "api.example.com" but NOT "example.com"
        if let Some(domain) = pattern.strip_prefix("*.") {
            // Only match if there's a subdomain (must have a dot before the domain)
            if host_for_comparison.ends_with(&format!(".{}", domain)) {
                return true;
            }
        }
    }

    false
}

/// Parse IP address from a host string, handling `IPv6` brackets.
/// `IPv6` addresses in URLs are bracketed: `[::1]`
fn parse_ip_from_host(host: &str) -> Result<IpAddr> {
    // Strip IPv6 brackets if present
    let clean_host = if host.starts_with('[') && host.ends_with(']') {
        &host[1..host.len() - 1]
    } else {
        host
    };

    clean_host.parse::<IpAddr>().map_err(|e| FraiseQLError::Validation {
        message: format!("failed to parse IP address: {}", e),
        path:    None,
    })
}

/// Validate that an IP address is not private/reserved.
/// Blocks:
/// - 127.0.0.0/8 (loopback)
/// - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC 1918 private)
/// - 169.254.0.0/16 (link-local)
/// - `::1` (`IPv6` loopback)
/// - `fc00::/7` (`IPv6` unique local addresses)
/// - `fe80::/10` (`IPv6` link-local)
fn validate_ip(ip: &IpAddr) -> Result<()> {
    match ip {
        IpAddr::V4(v4) => {
            if v4.is_loopback()
                || v4.is_private()
                || v4.is_link_local()
                || v4.is_broadcast()
                || is_ipv4_reserved(*v4)
            {
                return Err(FraiseQLError::Authorization {
                    message:  format!("private/reserved IP address not allowed: {}", v4),
                    action:   Some("http_request".to_string()),
                    resource: Some(v4.to_string()),
                });
            }
            Ok(())
        },
        IpAddr::V6(v6) => {
            if v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local() {
                return Err(FraiseQLError::Authorization {
                    message:  format!("private IPv6 address not allowed: {}", v6),
                    action:   Some("http_request".to_string()),
                    resource: Some(v6.to_string()),
                });
            }
            Ok(())
        },
    }
}

/// Check if an `IPv4` address is reserved.
///
/// Covers the reserved ranges not already caught by the dedicated
/// `is_loopback`/`is_private`/`is_link_local` checks in [`validate_ip`]:
/// - `0.0.0.0/8` "this network"
/// - `100.64.0.0/10` CGNAT / shared address space (RFC 6598)
/// - `240.0.0.0/4` reserved-for-future-use (includes the `255.255.255.255` broadcast)
///
/// This is a workaround since `Ipv4Addr::is_reserved()` is unstable. It is kept
/// precise on purpose: an earlier version blocked the whole `100..=127` first
/// octet, which wrongly rejected large public ranges (e.g. Cloudflare `104.x`).
/// Loopback `127.0.0.0/8` remains blocked via `is_loopback()`.
const fn is_ipv4_reserved(ip: std::net::Ipv4Addr) -> bool {
    let octets = ip.octets();
    octets[0] == 0                                              // 0.0.0.0/8 this-network
        || (octets[0] == 100 && (octets[1] & 0b1100_0000) == 0b0100_0000) // 100.64.0.0/10 CGNAT
        || matches!(octets[0], 240..=255) // 240.0.0.0/4 reserved/future + broadcast
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // Reason: tests use unwrap for concise assertions
mod tests;