"""Security utilities for Flowire nodes.

Provides SSRF protection by blocking requests to cloud metadata endpoints.
Node developers should call ``validate_url`` before making any outbound HTTP
request where the URL (or base URL) is user-controlled.
"""

import ipaddress
import socket
from urllib.parse import urlparse

# Cloud metadata endpoints — when Flowire runs on a cloud VM, these endpoints
# return IAM credentials without authentication. A workflow targeting these
# could leak cloud credentials to any authenticated user.
# See: https://hackingthe.cloud/aws/exploitation/ec2-metadata-ssrf/
# See: https://www.resecurity.com/blog/article/ssrf-to-aws-metadata-exposure-how-attackers-steal-cloud-credentials
_BLOCKED_HOSTS = {
    "169.254.169.254",  # AWS/GCP/Azure/DigitalOcean instance metadata
    "169.254.170.2",  # AWS ECS task metadata
    "metadata.google.internal",  # GCP metadata alternative hostname
    "fd00:ec2::254",  # AWS EC2 IPv6 instance metadata
}

# Derived from _BLOCKED_HOSTS: entries that are valid IPs, used for
# post-resolution checks (catches alternate representations and CNAME aliases).
_BLOCKED_IPS: set[ipaddress.IPv4Address | ipaddress.IPv6Address] = set()
for _host in _BLOCKED_HOSTS:
    try:
        _BLOCKED_IPS.add(ipaddress.ip_address(_host))
    except ValueError:
        pass  # DNS name like "metadata.google.internal"


def validate_url(url: str) -> None:
    """Block requests to cloud metadata endpoints.

    Internal/private IPs are intentionally allowed since Flowire is
    self-hosted and users commonly target internal services.

    Validates against bypass techniques:
    - Alternate IP representations (decimal, hex, octal, IPv6-mapped)
    - Trailing-dot DNS bypass (e.g., "169.254.169.254.")
    - DNS CNAME aliases pointing to metadata IPs

    Raises:
        ValueError: If the URL targets a blocked endpoint.
    """
    parsed = urlparse(url)

    if parsed.scheme not in ("http", "https"):
        raise ValueError(f"Unsupported URL scheme: {parsed.scheme!r}. Only http and https are allowed.")

    hostname = parsed.hostname
    if not hostname:
        raise ValueError("URL is missing a hostname.")

    # Strip trailing dot (FQDN notation) to prevent bypass like "169.254.169.254."
    hostname = hostname.rstrip(".")

    # Direct hostname check (catches literal strings like "metadata.google.internal")
    if hostname in _BLOCKED_HOSTS:
        raise ValueError(f"Requests to {hostname} are blocked (cloud metadata endpoint).")

    # Resolve hostname to canonical IP to catch alternate representations
    # (decimal 2852039166, hex 0xa9fea9fe, octal 0251.0376.0251.0376,
    # IPv6-mapped ::ffff:169.254.169.254) and DNS CNAME aliases.
    try:
        addr = ipaddress.ip_address(hostname)
        # If IPv6-mapped IPv4 (::ffff:x.x.x.x), extract the underlying IPv4
        if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
            addr = addr.ipv4_mapped
        if addr in _BLOCKED_IPS:
            raise ValueError(f"Requests to {hostname} are blocked (cloud metadata endpoint).")
    except ValueError as e:
        if "blocked" in str(e):
            raise
        # Not a literal IP — it's a DNS name; resolve it
        try:
            infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
            for info in infos:
                resolved_ip = ipaddress.ip_address(info[4][0])
                if isinstance(resolved_ip, ipaddress.IPv6Address) and resolved_ip.ipv4_mapped:
                    resolved_ip = resolved_ip.ipv4_mapped
                if resolved_ip in _BLOCKED_IPS:
                    raise ValueError(
                        f"Requests to {hostname} are blocked (resolves to cloud metadata endpoint)."
                    )
        except socket.gaierror:
            pass  # DNS resolution failed; let the HTTP client handle it
