/**
 * The public-target guard, tested offline with an injected resolver.
 *
 * No DNS is consulted here: every test that needs a name to resolve says what
 * it resolves to. A guard whose tests depend on the internet answering is a
 * guard that stops being tested the day the network is slow.
 */

import { expect, test } from "bun:test";
import { checkPublicTarget, refuseAddress, refuseV4, refuseV6 } from "./public-target.ts";

const resolves =
  (...addresses: string[]) =>
  () =>
    Promise.resolve(addresses.map((address) => ({ address })));

const nxdomain = () => Promise.reject(new Error("ENOTFOUND"));

test("the addresses an SSRF actually aims at are refused", () => {
  // Cloud metadata first: this is the one that turns an SSRF into stolen
  // instance credentials, and it is link-local, not RFC 1918.
  expect(refuseV4("169.254.169.254")).toBe("link-local space, where cloud metadata lives");
  expect(refuseV4("127.0.0.1")).toBe("the loopback");
  expect(refuseV4("10.1.2.3")).toBe("a private network");
  expect(refuseV4("172.16.0.1")).toBe("a private network");
  expect(refuseV4("172.31.255.255")).toBe("a private network");
  expect(refuseV4("192.168.1.1")).toBe("a private network");
  expect(refuseV4("100.64.0.1")).toBe("carrier-grade NAT space");
  expect(refuseV4("0.0.0.0")).toBe("this network");
  expect(refuseV4("255.255.255.255")).toBe("reserved space");
});

test("the boundaries of each range are where they are documented to be", () => {
  // 172.16.0.0/12 stops at 172.31.255.255; 172.32.x is public.
  expect(refuseV4("172.15.255.255")).toBeNull();
  expect(refuseV4("172.32.0.1")).toBeNull();
  // 100.64.0.0/10 stops at 100.127.255.255.
  expect(refuseV4("100.63.255.255")).toBeNull();
  expect(refuseV4("100.128.0.1")).toBeNull();
  // 169.254.0.0/16 does not extend to 169.255.
  expect(refuseV4("169.253.0.1")).toBeNull();
  expect(refuseV4("169.255.0.1")).toBeNull();
});

test("ordinary public addresses are left alone", () => {
  for (const address of ["1.1.1.1", "8.8.8.8", "93.184.216.34", "172.32.0.1", "11.0.0.1"]) {
    expect(refuseV4(address)).toBeNull();
  }
});

test("an IPv4 address wearing an IPv6 hat reaches the same place", () => {
  // `::ffff:127.0.0.1` is the loopback. Checking the textual form for `::1` and
  // stopping there is the classic way to miss it.
  expect(refuseV6("::ffff:127.0.0.1")).toBe("the loopback");
  expect(refuseV6("::ffff:169.254.169.254")).toBe("link-local space, where cloud metadata lives");
  expect(refuseV6("::ffff:10.0.0.1")).toBe("a private network");
  expect(refuseV6("::ffff:8.8.8.8")).toBeNull();
});

test("IPv6 space that is not the public internet is refused", () => {
  expect(refuseV6("::1")).toBe("the loopback");
  expect(refuseV6("[::1]")).toBe("the loopback");
  expect(refuseV6("::")).toBe("the unspecified address");
  expect(refuseV6("fe80::1")).toBe("link-local space");
  expect(refuseV6("fe80::1%eth0")).toBe("link-local space");
  expect(refuseV6("fd00::1")).toBe("unique-local space");
  expect(refuseV6("fc00::1")).toBe("unique-local space");
  expect(refuseV6("ff02::1")).toBe("multicast");
  expect(refuseV6("2001:db8::1")).toBe("documentation space");
  expect(refuseV6("2606:4700:4700::1111")).toBeNull();
});

test("a malformed octet is not read as a number", () => {
  // `Number("")` is 0 and `Number("1e2")` is 100. Either would let a bogus
  // string be classified as a real address.
  expect(refuseAddress("10.0.0")).toBeNull();
  expect(refuseAddress("10.0.0.1.2")).toBeNull();
  expect(refuseAddress("10.0.0.1e2")).toBeNull();
  expect(refuseAddress("10.0.0.256")).toBeNull();
  expect(refuseAddress("not-an-address")).toBeNull();
});

test("names that never leave the machine are refused without resolving them", async () => {
  // /etc/hosts decides what `localhost` means, so trusting the lookup would be
  // trusting a file an attacker may have influenced.
  const explode = () => Promise.reject(new Error("the resolver must not be called"));
  for (const host of ["localhost", "LOCALHOST", "app.localhost", "printer.local"]) {
    const verdict = await checkPublicTarget(host, explode);
    expect(verdict.ok).toBe(false);
  }
});

test("an intranet short name is refused", async () => {
  const verdict = await checkPublicTarget("wiki", () => Promise.reject(new Error("no")));
  expect(verdict).toEqual({ ok: false, reason: "that looks like an intranet name, not a public domain" });
});

test("a name resolving into private space is refused, and says where", async () => {
  const verdict = await checkPublicTarget("internal.example.com", resolves("192.168.1.10"));
  expect(verdict).toEqual({
    ok: false,
    reason: "internal.example.com resolves into a private network",
  });
});

test("one private answer among public ones is still refused", async () => {
  // A name answering with both is a rebinding attempt with the mask off.
  // Taking the public address would be choosing to be fooled.
  const verdict = await checkPublicTarget("mixed.example.com", resolves("93.184.216.34", "127.0.0.1"));
  expect(verdict.ok).toBe(false);
});

test("a name that does not resolve is refused, not passed through", async () => {
  const verdict = await checkPublicTarget("nope.example", nxdomain);
  expect(verdict).toEqual({ ok: false, reason: "nope.example does not resolve" });

  const empty = await checkPublicTarget("void.example", () => Promise.resolve([]));
  expect(empty.ok).toBe(false);
});

test("an ordinary public site passes", async () => {
  expect(await checkPublicTarget("example.com", resolves("93.184.216.34"))).toEqual({ ok: true });
  expect(await checkPublicTarget("www.elevaseo.com", resolves("2606:4700::1", "104.16.0.1"))).toEqual({
    ok: true,
  });
});

test("a literal address is judged directly, without a lookup", async () => {
  const explode = () => Promise.reject(new Error("the resolver must not be called"));
  expect((await checkPublicTarget("127.0.0.1", explode)).ok).toBe(false);
  expect((await checkPublicTarget("169.254.169.254", explode)).ok).toBe(false);
  expect((await checkPublicTarget("[::1]", explode)).ok).toBe(false);
  expect(await checkPublicTarget("8.8.8.8", explode)).toEqual({ ok: true });
});
