/**
 * notifications/egress-guard.ts
 *
 * SSRF guard for outbound webhook delivery. Requires https and rejects any
 * URL whose host resolves into a reserved/internal IP range (RFC 1918 private,
 * loopback, link-local — including the 169.254.169.254 cloud-metadata endpoint,
 * IPv6 unique-local, 0.0.0.0/8, multicast). The resolved IP is checked at call
 * time so a DNS-rebinding host that points public-then-internal is still caught.
 *
 * The blocked ranges are fixed RFC/IANA constants — nothing is operator-tuned.
 */
import { lookup } from "node:dns/promises";
export class EgressBlockedError extends Error {
    constructor(message) {
        super(message);
        this.name = "EgressBlockedError";
    }
}
const defaultResolve = async (hostname) => {
    const { address } = await lookup(hostname);
    return address;
};
export async function assertSafeEgressUrl(raw, options = {}) {
    let url;
    try {
        url = new URL(raw);
    }
    catch {
        throw new EgressBlockedError(`invalid callback URL: ${raw}`);
    }
    if (url.protocol !== "https:") {
        throw new EgressBlockedError(`callback URL must be https, got ${url.protocol}`);
    }
    const resolve = options.resolve ?? defaultResolve;
    const hostname = url.hostname.replace(/^\[|\]$/g, "");
    const address = await resolve(hostname);
    if (isReservedIp(address)) {
        throw new EgressBlockedError(`callback URL resolves to a reserved address: ${address}`);
    }
}
/** True when an IP literal falls in a reserved/internal range. */
export function isReservedIp(ip) {
    if (ip.includes(":"))
        return isReservedIpv6(ip);
    return isReservedIpv4(ip);
}
function isReservedIpv4(ip) {
    const parts = ip.split(".").map((n) => Number(n));
    if (parts.length !== 4 ||
        parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
        return true; // unparseable → treat as unsafe
    }
    const [a, b] = parts;
    if (a === 0)
        return true; // 0.0.0.0/8
    if (a === 10)
        return true; // 10/8 private
    if (a === 127)
        return true; // loopback
    if (a === 169 && b === 254)
        return true; // link-local (incl. metadata)
    if (a === 172 && b >= 16 && b <= 31)
        return true; // 172.16/12 private
    if (a === 192 && b === 168)
        return true; // 192.168/16 private
    if (a >= 224)
        return true; // multicast + reserved
    return false;
}
function isReservedIpv6(ip) {
    const addr = ip.toLowerCase().replace(/^\[|\]$/g, "");
    if (addr === "::1" || addr === "::")
        return true; // loopback / unspecified
    if (addr.startsWith("fe8") ||
        addr.startsWith("fe9") ||
        addr.startsWith("fea") ||
        addr.startsWith("feb")) {
        return true; // fe80::/10 link-local
    }
    if (addr.startsWith("fc") || addr.startsWith("fd"))
        return true; // fc00::/7 unique-local
    if (addr.startsWith("ff"))
        return true; // ff00::/8 multicast
    // IPv4-mapped (::ffff:a.b.c.d) → defer to the v4 check.
    const mapped = addr.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
    if (mapped)
        return isReservedIpv4(mapped[1]);
    return false;
}
