/**
 * IP canonicalization for SSRF defense.
 *
 * The previous regex-based approach in `webhook.ts` only matched the literal
 * dotted-quad form. It missed every encoding that `getaddrinfo` (and therefore
 * `fetch`) accepts:
 *
 *   - Decimal:        http://2130706433/        → 127.0.0.1
 *   - Octal:          http://0177.0.0.1/        → 127.0.0.1
 *   - Hex:            http://0x7f.0.0.1/        → 127.0.0.1
 *   - Short-form:     http://127.1/             → 127.0.0.1
 *   - IPv4-mapped v6: http://[::ffff:127.0.0.1] → 127.0.0.1
 *   - IPv4-compat v6: http://[::127.0.0.1]      → 127.0.0.1
 *
 * This module canonicalizes any of those forms to a 4-octet IPv4 string (or
 * a normalized 16-byte IPv6 address), then range-checks against a complete
 * private/internal block list including AWS/GCP/Azure metadata endpoints and
 * the carrier-grade NAT range.
 */
const PRIVATE_IPV4_RANGES = [
    // 0.0.0.0/8         "this network"
    { network: 0x00_00_00_00, mask: 0xff_00_00_00, reason: '0.0.0.0/8 (this network)' },
    // 10.0.0.0/8        RFC1918 private
    { network: 0x0a_00_00_00, mask: 0xff_00_00_00, reason: '10.0.0.0/8 (RFC1918 private)' },
    // 100.64.0.0/10     RFC6598 carrier-grade NAT
    { network: 0x64_40_00_00, mask: 0xff_c0_00_00, reason: '100.64.0.0/10 (CGNAT)' },
    // 127.0.0.0/8       loopback
    { network: 0x7f_00_00_00, mask: 0xff_00_00_00, reason: '127.0.0.0/8 (loopback)' },
    // 169.254.0.0/16    link-local + cloud metadata (AWS 169.254.169.254 is in here)
    { network: 0xa9_fe_00_00, mask: 0xff_ff_00_00, reason: '169.254.0.0/16 (link-local + metadata)' },
    // 172.16.0.0/12     RFC1918 private
    { network: 0xac_10_00_00, mask: 0xff_f0_00_00, reason: '172.16.0.0/12 (RFC1918 private)' },
    // 192.0.0.0/24      RFC6890 IETF protocol assignments
    { network: 0xc0_00_00_00, mask: 0xff_ff_ff_00, reason: '192.0.0.0/24 (IETF reserved)' },
    // 192.0.2.0/24      TEST-NET-1 (documentation)
    { network: 0xc0_00_02_00, mask: 0xff_ff_ff_00, reason: '192.0.2.0/24 (TEST-NET-1)' },
    // 192.168.0.0/16    RFC1918 private
    { network: 0xc0_a8_00_00, mask: 0xff_ff_00_00, reason: '192.168.0.0/16 (RFC1918 private)' },
    // 198.18.0.0/15     benchmark
    { network: 0xc6_12_00_00, mask: 0xff_fe_00_00, reason: '198.18.0.0/15 (benchmark)' },
    // 198.51.100.0/24   TEST-NET-2
    { network: 0xc6_33_64_00, mask: 0xff_ff_ff_00, reason: '198.51.100.0/24 (TEST-NET-2)' },
    // 203.0.113.0/24    TEST-NET-3
    { network: 0xcb_00_71_00, mask: 0xff_ff_ff_00, reason: '203.0.113.0/24 (TEST-NET-3)' },
    // 224.0.0.0/4       multicast
    { network: 0xe0_00_00_00, mask: 0xf0_00_00_00, reason: '224.0.0.0/4 (multicast)' },
    // 240.0.0.0/4       reserved (includes 255.255.255.255 broadcast)
    { network: 0xf0_00_00_00, mask: 0xf0_00_00_00, reason: '240.0.0.0/4 (reserved)' },
];
/**
 * Canonicalize a URL hostname into either a normalized IP address or a
 * passthrough hostname. Handles every IPv4 encoding accepted by `inet_aton`
 * (decimal, octal, hex, short-form), IPv4-mapped IPv6, and bracketed IPv6.
 */
export function canonicalizeHostname(rawHostname) {
    const raw = rawHostname;
    // Strip surrounding brackets (URLs use `[::1]` for IPv6 hosts).
    let host = raw;
    if (host.startsWith('[') && host.endsWith(']')) {
        host = host.slice(1, -1);
    }
    // Try IPv6 first — only IPv6 contains ':'.
    if (host.includes(':')) {
        const ipv6 = parseIPv6(host);
        if (ipv6) {
            // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) both
            // tunnel an IPv4 address through IPv6 — collapse to the underlying v4
            // so the v4 range check catches them.
            const tunnelled = extractIPv4FromIPv6(ipv6);
            if (tunnelled !== null) {
                return { raw, ipv4: ipv4ToString(tunnelled), isHostname: false, isValidIp: true };
            }
            return { raw, ipv6: formatIPv6(ipv6), isHostname: false, isValidIp: true };
        }
        // Looked like IPv6 (had a colon) but didn't parse. Mark as a non-hostname
        // *and* not a valid IP — SSRF gates fail closed on this shape.
        return { raw, isHostname: false, isValidIp: false };
    }
    // Try IPv4 in any of: decimal, octal, hex, dotted, short-form.
    const ipv4 = parseIPv4Literal(host);
    if (ipv4 !== null) {
        return { raw, ipv4: ipv4ToString(ipv4), isHostname: false, isValidIp: true };
    }
    // Not an IP literal in any form → treat as DNS hostname.
    return { raw, isHostname: true, isValidIp: false };
}
/**
 * Returns the matching private/internal range when the canonicalized host is
 * one, `null` when it's a safe public IP, or a "malformed" reason when the
 * input claims to be an IP literal but didn't parse cleanly.
 *
 * Defense-in-depth: even if a caller forgets to gate on `isValidIp`, this
 * function still fails closed on the malformed shape (`isHostname=false`
 * with no `ipv4`/`ipv6`).
 */
export function isPrivateAddress(host) {
    if (host.ipv4) {
        const num = ipv4StringToNumber(host.ipv4);
        if (num === null)
            return { reason: 'invalid IPv4 numeric form' };
        for (const range of PRIVATE_IPV4_RANGES) {
            // `>>> 0` forces the int32 result of `&` back to an unsigned 32-bit
            // value so comparisons with `range.network` (a positive JS number)
            // work correctly even when the high bit is set (172.16.x.x and up).
            if ((num & range.mask) >>> 0 === range.network) {
                return { reason: range.reason };
            }
        }
        return null;
    }
    if (host.ipv6) {
        const v6 = parseIPv6(host.ipv6);
        if (!v6)
            return { reason: 'invalid IPv6 form' };
        return checkPrivateIPv6(v6);
    }
    // Malformed IP literal (looked like an IP but didn't parse). Fail closed.
    if (!host.isHostname) {
        return { reason: 'malformed IP literal' };
    }
    // DNS hostname → no IP-level check possible without resolution.
    return null;
}
/**
 * Parse an IPv4 literal in *any* form `inet_aton` accepts:
 *   - 4 parts: a.b.c.d  (each 0-255)
 *   - 3 parts: a.b.c    (c is bottom 16 bits)
 *   - 2 parts: a.b      (b is bottom 24 bits)
 *   - 1 part:  a        (full 32-bit number)
 *
 * Each part may be decimal, octal (leading 0), or hex (leading 0x).
 */
function parseIPv4Literal(input) {
    if (input.length === 0)
        return null;
    const parts = input.split('.');
    if (parts.length === 0 || parts.length > 4)
        return null;
    const nums = [];
    for (const p of parts) {
        if (p.length === 0)
            return null;
        const n = parsePartNumber(p);
        if (n === null)
            return null;
        nums.push(n);
    }
    // Build 32-bit value per inet_aton rules.
    switch (nums.length) {
        case 1: {
            const a = nums[0];
            if (a > 0xff_ff_ff_ff)
                return null;
            return a >>> 0;
        }
        case 2: {
            const [a, b] = nums;
            if (a > 0xff || b > 0xff_ff_ff)
                return null;
            return ((a << 24) | b) >>> 0;
        }
        case 3: {
            const [a, b, c] = nums;
            if (a > 0xff || b > 0xff || c > 0xff_ff)
                return null;
            return ((a << 24) | (b << 16) | c) >>> 0;
        }
        case 4: {
            const [a, b, c, d] = nums;
            if (a > 0xff || b > 0xff || c > 0xff || d > 0xff)
                return null;
            return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
        }
        default:
            return null;
    }
}
function parsePartNumber(part) {
    // Hex: 0x...
    if (part.startsWith('0x') || part.startsWith('0X')) {
        const hex = part.slice(2);
        if (hex.length === 0 || !/^[0-9a-fA-F]+$/.test(hex))
            return null;
        const n = parseInt(hex, 16);
        return Number.isFinite(n) ? n : null;
    }
    // Octal: starts with 0 followed by digits (but bare "0" is decimal 0).
    if (part.length > 1 && part.startsWith('0')) {
        if (!/^[0-7]+$/.test(part))
            return null;
        const n = parseInt(part, 8);
        return Number.isFinite(n) ? n : null;
    }
    // Decimal.
    if (!/^[0-9]+$/.test(part))
        return null;
    const n = parseInt(part, 10);
    return Number.isFinite(n) ? n : null;
}
function ipv4StringToNumber(s) {
    const parts = s.split('.');
    if (parts.length !== 4)
        return null;
    let n = 0;
    for (const p of parts) {
        const x = parseInt(p, 10);
        if (!Number.isFinite(x) || x < 0 || x > 255)
            return null;
        n = (n << 8) | x;
    }
    return n >>> 0;
}
function ipv4ToString(n) {
    return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
}
/**
 * Parse an IPv6 address into 8 groups of 16-bit numbers. Supports `::`
 * compression and the `::ffff:a.b.c.d` IPv4-mapped trailing form.
 *
 * Reconstruction order with compression:
 *   [head hextets] [zero-fill] [tail hextets] [optional v4 suffix as 2 hextets]
 *
 * Without compression: just `[8 hextets, optional last replaced by v4 suffix]`.
 */
function parseIPv6(input) {
    if (input.length === 0)
        return null;
    const doubleColon = input.indexOf('::');
    let head;
    let tail;
    if (doubleColon >= 0) {
        if (input.indexOf('::', doubleColon + 1) !== -1)
            return null; // multiple ::
        head = input
            .slice(0, doubleColon)
            .split(':')
            .filter((s) => s.length > 0);
        tail = input
            .slice(doubleColon + 2)
            .split(':')
            .filter((s) => s.length > 0);
    }
    else {
        head = input.split(':');
        tail = [];
        // No compression: must be exactly 8 hextets (or 7 + a trailing IPv4).
    }
    // The trailing group may be a dotted-quad IPv4. It's always at the end of
    // either `tail` (when compression is present) or `head` (when not).
    let v4Suffix = null;
    const trailingGroupArr = tail.length > 0 ? tail : head;
    const trailingGroup = trailingGroupArr[trailingGroupArr.length - 1] ?? '';
    if (trailingGroup.includes('.')) {
        const v4 = parseIPv4Literal(trailingGroup);
        if (v4 === null)
            return null;
        v4Suffix = [(v4 >>> 16) & 0xff_ff, v4 & 0xff_ff];
        if (tail.length > 0)
            tail = tail.slice(0, -1);
        else
            head = head.slice(0, -1);
    }
    const headGroups = head.map(parseHextet);
    const tailGroups = tail.map(parseHextet);
    if (headGroups.includes(null) || tailGroups.includes(null))
        return null;
    const v4Count = v4Suffix ? 2 : 0;
    if (doubleColon >= 0) {
        const fillCount = 8 - headGroups.length - tailGroups.length - v4Count;
        if (fillCount < 0)
            return null;
        const zeros = new Array(fillCount).fill(0);
        const result = [
            ...headGroups,
            ...zeros,
            ...tailGroups,
            ...(v4Suffix ?? []),
        ];
        return result.length === 8 ? result : null;
    }
    // No compression — must add up to exactly 8 hextets including v4 suffix.
    const result = [...headGroups, ...(v4Suffix ?? [])];
    return result.length === 8 ? result : null;
}
function parseHextet(s) {
    if (s.length === 0 || s.length > 4)
        return null;
    if (!/^[0-9a-fA-F]+$/.test(s))
        return null;
    return parseInt(s, 16);
}
function formatIPv6(groups) {
    return groups.map((g) => g.toString(16)).join(':');
}
/**
 * If `groups` represents an IPv4-mapped (`::ffff:a.b.c.d`) or non-trivial
 * IPv4-compatible (`::a.b.c.d` with a.b.c.d != ::1, deprecated) IPv6 address,
 * return the inner 32-bit IPv4 value.
 *
 * IMPORTANT: We deliberately do NOT extract from `::1` (loopback) or `::`
 * (unspecified). Those are pure IPv6 addresses, not tunneled IPv4 — treating
 * `::1` as `0.0.0.1` would silently allow loopback bypass via the IPv4
 * range checks (which don't include `0.0.0.1`).
 */
function extractIPv4FromIPv6(groups) {
    // First 5 groups must be all zero (the high-order 80 bits).
    if (groups.slice(0, 5).some((g) => g !== 0))
        return null;
    const isMapped = groups[5] === 0xff_ff;
    if (isMapped) {
        return (((groups[6] ?? 0) << 16) | (groups[7] ?? 0)) >>> 0;
    }
    // IPv4-compatible (deprecated by RFC4291 §2.5.5.1): only when groups[5]===0
    // AND the embedded "IPv4" is itself a real public-looking address (i.e.
    // the whole thing isn't just `::N` for small N). We require the high
    // hextet of the IPv4 portion (groups[6]) to be non-zero — that excludes
    // `::1`, `::`, `::N` for N < 65536, all of which should be treated as
    // pure IPv6 by their own range checks (loopback, unspecified, etc).
    const isCompat = groups[5] === 0 && (groups[6] ?? 0) !== 0;
    if (isCompat) {
        return (((groups[6] ?? 0) << 16) | (groups[7] ?? 0)) >>> 0;
    }
    return null;
}
function checkPrivateIPv6(groups) {
    // ::1 loopback
    if (groups.every((g, i) => (i === 7 ? g === 1 : g === 0))) {
        return { reason: '::1 (IPv6 loopback)' };
    }
    // :: unspecified
    if (groups.every((g) => g === 0)) {
        return { reason: ':: (unspecified)' };
    }
    // fc00::/7  unique local
    if ((groups[0] & 0xfe_00) === 0xfc_00) {
        return { reason: 'fc00::/7 (IPv6 unique local)' };
    }
    // fe80::/10  link-local
    if ((groups[0] & 0xff_c0) === 0xfe_80) {
        return { reason: 'fe80::/10 (IPv6 link-local)' };
    }
    // ff00::/8  multicast
    if ((groups[0] & 0xff_00) === 0xff_00) {
        return { reason: 'ff00::/8 (IPv6 multicast)' };
    }
    // 64:ff9b::/96  well-known IPv4/IPv6 translation
    if (groups[0] === 0x00_64 && groups[1] === 0xff_9b && groups.slice(2, 6).every((g) => g === 0)) {
        return { reason: '64:ff9b::/96 (IPv4-IPv6 translation)' };
    }
    return null;
}
//# sourceMappingURL=ip-canon.js.map