import { isIP } from 'node:net';
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
export function isExplicitlyEnabled(value) {
    return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
/**
 * True when unauthenticated network exposure is acceptable: either a password is
 * set (auth active) or the operator explicitly acknowledged it. Used by the
 * tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
 */
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false) {
    if (process.env.CODEMAN_PASSWORD)
        return true;
    return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
export function isLoopbackBindHost(host) {
    const normalized = host
        .trim()
        .toLowerCase()
        .replace(/^\[(.*)\]$/, '$1');
    if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
        return true;
    }
    if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
        return true;
    }
    return normalized.startsWith('::ffff:127.');
}
/**
 * Hostname suffixes that are always accepted by the Host/Origin allowlist. These
 * are namespaces an external attacker cannot register DNS-rebinding records under
 * (tailscale MagicDNS, Cloudflare quick/named tunnels), so accepting them keeps
 * the project's documented tunnel access paths working without reopening the
 * rebinding hole. Extend per-deployment via CODEMAN_ALLOWED_HOSTS.
 */
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
/**
 * Extract the lowercased hostname from a Host/authority value, stripping the port
 * and IPv6 brackets. Returns null for empty/garbage input.
 */
export function parseAuthorityHostname(authority) {
    if (!authority)
        return null;
    let h = authority.trim();
    if (!h)
        return null;
    if (h.startsWith('[')) {
        // [::1] or [::1]:3000
        const end = h.indexOf(']');
        if (end === -1)
            return null;
        return h.slice(1, end).toLowerCase() || null;
    }
    // host:port — only treat a single trailing colon as a port separator so a
    // bracketless IPv6 literal (multiple colons) is left intact.
    const first = h.indexOf(':');
    if (first !== -1 && first === h.lastIndexOf(':')) {
        h = h.slice(0, first);
    }
    return h.toLowerCase() || null;
}
/** Build a HostPolicy from the bind host, CODEMAN_ALLOWED_HOSTS, and an active tunnel URL. */
export function buildHostPolicy(bindHost, tunnelUrl) {
    const allowedHosts = (process.env.CODEMAN_ALLOWED_HOSTS || '')
        .split(',')
        .map((s) => s.trim().toLowerCase())
        .filter(Boolean);
    let tunnelHost = null;
    if (tunnelUrl) {
        try {
            tunnelHost = new URL(tunnelUrl).hostname.toLowerCase();
        }
        catch {
            tunnelHost = null;
        }
    }
    return { bindHost, allowedHosts, tunnelHost };
}
function matchesHost(hostname, policy) {
    // localhost is reserved (always resolves to loopback, not rebindable).
    if (hostname === 'localhost')
        return true;
    // Any IP literal: a literal address cannot be the target of DNS rebinding — the
    // browser connected straight to it, there is no name to re-point.
    if (isIP(hostname) !== 0)
        return true;
    const bind = parseAuthorityHostname(policy.bindHost);
    if (bind && hostname === bind)
        return true;
    if (policy.tunnelHost && hostname === policy.tunnelHost)
        return true;
    for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
        if (hostname === suffix.slice(1) || hostname.endsWith(suffix))
            return true;
    }
    for (const entry of policy.allowedHosts) {
        if (entry.startsWith('.')) {
            if (hostname === entry.slice(1) || hostname.endsWith(entry))
                return true;
        }
        else if (hostname === entry) {
            return true;
        }
    }
    return false;
}
/**
 * True if a request's Host header is allowed. Blocks DNS-rebinding: a custom
 * domain rebound to a loopback/LAN address still carries its own name in Host,
 * which will not be in the allowlist.
 */
export function isAllowedRequestHost(hostHeader, policy) {
    const hostname = parseAuthorityHostname(hostHeader);
    if (!hostname)
        return false;
    return matchesHost(hostname, policy);
}
/**
 * True if a request's Origin is allowed for a state-changing / WebSocket request.
 * A MISSING Origin is allowed: non-browser clients (curl, Claude Code hooks) omit
 * it, while browsers always attach it on cross-origin state-changing/WS requests —
 * so a forged cross-site request is caught while local automation keeps working.
 * The opaque origin 'null' (sandboxed iframe, data: URL) is rejected.
 */
export function isAllowedRequestOrigin(originHeader, policy) {
    if (originHeader === undefined || originHeader === '')
        return true;
    if (originHeader === 'null')
        return false;
    try {
        return matchesHost(new URL(originHeader).hostname.toLowerCase(), policy);
    }
    catch {
        return false;
    }
}
//# sourceMappingURL=network-auth-policy.js.map