/**
 * Is this hostname somewhere a stranger may point us?
 *
 * The audit binary was built for an allowlist: each key names the exact origins
 * it may audit, so a leaked key could not turn the box into an audit proxy. A
 * public form has no allowlist — the whole point is that a visitor types their
 * own domain — so the allowlist is replaced by this: anything that resolves
 * outside the public internet is refused.
 *
 * READ THIS BEFORE TRUSTING IT. This module is not the security boundary, and
 * cannot be:
 *
 *   1. The binary follows redirects. `https://evil.test/` may answer 302 to
 *      `http://169.254.169.254/latest/meta-data/`, and nothing checked here
 *      ever saw that address.
 *   2. Between our lookup and the binary's own, the answer can change. A DNS
 *      record with a one-second TTL that alternates between a public address
 *      and 127.0.0.1 defeats any check-then-use, by construction.
 *
 * The boundary is `egress-rules.sh` — the `DOCKER-USER` chain that drops the
 * worker's packets whatever address the binary ended up dialling. What this
 * module buys is a clean 400 naming the problem, instead of a confusing
 * "unreachable" after the kernel silently ate the connection — and it stops the
 * obvious attempts before a process is spawned.
 */

import { lookup } from "node:dns/promises";

export type TargetVerdict = { ok: true } | { ok: false; reason: string };

/**
 * IPv4 ranges that are not the public internet.
 *
 * Not only the RFC 1918 three: `169.254.169.254` is the cloud metadata endpoint
 * — the address that turns an SSRF into stolen instance credentials — and it
 * lives in link-local, which is not private in the RFC 1918 sense at all.
 */
const BLOCKED_V4: ReadonlyArray<readonly [string, number, string]> = [
  ["0.0.0.0", 8, "this network"],
  ["10.0.0.0", 8, "a private network"],
  ["100.64.0.0", 10, "carrier-grade NAT space"],
  ["127.0.0.0", 8, "the loopback"],
  ["169.254.0.0", 16, "link-local space, where cloud metadata lives"],
  ["172.16.0.0", 12, "a private network"],
  ["192.0.0.0", 24, "IETF protocol assignments"],
  ["192.0.2.0", 24, "documentation space"],
  ["192.88.99.0", 24, "6to4 relay anycast"],
  ["192.168.0.0", 16, "a private network"],
  ["198.18.0.0", 15, "benchmarking space"],
  ["198.51.100.0", 24, "documentation space"],
  ["203.0.113.0", 24, "documentation space"],
  ["224.0.0.0", 4, "multicast"],
  ["240.0.0.0", 4, "reserved space"],
];

function v4ToInt(address: string): number | null {
  const parts = address.split(".");
  if (parts.length !== 4) return null;
  let value = 0;
  for (const part of parts) {
    // `Number("")` is 0 and `Number("1e2")` is 100: neither is an octet.
    if (!/^\d{1,3}$/.test(part)) return null;
    const octet = Number(part);
    if (octet > 255) return null;
    value = value * 256 + octet;
  }
  return value >>> 0;
}

/** Classify a literal IPv4 address. Returns why it is refused, or null. */
export function refuseV4(address: string): string | null {
  const value = v4ToInt(address);
  if (value === null) return null;
  for (const [base, bits, why] of BLOCKED_V4) {
    const network = v4ToInt(base);
    if (network === null) continue;
    const mask = bits === 0 ? 0 : (0xff_ff_ff_ff << (32 - bits)) >>> 0;
    if ((value & mask) === (network & mask)) return why;
  }
  return null;
}

/** Classify a literal IPv6 address. Returns why it is refused, or null. */
export function refuseV6(address: string): string | null {
  const raw = address.trim().toLowerCase().replace(/^\[|]$/g, "");
  // Strip a zone index: `fe80::1%eth0`.
  const bare = raw.split("%")[0] ?? raw;

  // An IPv4-mapped or IPv4-compatible address is an IPv4 address wearing a hat,
  // and `::ffff:127.0.0.1` reaches the loopback exactly like `127.0.0.1` does.
  const embedded = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
  if (embedded?.[1] && (bare.startsWith("::ffff:") || bare.startsWith("::"))) {
    return refuseV4(embedded[1]) ?? null;
  }

  if (bare === "::" || bare === "::0") return "the unspecified address";
  if (bare === "::1") return "the loopback";
  if (
    bare.startsWith("fe8") ||
    bare.startsWith("fe9") ||
    bare.startsWith("fea") ||
    bare.startsWith("feb")
  ) {
    return "link-local space";
  }
  // fc00::/7 — both halves of the unique-local range.
  if (bare.startsWith("fc") || bare.startsWith("fd")) return "unique-local space";
  if (bare.startsWith("ff")) return "multicast";
  if (bare.startsWith("2001:db8")) return "documentation space";
  if (bare.startsWith("64:ff9b")) return "NAT64 space";
  if (bare.startsWith("100:")) return "discard-only space";
  return null;
}

/** Refuse a literal address of either family. Returns why, or null. */
export function refuseAddress(address: string): string | null {
  return address.includes(":") ? refuseV6(address) : refuseV4(address);
}

/**
 * Resolve `hostname` and refuse it unless every address it answers with is on
 * the public internet.
 *
 * *Every* address, not the first: a name that answers with one public address
 * and one private one is a rebinding attempt with the mask off, and picking the
 * public one would be choosing to be fooled.
 */
export async function checkPublicTarget(
  hostname: string,
  resolver: (host: string) => Promise<Array<{ address: string }>> = (host) =>
    lookup(host, { all: true, verbatim: true }),
): Promise<TargetVerdict> {
  const host = hostname.trim().toLowerCase().replace(/^\[|]$/g, "");
  if (host === "") return { ok: false, reason: "the host is empty" };

  // `localhost` need not resolve to 127.0.0.1 — /etc/hosts decides — so refuse
  // the name itself rather than trusting what it maps to today.
  if (host === "localhost" || host.endsWith(".localhost")) {
    return { ok: false, reason: "localhost is not a site this can audit" };
  }
  // RFC 6762: never leaves the local link.
  if (host.endsWith(".local")) {
    return { ok: false, reason: "a .local name is not on the public internet" };
  }
  // No dot and not a literal address: an intranet short name.
  const literal = refuseAddress(host);
  if (literal !== null) {
    return { ok: false, reason: `that address is in ${literal}` };
  }
  const isLiteral = /^[\d.]+$/.test(host) || host.includes(":");
  if (!isLiteral && !host.includes(".")) {
    return { ok: false, reason: "that looks like an intranet name, not a public domain" };
  }
  if (isLiteral) return { ok: true };

  let addresses: Array<{ address: string }>;
  try {
    addresses = await resolver(host);
  } catch {
    return { ok: false, reason: `${host} does not resolve` };
  }
  if (addresses.length === 0) {
    return { ok: false, reason: `${host} does not resolve` };
  }
  for (const { address } of addresses) {
    const why = refuseAddress(address);
    if (why !== null) {
      return { ok: false, reason: `${host} resolves into ${why}` };
    }
  }
  return { ok: true };
}

/**
 * Turn what a visitor typed into the origin we would audit, or say why we will
 * not.
 *
 * The form asks for a domain, so a bare host is the common case and gets
 * `https://`. The path is dropped on purpose: the binary audits an origin, and
 * keeping a path in the stored target would only invite the belief that a
 * single page was checked. A port is kept — a public site on :8443 is still a
 * public site — but only the two web schemes are accepted, because the binary
 * refuses the rest anyway (contract §1.1) and a 400 here reads better than an
 * exit 2 an hour later in the queue.
 */
export async function resolveAuditTarget(
  input: string,
  resolver?: (host: string) => Promise<Array<{ address: string }>>,
): Promise<{ ok: true; origin: string } | { ok: false; reason: string }> {
  const typed = input.trim();
  if (typed === "") return { ok: false, reason: "type a domain first" };
  if (typed.length > 253) return { ok: false, reason: "that is too long to be a domain" };

  let url: URL;
  try {
    url = new URL(/^[a-z][a-z\d+.-]*:/i.test(typed) ? typed : `https://${typed}`);
  } catch {
    return { ok: false, reason: "that is not a domain we can read" };
  }
  if (url.protocol !== "http:" && url.protocol !== "https:") {
    return { ok: false, reason: `${url.protocol.replace(":", "")} is not a scheme we audit` };
  }
  // Credentials in the URL would be sent to the target by the binary. They are
  // never something a public form needs, and often something a phisher wants.
  if (url.username !== "" || url.password !== "") {
    return { ok: false, reason: "drop the credentials from the URL" };
  }

  const verdict = await checkPublicTarget(url.hostname, resolver);
  if (!verdict.ok) return verdict;

  return { ok: true, origin: url.origin };
}

/** How long the site has to answer before we audit what the visitor typed. */
const REDIRECT_TIMEOUT_MS = 3000;

/** The same host with `www.` put on, or taken off. */
function wwwTwin(host: string): string {
  return host.startsWith("www.") ? host.slice(4) : `www.${host}`;
}

/**
 * Follow a redirect between `example.com` and `www.example.com`, and nothing
 * else.
 *
 * A visitor types the domain they know, which is often the one that redirects.
 * The binary never follows a redirect on a page it audits — deliberately, since
 * that would audit a page nobody asked about (contract §8.3) — so every sampled
 * URL came back 3xx, no page was read, and the honest verdict was that nothing
 * could be checked. True, and useless: the same site answers perfectly on its
 * other name.
 *
 * The narrow scope is the safety argument, and it is why this is not a redirect
 * follower. Sending a request to a location a stranger's server chose is exactly
 * the SSRF this repo keeps out of the app, so the only hop taken is to a host
 * that differs from the typed one by `www.` — which is then resolved and vetted
 * again like any other target, because a name can point anywhere. A `302` to the
 * metadata endpoint is not refused after inspection; it is never followed.
 *
 * Anything unexpected — no answer, a refused HEAD, a redirect somewhere else —
 * leaves the typed origin alone. This improves an audit; it may not prevent one.
 */
export async function settleWwwRedirect(
  origin: string,
  fetcher: typeof fetch = fetch,
  resolver?: (host: string) => Promise<Array<{ address: string }>>,
): Promise<string> {
  let current: URL;
  try {
    current = new URL(origin);
  } catch {
    return origin;
  }

  // Two: one to cross to the other name, one for a scheme upgrade on the way.
  // A site that bounces further than that is telling us to stop.
  for (let hop = 0; hop < 2; hop++) {
    let response: Response;
    try {
      response = await fetcher(current.toString(), {
        method: "HEAD",
        redirect: "manual",
        signal: AbortSignal.timeout(REDIRECT_TIMEOUT_MS),
      });
    } catch {
      return current.origin;
    }
    if (response.status < 300 || response.status > 399) return current.origin;

    const location = response.headers.get("location");
    if (location === null) return current.origin;

    let next: URL;
    try {
      next = new URL(location, current);
    } catch {
      return current.origin;
    }
    if (next.protocol !== "https:" && next.protocol !== "http:") return current.origin;

    const here = current.hostname.toLowerCase();
    const there = next.hostname.toLowerCase();
    const crossed = there === wwwTwin(here);
    // A same-host hop is only ever an upgrade to https. Following a downgrade
    // would mean auditing a site over a scheme it is trying to leave.
    const upgraded = there === here && current.protocol === "http:" && next.protocol === "https:";
    if (!crossed && !upgraded) return current.origin;

    if (crossed) {
      const verdict = await checkPublicTarget(there, resolver);
      if (!verdict.ok) return current.origin;
    }
    current = new URL(next.origin);
  }

  return current.origin;
}
