what-core 1.7.5

Core framework for What - an HTML-first web framework powered by Rust
Documentation
//! SSRF egress guard for template-driven `fetch` directives.
//!
//! Server-rendered pages can interpolate request-time input (`#query.*#`,
//! `#user.*#`, `#session.*#`) into `fetch` URLs, which the server then fetches.
//! Without a guard, `fetch.x = "#query.u#"` + `?u=http://169.254.169.254/…`
//! turns the server into a proxy for internal/metadata endpoints (SSRF).
//!
//! This is an **application-layer** guard: it resolves the target host and
//! rejects requests to loopback/private/link-local/ULA/metadata addresses. It is
//! not a substitute for network egress policy — it does not pin the resolved IP
//! to the socket, so a determined DNS-rebinding attacker with control of a
//! resolver could still win the resolve-vs-connect race. It closes the realistic
//! cases (literal internal IPs, private DNS records, redirect-to-internal) at a
//! complexity proportionate to a small self-hosted framework.
//!
//! Applied ONLY to `fetch`/datasource requests (attacker-influenceable), never to
//! framework-owned outbound (R2 uploads, auth backend) which use a fixed host.

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

/// Why an outbound fetch was refused.
#[derive(Debug, Clone)]
pub enum EgressError {
    /// Scheme other than http/https.
    BadScheme(String),
    /// URL carried `user:pass@` credentials.
    Userinfo,
    /// URL had no host.
    NoHost,
    /// Host carried an IPv6 zone id (`%eth0`).
    ZoneId,
    /// URL failed to parse.
    Parse(String),
    /// DNS resolution failed / returned nothing.
    Resolve(String),
    /// The host (or a resolved address) is in a forbidden range.
    Blocked(String),
    /// Exceeded the redirect hop limit.
    TooManyRedirects,
}

impl std::fmt::Display for EgressError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EgressError::BadScheme(s) => write!(f, "disallowed URL scheme '{s}' (only http/https)"),
            EgressError::Userinfo => write!(f, "URL must not contain credentials (user:pass@)"),
            EgressError::NoHost => write!(f, "URL has no host"),
            EgressError::ZoneId => write!(f, "URL host must not contain an IPv6 zone id"),
            EgressError::Parse(e) => write!(f, "invalid URL: {e}"),
            EgressError::Resolve(e) => write!(f, "could not resolve host: {e}"),
            EgressError::Blocked(w) => {
                write!(f, "refused fetch to non-public address: {w} (set [server] allow_private_fetch or fetch_allowed_hosts to override)")
            }
            EgressError::TooManyRedirects => write!(f, "too many redirects"),
        }
    }
}

/// True if an IPv4 address must not be fetched (loopback/private/link-local/
/// CGNAT/multicast/reserved/broadcast/documentation/unspecified/0.0.0.0-8).
/// Uses stable `Ipv4Addr` predicates plus explicit ranges the stdlib lacks.
fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
    let o = ip.octets();
    ip.is_loopback()            // 127.0.0.0/8
        || ip.is_private()      // 10/8, 172.16/12, 192.168/16
        || ip.is_link_local()   // 169.254.0.0/16
        || ip.is_broadcast()    // 255.255.255.255
        || ip.is_documentation()// 192.0.2/24, 198.51.100/24, 203.0.113/24
        || ip.is_unspecified()  // 0.0.0.0
        || o[0] == 0            // 0.0.0.0/8
        || (o[0] == 100 && (64..=127).contains(&o[1])) // 100.64/10 CGNAT
        || ip.is_multicast()    // 224.0.0.0/4
        || o[0] >= 240          // 240.0.0.0/4 reserved
}

/// True if an IPv6 address must not be fetched. IPv4-mapped/compatible forms are
/// normalized to IPv4 first so `::ffff:169.254.169.254` is caught by v4 rules.
fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
    if let Some(v4) = ip.to_ipv4_mapped() {
        return is_forbidden_v4(v4);
    }
    // `to_ipv4()` also maps deprecated `::a.b.c.d`; apply v4 rules there too,
    // but not to `::1`/`::` which map to 0.0.0.1/0.0.0.0 and are handled below.
    if let Some(v4) = ip.to_ipv4() {
        if !ip.is_loopback() && !ip.is_unspecified() {
            return is_forbidden_v4(v4);
        }
    }
    let seg = ip.segments();
    ip.is_loopback()                    // ::1
        || ip.is_unspecified()          // ::
        || ip.is_multicast()            // ff00::/8
        || (seg[0] & 0xfe00) == 0xfc00  // fc00::/7  unique-local
        || (seg[0] & 0xffc0) == 0xfe80  // fe80::/10 link-local
        || (seg[0] == 0x2001 && seg[1] == 0x0db8) // 2001:db8::/32 documentation
}

/// Whether an IP is forbidden as an outbound fetch target.
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => is_forbidden_v4(v4),
        IpAddr::V6(v6) => is_forbidden_v6(v6),
    }
}

/// Parse and structurally validate a fetch URL: http/https only, no credentials,
/// a host, and no IPv6 zone id. Returns the parsed URL (canonical host — numeric
/// IP encodings like `2130706433` / `0177.0.0.1` are normalized by the parser).
pub fn parse_fetch_url(raw: &str) -> Result<reqwest::Url, EgressError> {
    let url = reqwest::Url::parse(raw).map_err(|e| EgressError::Parse(e.to_string()))?;
    match url.scheme() {
        "http" | "https" => {}
        other => return Err(EgressError::BadScheme(other.to_string())),
    }
    if !url.username().is_empty() || url.password().is_some() {
        return Err(EgressError::Userinfo);
    }
    let host = url.host_str().ok_or(EgressError::NoHost)?;
    if host.contains('%') {
        return Err(EgressError::ZoneId);
    }
    Ok(url)
}

/// Async host validation: allowlist and broad opt-out first, then a literal-IP
/// check, else DNS resolution with every resolved address checked. `allow_private`
/// disables the IP check entirely; `allowed_hosts` exempts specific hostnames.
pub async fn validate_fetch_target(
    url: &reqwest::Url,
    allow_private: bool,
    allowed_hosts: &[String],
) -> Result<(), EgressError> {
    let host = url.host_str().ok_or(EgressError::NoHost)?;

    if allowed_hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
        return Ok(());
    }
    if allow_private {
        return Ok(());
    }

    // Literal IP host — check without touching DNS.
    if let Ok(ip) = host.parse::<IpAddr>() {
        return if is_forbidden_ip(ip) {
            Err(EgressError::Blocked(ip.to_string()))
        } else {
            Ok(())
        };
    }

    // Hostname — resolve and check every address.
    let port = url.port_or_known_default().unwrap_or(80);
    let addrs = tokio::net::lookup_host((host, port))
        .await
        .map_err(|e| EgressError::Resolve(e.to_string()))?;
    let mut saw_any = false;
    for sa in addrs {
        saw_any = true;
        if is_forbidden_ip(sa.ip()) {
            return Err(EgressError::Blocked(format!("{host} -> {}", sa.ip())));
        }
    }
    if !saw_any {
        return Err(EgressError::Resolve(format!("no addresses for {host}")));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn v4(s: &str) -> IpAddr {
        IpAddr::V4(s.parse().unwrap())
    }
    fn v6(s: &str) -> IpAddr {
        IpAddr::V6(s.parse().unwrap())
    }

    #[test]
    fn blocks_ipv4_internal_ranges() {
        for s in [
            "127.0.0.1", "127.1.2.3", "10.0.0.1", "172.16.5.4", "172.31.255.255",
            "192.168.1.1", "169.254.169.254", "0.0.0.0", "0.1.2.3",
            "100.64.0.1", "224.0.0.1", "240.0.0.1", "255.255.255.255", "192.0.2.5",
        ] {
            assert!(is_forbidden_ip(v4(s)), "{s} should be forbidden");
        }
    }

    #[test]
    fn allows_public_ipv4() {
        for s in ["1.1.1.1", "8.8.8.8", "93.184.216.34", "172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1"] {
            assert!(!is_forbidden_ip(v4(s)), "{s} should be allowed");
        }
    }

    #[test]
    fn blocks_ipv6_internal_and_mapped() {
        for s in ["::1", "::", "fe80::1", "fc00::1", "fd00::1", "2001:db8::1", "ff02::1"] {
            assert!(is_forbidden_ip(v6(s)), "{s} should be forbidden");
        }
        // IPv4-mapped metadata address must be caught by v4 rules
        assert!(is_forbidden_ip(v6("::ffff:169.254.169.254")));
        assert!(is_forbidden_ip(v6("::ffff:127.0.0.1")));
    }

    #[test]
    fn allows_public_ipv6() {
        assert!(!is_forbidden_ip(v6("2606:4700:4700::1111"))); // cloudflare
        assert!(!is_forbidden_ip(v6("::ffff:8.8.8.8")));
    }

    #[test]
    fn parse_rejects_bad_schemes_and_userinfo() {
        assert!(matches!(parse_fetch_url("file:///etc/passwd"), Err(EgressError::BadScheme(_))));
        assert!(matches!(parse_fetch_url("gopher://x/"), Err(EgressError::BadScheme(_))));
        assert!(matches!(parse_fetch_url("http://user:pass@example.com/"), Err(EgressError::Userinfo)));
        assert!(parse_fetch_url("https://example.com/path").is_ok());
    }

    #[test]
    fn parser_canonicalizes_numeric_hosts() {
        // Decimal and octal IPv4 encodings normalize to a literal the IP check catches.
        let u = parse_fetch_url("http://2130706433/").unwrap(); // 127.0.0.1
        assert_eq!(u.host_str().unwrap().parse::<IpAddr>().unwrap(), v4("127.0.0.1"));
        let u = parse_fetch_url("http://0177.0.0.1/").unwrap();
        assert_eq!(u.host_str().unwrap().parse::<IpAddr>().unwrap(), v4("127.0.0.1"));
    }

    #[tokio::test]
    async fn validate_blocks_literal_metadata_ip() {
        let u = parse_fetch_url("http://169.254.169.254/latest/meta-data/").unwrap();
        assert!(matches!(
            validate_fetch_target(&u, false, &[]).await,
            Err(EgressError::Blocked(_))
        ));
    }

    #[tokio::test]
    async fn validate_allows_when_opted_out_or_allowlisted() {
        let u = parse_fetch_url("http://127.0.0.1:3000/api").unwrap();
        // broad opt-out
        assert!(validate_fetch_target(&u, true, &[]).await.is_ok());
        // per-host allowlist
        assert!(validate_fetch_target(&u, false, &["127.0.0.1".to_string()])
            .await
            .is_ok());
        // neither → blocked
        assert!(validate_fetch_target(&u, false, &[]).await.is_err());
    }
}