import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { Agent, fetch as undiciFetch } from "undici";
import { isBlockedHostname, isPrivateIpAddress } from "./network.js";
class UnsafeOutboundUrlError extends Error {
  constructor(reason, message) {
    super(message ?? `Outbound URL rejected: ${reason}`);
    this.name = "UnsafeOutboundUrlError";
    this.reason = reason;
  }
}
const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
const defaultErrorFactory = (reason, message) => new UnsafeOutboundUrlError(reason, message);
function parseOutboundUrl(rawUrl, options = {}) {
  const subject = options.subject ?? "URL";
  const factory = options.errorFactory ?? defaultErrorFactory;
  let url;
  try {
    url = new URL(rawUrl);
  } catch {
    throw factory("invalid_url", `${subject} is not a valid URL`);
  }
  if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
    throw factory(
      "forbidden_protocol",
      `${subject} protocol "${url.protocol.replace(":", "")}" is not allowed; use http or https`
    );
  }
  if (url.username || url.password) {
    throw factory("credentials_in_url", `${subject} must not embed basic-auth credentials`);
  }
  let hostname = url.hostname.trim().toLowerCase();
  if (!hostname) {
    throw factory("missing_host", `${subject} must include a hostname`);
  }
  if (hostname.startsWith("[") && hostname.endsWith("]")) {
    hostname = hostname.slice(1, -1);
  }
  return { url, hostname };
}
function assertStaticallySafeOutboundUrl(rawUrl, options = {}) {
  const subject = options.subject ?? "URL";
  const factory = options.errorFactory ?? defaultErrorFactory;
  const { hostname } = parseOutboundUrl(rawUrl, options);
  if (options.allowPrivate) return;
  if (isBlockedHostname(hostname)) {
    throw factory("blocked_hostname", `${subject} host "${hostname}" is not allowed`);
  }
  if (isIP(hostname) && isPrivateIpAddress(hostname)) {
    throw factory(
      "private_ip_literal",
      `${subject} host "${hostname}" resolves to a private or reserved IP range`
    );
  }
}
async function assertSafeOutboundUrl(rawUrl, options = {}) {
  await resolveSafeOutboundUrl(rawUrl, options);
}
async function resolveSafeOutboundUrl(rawUrl, options = {}) {
  const subject = options.subject ?? "URL";
  const factory = options.errorFactory ?? defaultErrorFactory;
  const { url, hostname } = parseOutboundUrl(rawUrl, options);
  if (options.allowPrivate && !options.pinAllowedPrivate) {
    return { url, hostname, addresses: null };
  }
  if (!options.allowPrivate && isBlockedHostname(hostname)) {
    throw factory("blocked_hostname", `${subject} host "${hostname}" is not allowed`);
  }
  if (isIP(hostname)) {
    if (!options.allowPrivate && isPrivateIpAddress(hostname)) {
      throw factory(
        "private_ip_literal",
        `${subject} host "${hostname}" resolves to a private or reserved IP range`
      );
    }
    return { url, hostname, addresses: null };
  }
  const resolver = options.lookupHost ?? (async (host) => {
    const records = await lookup(host, { all: true, verbatim: true });
    return records;
  });
  let addresses;
  try {
    addresses = await resolver(hostname);
  } catch (error) {
    throw factory(
      "dns_resolution_failed",
      `${subject} host "${hostname}" could not be resolved: ${error instanceof Error ? error.message : "lookup failed"}`
    );
  }
  if (!addresses || addresses.length === 0) {
    throw factory(
      "dns_resolution_empty",
      `${subject} host "${hostname}" has no DNS A/AAAA records`
    );
  }
  for (const record of addresses) {
    if (!options.allowPrivate && isPrivateIpAddress(record.address)) {
      throw factory(
        "private_ip_resolved",
        `${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`
      );
    }
  }
  return { url, hostname, addresses };
}
async function safeOutboundFetch(rawUrl, init = {}, options = {}) {
  const { url, hostname, addresses } = await resolveSafeOutboundUrl(rawUrl, options);
  void url;
  const mergedInit = {
    redirect: "manual",
    ...init
  };
  const fetchImpl = options.fetchImpl;
  if (fetchImpl) {
    return fetchImpl(rawUrl, mergedInit);
  }
  if (!addresses || addresses.length === 0) {
    return globalThis.fetch(rawUrl, mergedInit);
  }
  const dispatcher = new Agent({
    connect: {
      lookup: createPinnedDnsLookup(hostname, addresses[0])
    }
  });
  try {
    const pinnedFetch = options.pinnedFetchImpl ?? undiciFetch;
    return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher });
  } finally {
    dispatcher.close().catch(() => {
    });
  }
}
function createPinnedDnsLookup(expectedHostname, pinned) {
  return (host, opts, cb) => {
    if (host !== expectedHostname) {
      const err = new Error(
        `Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`
      );
      err.code = "EREFUSED";
      cb(err, "", 0);
      return;
    }
    if (opts.all) {
      cb(null, [pinned]);
      return;
    }
    cb(null, pinned.address, pinned.family);
  };
}
export {
  UnsafeOutboundUrlError,
  assertSafeOutboundUrl,
  assertStaticallySafeOutboundUrl,
  createPinnedDnsLookup,
  parseOutboundUrl,
  resolveSafeOutboundUrl,
  safeOutboundFetch
};
//# sourceMappingURL=url-safety.js.map
