//! SSRF guard for caller-supplied remote storage endpoints.
//!
//! `FetchAndWriteNeedle` accepts an S3 endpoint URL chosen by the caller and
//! dials it directly from the volume server, which typically has network
//! access to cluster-internal hosts. Without validation a caller could point
//! the server at loopback / link-local / RFC 1918 / cloud-metadata addresses
//! and read internal services (SSRF). This mirrors the Go volume server's
//! `validateRemoteEndpoint` (`weed/server/volume_grpc_remote.go`); operators
//! that legitimately fetch from private hosts opt out with
//! `-volume.allowUntrustedRemoteEndpoints`.
//!
//! NOTE: the Go server additionally pins the validated endpoint against DNS
//! rebinding with a custom dialer that re-checks the resolved IP at TCP connect
//! time (`guardedDialer`). The `aws-sdk-s3` client used here builds its own
//! connector, so that connect-time re-validation is not yet ported; the
//! up-front resolve-and-check below still blocks the common SSRF vectors. A
//! narrow TOCTOU window (a hostname that resolves to a public IP here and then
//! flips to a blocked one when the SDK dials) remains as a follow-up.

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

/// AWS/Azure/GCP IPv4 instance-metadata-service (IMDS) address. It is
/// link-local and thus already covered by [`is_link_local`], but is named
/// explicitly so the rejection reason is unambiguous in logs.
const IMDS_IPV4: IpAddr = IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254));

/// Hostnames that target cloud instance metadata services, blocked regardless
/// of how they resolve because some environments alias the IMDS address under
/// a name.
fn is_blocked_imds_host(host: &str) -> bool {
    matches!(host, "metadata.google.internal" | "metadata")
}

/// Whether `ip` is in a link-local range: IPv4 169.254.0.0/16, IPv6 fe80::/10
/// (unicast) and interface-local / link-local multicast (scope 1 and 2).
fn is_link_local(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => v4.is_link_local(),
        IpAddr::V6(v6) => {
            let seg0 = v6.segments()[0];
            if (seg0 & 0xffc0) == 0xfe80 {
                return true; // fe80::/10 link-local unicast
            }
            if (seg0 & 0xff00) == 0xff00 {
                // ffXS:: multicast; reject interface-local (scope 1) and
                // link-local (scope 2) the way Go's IsInterfaceLocalMulticast /
                // IsLinkLocalMulticast do.
                let scope = seg0 & 0x000f;
                return scope == 1 || scope == 2;
            }
            false
        }
    }
}

/// Whether `ip` is in a private range: IPv4 RFC 1918, IPv6 fc00::/7 unique-local
/// (matching Go's `net.IP.IsPrivate`).
fn is_private(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => v4.is_private(),
        IpAddr::V6(v6) => (v6.segments()[0] & 0xfe00) == 0xfc00,
    }
}

/// Whether `ip` is in the RFC 6598 carrier-grade NAT range (100.64.0.0/10).
/// The IPv4 private check does not cover CGNAT, so check it explicitly.
fn is_cgnat(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => {
            let o = v4.octets();
            o[0] == 100 && (o[1] & 0xc0) == 0x40 // second octet 64..=127
        }
        IpAddr::V6(_) => false,
    }
}

/// Returns the IPv4 address carried by an IPv6 transition address -- NAT64
/// 64:ff9b::/96 (RFC 6052), 6to4 2002::/16 (RFC 3056), Teredo 2001:0000::/32
/// (RFC 4380), and the deprecated IPv4-compatible ::/96 (RFC 4291) -- or None
/// when it is not one of those. IPv4-mapped ::ffff:0:0/96 is excluded; it is
/// already normalized via `to_ipv4_mapped`.
fn embedded_transition_ipv4(v6: Ipv6Addr) -> Option<Ipv4Addr> {
    let o = v6.octets();
    if o[0] == 0x00
        && o[1] == 0x64
        && o[2] == 0xff
        && o[3] == 0x9b
        && o[4..12].iter().all(|&b| b == 0)
    {
        return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
    }
    if o[0] == 0x20 && o[1] == 0x02 {
        return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
    }
    if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && o[3] == 0x00 {
        // Teredo obfuscates the client IPv4 as its ones' complement.
        return Some(Ipv4Addr::new(
            o[12] ^ 0xff,
            o[13] ^ 0xff,
            o[14] ^ 0xff,
            o[15] ^ 0xff,
        ));
    }
    if o[..12].iter().all(|&b| b == 0) {
        // IPv4-compatible ::a.b.c.d; :: and ::1 are already handled by the
        // unspecified / loopback checks before extraction runs.
        return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
    }
    None
}

/// Returns an error if `ip` is not safe to dial from a server that can reach
/// cluster-internal hosts. Mirrors Go's `checkBlockedIP`.
pub fn check_blocked_ip(endpoint: &str, ip: IpAddr) -> Result<(), String> {
    check_blocked_ip_policy(endpoint, ip, false)
}

/// Like [`check_blocked_ip`], but `allow_private` keeps RFC 1918 / CGNAT
/// reachable for callers whose target legitimately sits on an internal network
/// (peer volume servers), while still blocking loopback, link-local (IMDS) and
/// unspecified. Mirrors Go's `checkBlockedIPPolicy`.
pub fn check_blocked_ip_policy(endpoint: &str, ip: IpAddr, allow_private: bool) -> Result<(), String> {
    // Normalize IPv4-mapped IPv6 (`::ffff:a.b.c.d`) to its IPv4 form so the
    // IPv4 deny rules apply. The OS routes these to the embedded IPv4 address,
    // so without this `::ffff:127.0.0.1` / `::ffff:169.254.169.254` would slip
    // past the IPv6-only checks. Mirrors Go's reliance on net.IP.To4().
    let ip = match ip {
        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
            Some(v4) => IpAddr::V4(v4),
            None => IpAddr::V6(v6),
        },
        other => other,
    };
    if ip == IMDS_IPV4 {
        return Err(format!(
            "remote endpoint {:?} targets instance metadata service {}",
            endpoint, ip
        ));
    }
    if ip.is_loopback() {
        return Err(format!(
            "remote endpoint {:?} resolves to loopback address {}",
            endpoint, ip
        ));
    }
    if ip.is_unspecified() {
        return Err(format!(
            "remote endpoint {:?} resolves to unspecified address {}",
            endpoint, ip
        ));
    }
    if is_link_local(ip) {
        return Err(format!(
            "remote endpoint {:?} resolves to link-local address {}",
            endpoint, ip
        ));
    }
    if !allow_private {
        if is_private(ip) {
            return Err(format!(
                "remote endpoint {:?} resolves to private address {}",
                endpoint, ip
            ));
        }
        if is_cgnat(ip) {
            return Err(format!(
                "remote endpoint {:?} resolves to CGNAT address {}",
                endpoint, ip
            ));
        }
    }
    // IPv6 transition addresses embed an IPv4 destination that routes to the
    // same host wherever the matching relay exists (common in IPv6-only cloud).
    // to_ipv4_mapped above only covers ::ffff: mapped addresses, so pull the
    // embedded IPv4 out of the other forms and re-check it against the rules.
    if let IpAddr::V6(v6) = ip {
        if let Some(v4) = embedded_transition_ipv4(v6) {
            return check_blocked_ip_policy(endpoint, IpAddr::V4(v4), allow_private);
        }
    }
    Ok(())
}

/// Outcome of the synchronous, DNS-free portion of endpoint validation.
#[derive(Debug)]
enum HostCheck {
    /// Host was a literal IP and already passed [`check_blocked_ip`].
    Validated,
    /// Host is a name that must be resolved and each address checked.
    NeedsResolution(String),
}

/// Validate the scheme and host without touching DNS. Pure and unit-testable.
fn precheck_endpoint(endpoint: &str) -> Result<HostCheck, String> {
    let trimmed = endpoint.trim();
    if trimmed.is_empty() {
        return Err("remote endpoint is empty".to_string());
    }

    let Some(scheme_end) = trimmed.find("://") else {
        return Err(format!(
            "remote endpoint {:?} must use http or https",
            endpoint
        ));
    };
    let scheme = trimmed[..scheme_end].to_ascii_lowercase();
    if scheme != "http" && scheme != "https" {
        return Err(format!(
            "remote endpoint {:?} must use http or https, got {:?}",
            endpoint,
            &trimmed[..scheme_end]
        ));
    }

    // Authority is everything up to the first '/', '?', or '#'.
    let after = &trimmed[scheme_end + 3..];
    let authority_end = after
        .find(|c| c == '/' || c == '?' || c == '#')
        .unwrap_or(after.len());
    let authority = &after[..authority_end];

    // Strip optional userinfo ("user:pass@").
    let host_port = match authority.rfind('@') {
        Some(at) => &authority[at + 1..],
        None => authority,
    };

    // Extract the host, handling bracketed IPv6 literals and host:port.
    let host = if let Some(rest) = host_port.strip_prefix('[') {
        match rest.find(']') {
            Some(end) => &rest[..end],
            None => {
                return Err(format!(
                    "remote endpoint {:?} has a malformed IPv6 host",
                    endpoint
                ))
            }
        }
    } else {
        match host_port.find(':') {
            Some(colon) => &host_port[..colon],
            None => host_port,
        }
    };

    if host.is_empty() {
        return Err(format!("remote endpoint {:?} has no host", endpoint));
    }

    if is_blocked_imds_host(&host.to_ascii_lowercase()) {
        return Err(format!(
            "remote endpoint {:?} targets instance metadata service",
            endpoint
        ));
    }

    if let Ok(ip) = host.parse::<IpAddr>() {
        check_blocked_ip(endpoint, ip)?;
        return Ok(HostCheck::Validated);
    }

    Ok(HostCheck::NeedsResolution(host.to_string()))
}

/// Resolve `host` to its addresses with a short timeout, matching Go's 2s
/// resolver deadline.
async fn resolve_host(host: &str) -> Result<Vec<IpAddr>, String> {
    let lookup = tokio::net::lookup_host((host, 0u16));
    let addrs = tokio::time::timeout(std::time::Duration::from_secs(2), lookup)
        .await
        .map_err(|_| format!("resolve remote endpoint host {:?}: timed out", host))?
        .map_err(|e| format!("resolve remote endpoint host {:?}: {}", host, e))?;
    Ok(addrs.map(|sock| sock.ip()).collect())
}

/// Returns an error if `endpoint` is not safe to dial from a server with access
/// to cluster-internal hosts. Rejects empty / non-http(s) endpoints,
/// loopback / unspecified / link-local / RFC 1918 / CGNAT addresses, and
/// well-known IMDS hostnames. Hostnames are resolved and every returned address
/// is checked. Mirrors Go's `validateRemoteEndpoint`.
pub async fn validate_remote_endpoint(endpoint: &str) -> Result<(), String> {
    match precheck_endpoint(endpoint)? {
        HostCheck::Validated => Ok(()),
        HostCheck::NeedsResolution(host) => {
            let addrs = resolve_host(&host).await?;
            if addrs.is_empty() {
                return Err(format!(
                    "resolve remote endpoint host {:?}: no addresses",
                    host
                ));
            }
            for ip in addrs {
                check_blocked_ip(endpoint, ip)?;
            }
            Ok(())
        }
    }
}

/// Returns an error if `target` could redirect a replica upload away from a peer
/// volume server. The target must be a bare `host:port` -- a scheme, userinfo,
/// path, query or fragment can smuggle a different destination into the
/// formatted upload URL -- whose host is not loopback, link-local (IMDS) or
/// unspecified. Cluster peers legitimately sit on private networks, so RFC 1918
/// / CGNAT are allowed. Mirrors Go's `validateReplicaTarget`.
pub async fn validate_replica_target(target: &str) -> Result<(), String> {
    let trimmed = target.trim();
    if trimmed.is_empty() {
        return Err("replica target is empty".to_string());
    }
    if trimmed.contains("://") || trimmed.contains(['/', '?', '#', '@', '\\']) {
        return Err(format!("replica target {:?} must be a bare host:port", target));
    }

    // Require an explicit host:port, handling `[IPv6]:port`. A bracketless IPv6
    // literal (which carries its own colons) is rejected; peers are addressed as
    // `[ipv6]:port`, matching Go's net.SplitHostPort.
    let host = if let Some(rest) = trimmed.strip_prefix('[') {
        match rest.split_once(']') {
            Some((h, port)) if port.starts_with(':') && port.len() > 1 => h,
            _ => return Err(format!("replica target {:?} must be a bare host:port", target)),
        }
    } else {
        match trimmed.rsplit_once(':') {
            Some((h, port)) if !port.is_empty() && !h.contains(':') => h,
            _ => return Err(format!("replica target {:?} must be a bare host:port", target)),
        }
    };

    if host.is_empty() {
        return Err(format!("replica target {:?} has no host", target));
    }
    if is_blocked_imds_host(&host.to_ascii_lowercase()) {
        return Err(format!(
            "replica target {:?} targets instance metadata service",
            target
        ));
    }
    if let Ok(ip) = host.parse::<IpAddr>() {
        return check_blocked_ip_policy(target, ip, true);
    }

    let addrs = resolve_host(host).await?;
    if addrs.is_empty() {
        return Err(format!("resolve replica target host {:?}: no addresses", host));
    }
    for ip in addrs {
        check_blocked_ip_policy(target, ip, true)?;
    }
    Ok(())
}

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

    fn ip(s: &str) -> IpAddr {
        s.parse().unwrap()
    }

    #[test]
    fn rejects_blocked_literal_endpoints() {
        let cases = [
            ("http://127.0.0.1:8080", "loopback"),
            ("http://[::1]:8080", "loopback"),
            ("http://169.254.169.254/", "metadata"),
            ("http://0.0.0.0/", "unspecified"),
            ("http://[fe80::1]/", "link-local"),
            ("http://10.0.0.1/", "private"),
            ("http://172.16.5.5/", "private"),
            ("http://192.168.0.1/", "private"),
            ("http://100.64.0.1/", "CGNAT"),
        ];
        for (endpoint, want) in cases {
            let err = precheck_endpoint(endpoint).expect_err(endpoint);
            assert!(err.contains(want), "{endpoint}: {err:?} missing {want:?}");
        }
    }

    #[test]
    fn rejects_empty_and_bad_scheme() {
        assert!(precheck_endpoint("").unwrap_err().contains("empty"));
        assert!(precheck_endpoint("ftp://example.com/")
            .unwrap_err()
            .contains("http or https"));
        assert!(precheck_endpoint("example.com/")
            .unwrap_err()
            .contains("http or https"));
    }

    #[test]
    fn rejects_imds_hostnames() {
        assert!(precheck_endpoint("http://metadata.google.internal/")
            .unwrap_err()
            .contains("metadata service"));
        assert!(precheck_endpoint("http://metadata/")
            .unwrap_err()
            .contains("metadata service"));
    }

    #[test]
    fn accepts_public_literal_and_defers_hostname() {
        assert!(matches!(
            precheck_endpoint("https://52.216.10.10/"),
            Ok(HostCheck::Validated)
        ));
        match precheck_endpoint("https://s3.us-east-1.amazonaws.com/") {
            Ok(HostCheck::NeedsResolution(host)) => {
                assert_eq!(host, "s3.us-east-1.amazonaws.com")
            }
            other => panic!("expected resolution, got {other:?}"),
        }
    }

    #[test]
    fn check_blocked_ip_matches_resolved_categories() {
        // Mirror Go's "host resolves to X" cases at the address level.
        assert!(check_blocked_ip("e", ip("127.0.0.1"))
            .unwrap_err()
            .contains("loopback"));
        assert!(check_blocked_ip("e", ip("169.254.10.20"))
            .unwrap_err()
            .contains("link-local"));
        assert!(check_blocked_ip("e", ip("10.1.2.3"))
            .unwrap_err()
            .contains("private"));
        assert!(check_blocked_ip("e", ip("172.20.0.5"))
            .unwrap_err()
            .contains("private"));
        assert!(check_blocked_ip("e", ip("192.168.1.1"))
            .unwrap_err()
            .contains("private"));
        assert!(check_blocked_ip("e", ip("100.64.0.42"))
            .unwrap_err()
            .contains("CGNAT"));
        assert!(check_blocked_ip("e", ip("fc00::1"))
            .unwrap_err()
            .contains("private"));
        assert!(check_blocked_ip("e", ip("52.216.10.10")).is_ok());
        assert!(check_blocked_ip("e", ip("2606:4700:4700::1111")).is_ok());
    }

    #[test]
    fn rejects_ipv6_transition_addresses() {
        // Every transition form encoding an internal IPv4 must be blocked.
        assert!(
            check_blocked_ip("e", ip("64:ff9b::a9fe:a9fe")) // NAT64 -> IMDS
                .unwrap_err()
                .contains("metadata")
        );
        assert!(
            check_blocked_ip("e", ip("64:ff9b::7f00:1")) // NAT64 -> loopback
                .unwrap_err()
                .contains("loopback")
        );
        assert!(
            check_blocked_ip("e", ip("2002:a00:1::")) // 6to4 -> 10.0.0.1
                .unwrap_err()
                .contains("private")
        );
        assert!(
            check_blocked_ip("e", ip("2001:0:4136:e378:8000:63bf:80ff:fffe")) // Teredo -> loopback
                .unwrap_err()
                .contains("loopback")
        );
        assert!(
            check_blocked_ip("e", ip("::7f00:1")) // IPv4-compatible -> loopback
                .unwrap_err()
                .contains("loopback")
        );
        // A NAT64 address outside the 64:ff9b::/96 well-known prefix is not
        // decoded (its embedded IPv4 lives elsewhere), so it is left as-is.
        assert!(check_blocked_ip("e", ip("64:ff9b:1::a9fe:a9fe")).is_ok());
        // Every transition form embedding a public IPv4 (8.8.8.8) still passes:
        // NAT64, 6to4, Teredo, IPv4-compatible.
        assert!(check_blocked_ip("e", ip("64:ff9b::808:808")).is_ok());
        assert!(check_blocked_ip("e", ip("2002:808:808::")).is_ok());
        assert!(check_blocked_ip("e", ip("2001::f7f7:f7f7")).is_ok());
        assert!(check_blocked_ip("e", ip("::808:808")).is_ok());
        // Bracketed transition literal via the full endpoint path.
        assert!(precheck_endpoint("http://[64:ff9b::a9fe:a9fe]/")
            .unwrap_err()
            .contains("metadata"));
    }

    #[test]
    fn rejects_ipv4_mapped_ipv6() {
        // IPv4-mapped IPv6 must be unmapped so the IPv4 rules catch it.
        assert!(check_blocked_ip("e", ip("::ffff:127.0.0.1"))
            .unwrap_err()
            .contains("loopback"));
        assert!(check_blocked_ip("e", ip("::ffff:169.254.169.254"))
            .unwrap_err()
            .contains("metadata"));
        assert!(check_blocked_ip("e", ip("::ffff:10.0.0.1"))
            .unwrap_err()
            .contains("private"));
        // A mapped public address still passes, and genuine IPv6 loopback is
        // still caught by the V6 path.
        assert!(check_blocked_ip("e", ip("::ffff:52.216.10.10")).is_ok());
        assert!(check_blocked_ip("e", ip("::1"))
            .unwrap_err()
            .contains("loopback"));
        // Bracketed mapped literal via the full endpoint path.
        assert!(precheck_endpoint("http://[::ffff:127.0.0.1]/")
            .unwrap_err()
            .contains("loopback"));
    }

    #[test]
    fn check_blocked_ip_policy_allows_private_peers() {
        // The replica leg targets peer volume servers, which may be private.
        assert!(check_blocked_ip_policy("e", ip("10.0.0.7"), true).is_ok());
        assert!(check_blocked_ip_policy("e", ip("192.168.1.5"), true).is_ok());
        assert!(check_blocked_ip_policy("e", ip("100.64.0.42"), true).is_ok());
        // Loopback / IMDS / unspecified stay blocked even when private is allowed.
        assert!(check_blocked_ip_policy("e", ip("127.0.0.1"), true)
            .unwrap_err()
            .contains("loopback"));
        assert!(check_blocked_ip_policy("e", ip("169.254.169.254"), true)
            .unwrap_err()
            .contains("metadata"));
        assert!(check_blocked_ip_policy("e", ip("0.0.0.0"), true)
            .unwrap_err()
            .contains("unspecified"));
    }

    #[tokio::test]
    async fn validate_replica_target_rejects_and_allows() {
        // A path plus a trailing ?a= would otherwise swallow ?type=replicate.
        assert!(validate_replica_target("127.0.0.1:7000/status/x/?a=")
            .await
            .unwrap_err()
            .contains("bare host:port"));
        assert!(validate_replica_target("http://10.0.0.7:8080")
            .await
            .unwrap_err()
            .contains("bare host:port"));
        assert!(validate_replica_target("user@10.0.0.7:8080")
            .await
            .unwrap_err()
            .contains("bare host:port"));
        assert!(validate_replica_target("10.0.0.7")
            .await
            .unwrap_err()
            .contains("bare host:port"));
        assert!(validate_replica_target("peer.example.com")
            .await
            .unwrap_err()
            .contains("bare host:port"));
        assert!(validate_replica_target("127.0.0.1:8080")
            .await
            .unwrap_err()
            .contains("loopback"));
        assert!(validate_replica_target("[::1]:8080")
            .await
            .unwrap_err()
            .contains("loopback"));
        assert!(validate_replica_target("169.254.169.254:80")
            .await
            .unwrap_err()
            .contains("metadata"));
        assert!(validate_replica_target("metadata:80")
            .await
            .unwrap_err()
            .contains("metadata"));
        assert!(validate_replica_target("")
            .await
            .unwrap_err()
            .contains("empty"));
        // Legitimate peer volume servers on private networks pass.
        assert!(validate_replica_target("10.0.0.7:8080").await.is_ok());
        assert!(validate_replica_target("192.168.1.5:8080").await.is_ok());
        assert!(validate_replica_target("[fd00::1]:8080").await.is_ok());
    }
}
