19aeb2ba04
CI / Lint (push) Successful in 3m4s
CI / Typecheck (push) Successful in 3m6s
CI / Architecture Guardrails (push) Successful in 3m8s
CI / Assistant Split Regression (push) Successful in 3m48s
CI / Build (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 3): compose/DB/infra + stray code refs capakraken → nexus (#62) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { __test__, assertWebhookUrlAllowed, resolveAndValidate } from "../lib/ssrf-guard.js";
|
|
|
|
// Mock dns.lookup so tests do not require real DNS resolution.
|
|
// The guard now calls lookup(host, { all: true }) and receives an array.
|
|
vi.mock("node:dns/promises", () => ({
|
|
lookup: vi.fn(async (hostname: string) => {
|
|
const mapping: Record<string, Array<{ address: string; family: number }>> = {
|
|
"example.com": [{ address: "93.184.216.34", family: 4 }],
|
|
"hooks.external.io": [{ address: "52.1.2.3", family: 4 }],
|
|
};
|
|
const addrs = mapping[hostname];
|
|
if (!addrs) throw new Error(`ENOTFOUND ${hostname}`);
|
|
return addrs;
|
|
}),
|
|
}));
|
|
|
|
describe("assertWebhookUrlAllowed — SSRF guard", () => {
|
|
// ── Allowed targets ─────────────────────────────────────────────────────────
|
|
|
|
it("allows a valid HTTPS URL that resolves to a public IP", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://example.com/webhook")).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("allows an HTTPS URL with a path and query string", async () => {
|
|
await expect(
|
|
assertWebhookUrlAllowed("https://hooks.external.io/events?source=nexus"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
// ── Rejected schemes ─────────────────────────────────────────────────────────
|
|
|
|
it("rejects an HTTP URL (only HTTPS allowed)", async () => {
|
|
await expect(assertWebhookUrlAllowed("http://example.com/webhook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects an FTP URL", async () => {
|
|
await expect(assertWebhookUrlAllowed("ftp://example.com/file")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects a completely invalid URL", async () => {
|
|
await expect(assertWebhookUrlAllowed("not-a-url")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
// ── Blocked hostnames ────────────────────────────────────────────────────────
|
|
|
|
it("rejects localhost by hostname", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://localhost/callback")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects the AWS cloud metadata endpoint by hostname", async () => {
|
|
await expect(
|
|
assertWebhookUrlAllowed("https://169.254.169.254/latest/meta-data"),
|
|
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
});
|
|
|
|
it("rejects Google cloud metadata by hostname", async () => {
|
|
await expect(
|
|
assertWebhookUrlAllowed("https://metadata.google.internal/computeMetadata/v1"),
|
|
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
});
|
|
|
|
// ── Blocked IP ranges (direct IP addresses as hostname) ─────────────────────
|
|
|
|
it("rejects IPv4 loopback 127.0.0.1", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://127.0.0.1/callback")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects IPv4 loopback 127.1.2.3 (full /8 block)", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://127.1.2.3/callback")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects RFC 1918 private address 10.0.0.1", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://10.0.0.1/callback")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects RFC 1918 private address 172.16.0.1", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://172.16.0.1/callback")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects RFC 1918 private address 192.168.1.100", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://192.168.1.100/callback")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects link-local address 169.254.1.1", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://169.254.1.1/callback")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
// ── DNS fail-closed behaviour ────────────────────────────────────────────────
|
|
|
|
it("rejects a hostname that cannot be resolved (fail-closed)", async () => {
|
|
// "unresolvable.internal" is not in the mock DNS table — lookup throws ENOTFOUND.
|
|
await expect(
|
|
assertWebhookUrlAllowed("https://unresolvable.internal/hook"),
|
|
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
});
|
|
|
|
// ── DNS-rebinding protection ──────────────────────────────────────────────────
|
|
|
|
it("rejects a public hostname that resolves to a private IP (DNS rebinding)", async () => {
|
|
const { lookup } = await import("node:dns/promises");
|
|
vi.mocked(lookup).mockResolvedValueOnce([{ address: "192.168.0.1", family: 4 }]);
|
|
|
|
await expect(assertWebhookUrlAllowed("https://rebind.example.com/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects if ANY of the resolved addresses is private (multi-record attack)", async () => {
|
|
const { lookup } = await import("node:dns/promises");
|
|
vi.mocked(lookup).mockResolvedValueOnce([
|
|
{ address: "93.184.216.34", family: 4 },
|
|
{ address: "10.0.0.5", family: 4 },
|
|
]);
|
|
await expect(assertWebhookUrlAllowed("https://multi.example.com/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("resolveAndValidate returns the first validated address for connection pinning", async () => {
|
|
const resolved = await resolveAndValidate("https://example.com/hook");
|
|
expect(resolved.address).toBe("93.184.216.34");
|
|
expect(resolved.family).toBe(4);
|
|
expect(resolved.hostname).toBe("example.com");
|
|
});
|
|
|
|
// ── IPv6 blocklist ───────────────────────────────────────────────────────────
|
|
|
|
it("rejects IPv6 loopback ::1", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://[::1]/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects IPv6 unique-local fc00::/7 (fc00::1)", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://[fc00::1]/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects IPv6 link-local fe80::/10 (fe80::1)", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://[fe80::1]/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects IPv4-mapped IPv6 (::ffff:192.168.1.1) pointing into private v4", async () => {
|
|
await expect(
|
|
assertWebhookUrlAllowed("https://[::ffff:192.168.1.1]/hook"),
|
|
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
});
|
|
|
|
it("rejects IPv6 multicast (ff02::1)", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://[ff02::1]/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects 0.0.0.0/8", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://0.0.0.0/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("rejects 100.64.0.0/10 CGNAT", async () => {
|
|
await expect(assertWebhookUrlAllowed("https://100.64.1.1/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
await expect(assertWebhookUrlAllowed("https://100.127.254.254/hook")).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
});
|
|
});
|
|
|
|
it("accepts a 100.x address outside the CGNAT /10 (100.63.x is public)", async () => {
|
|
// 100.63.x is not in 100.64.0.0/10 — it is part of the public IANA pool.
|
|
expect(__test__.isBlockedIpv4("100.63.1.1")).toBe(false);
|
|
});
|
|
|
|
it("rejects 198.18.0.0/15 benchmark and TEST-NET ranges", async () => {
|
|
expect(__test__.isBlockedIpv4("198.18.0.1")).toBe(true);
|
|
expect(__test__.isBlockedIpv4("192.0.2.1")).toBe(true);
|
|
expect(__test__.isBlockedIpv4("203.0.113.1")).toBe(true);
|
|
});
|
|
|
|
it("expandIpv6 normalises short-form addresses to full 8-group form", () => {
|
|
expect(__test__.expandIpv6("::1")).toBe("0000:0000:0000:0000:0000:0000:0000:0001");
|
|
expect(__test__.expandIpv6("fe80::1")).toBe("fe80:0000:0000:0000:0000:0000:0000:0001");
|
|
expect(__test__.expandIpv6("::ffff:192.168.1.1")).toBe(
|
|
"0000:0000:0000:0000:0000:ffff:c0a8:0101",
|
|
);
|
|
});
|
|
});
|