import { canonicalizeHostname, isPrivateAddress } from './ip-canon.js';
/**
 * Validate that a webhook URL does not target private/internal networks
 * (string-level SSRF prevention).
 *
 * Catches every IP-literal encoding `getaddrinfo` accepts: decimal
 * (`http://2130706433`), octal (`http://0177.0.0.1`), hex
 * (`http://0x7f.0.0.1`), short-form (`http://127.1`), IPv4-mapped IPv6
 * (`http://[::ffff:127.0.0.1]`), bracketed IPv6, and the reserved
 * `100.64.0.0/10` carrier-grade NAT range that previously slipped through.
 *
 * **Important caveat:** this only validates the URL **as written**. A hostname
 * like `attacker-controlled.tld` that resolves to a public IP at validation
 * time and to `127.0.0.1` at fetch time (DNS rebinding) still bypasses this
 * check — `safeFetch` calls `resolveAndCheck()` on top to defend against that.
 */
export function validateWebhookUrl(url) {
    let parsed;
    try {
        parsed = new URL(url);
    }
    catch {
        return { valid: false, error: 'Invalid URL' };
    }
    if (!['https:', 'http:'].includes(parsed.protocol)) {
        return { valid: false, error: 'Only HTTP(S) protocols are allowed' };
    }
    // Special-case the well-known names — they're DNS hostnames so the
    // canonicalizer can't catch them via numeric ranges.
    const lowerHost = parsed.hostname.toLowerCase();
    if (lowerHost === 'localhost' || lowerHost === 'ip6-localhost' || lowerHost === 'ip6-loopback') {
        return { valid: false, error: 'Localhost hostnames are not allowed' };
    }
    // metadata.google.internal and AWS' internal DNS shadow real metadata IPs
    // we already block — but the hostname can be redirected via /etc/hosts in
    // self-hosted contexts, so block it explicitly.
    if (lowerHost === 'metadata.google.internal' || lowerHost.endsWith('.internal')) {
        return { valid: false, error: 'Cloud metadata hostnames are not allowed' };
    }
    const canonical = canonicalizeHostname(parsed.hostname);
    if (!canonical.isHostname) {
        // Three sub-cases for `!isHostname`:
        //   1. Valid IP literal that's private  → reject with the range reason.
        //   2. Valid IP literal that's public   → allow (fall through).
        //   3. Malformed IP literal (e.g. `::1::1`, IPv6 with garbage) →
        //      `isValidIp` is false. Fail closed: the URL was clearly not a
        //      DNS hostname, so a `getaddrinfo` will reject it too — but
        //      worse, on some platforms it may resolve to *something*. Reject
        //      here rather than risk that ambiguity.
        if (!canonical.isValidIp) {
            return {
                valid: false,
                error: `Malformed IP literal in URL hostname: ${parsed.hostname}`,
            };
        }
        const denied = isPrivateAddress(canonical);
        if (denied) {
            return {
                valid: false,
                error: `Private/internal IP address rejected: ${denied.reason}`,
            };
        }
    }
    return { valid: true };
}
/**
 * Resolve a hostname's A/AAAA records and verify none of them land in a
 * private range. Pair with `validateWebhookUrl` to defend against DNS
 * rebinding — see `safeFetch`.
 */
export async function resolveAndCheck(hostname) {
    // If the hostname is already an IP literal, skip DNS and check directly.
    const directCanonical = canonicalizeHostname(hostname);
    if (!directCanonical.isHostname) {
        // Malformed IP literal — treat as unsafe. Without this, a bad IPv6
        // string (e.g. `::1::garbage`) would slip through the SSRF gate
        // because `isPrivateAddress` legitimately returns null when there's
        // no parsed IP to check.
        if (!directCanonical.isValidIp) {
            return { safe: false, resolvedIp: hostname, error: 'malformed IP literal' };
        }
        const denied = isPrivateAddress(directCanonical);
        if (denied) {
            return { safe: false, resolvedIp: hostname, error: denied.reason };
        }
        return { safe: true, resolvedIp: hostname };
    }
    const { resolve4, resolve6 } = await import('node:dns/promises');
    const ips = [];
    try {
        const v4 = await resolve4(hostname);
        ips.push(...v4);
    }
    catch {
        /* no A records */
    }
    try {
        const v6 = await resolve6(hostname);
        ips.push(...v6);
    }
    catch {
        /* no AAAA records */
    }
    if (ips.length === 0) {
        return { safe: false, error: `DNS resolution failed for ${hostname}` };
    }
    // Check EVERY resolved IP, not just the first — a multi-A-record response
    // could mix public + private IPs and we must reject if any single one is
    // private to defeat round-robin DNS rebinding.
    for (const ip of ips) {
        const c = canonicalizeHostname(ip);
        // DNS resolvers return canonical IP literals, so `isValidIp` should be
        // true. If a resolver ever returns something we can't parse, treat it
        // as unsafe rather than trusting it.
        if (!c.isValidIp) {
            return {
                safe: false,
                resolvedIp: ip,
                error: `Unparseable IP returned from DNS for ${hostname}: ${ip}`,
            };
        }
        const denied = isPrivateAddress(c);
        if (denied) {
            return {
                safe: false,
                resolvedIp: ip,
                error: `Resolved IP ${ip} is in a private range: ${denied.reason}`,
            };
        }
    }
    return { safe: true, resolvedIp: ips[0] };
}
//# sourceMappingURL=webhook.js.map