Files
2026-07-13 13:32:57 +08:00

118 lines
4.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
assertAddressAllowed,
assertSafeWebhookUrl,
UnsafeWebhookUrlError,
} from "../app/v3/services/alerts/safeWebhookUrl.server.js";
// These cases are decided by the lexical / IP-literal checks, so no network is
// needed. The DNS-resolution branch is not exercised here.
describe("assertSafeWebhookUrl", () => {
it("accepts a public http(s) URL given as an IP literal (no DNS needed)", async () => {
await expect(assertSafeWebhookUrl("https://93.184.216.34/hook?x=1")).resolves.toBeInstanceOf(
URL
);
});
it("rejects non-http(s) schemes", async () => {
for (const url of [
"file:///etc/passwd",
"gopher://evil/_",
"ftp://host/x",
"data:text/plain,hi",
"javascript:alert(1)",
]) {
await expect(assertSafeWebhookUrl(url)).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
}
});
it("rejects loopback and unspecified IPv4", async () => {
for (const host of ["127.0.0.1", "127.9.9.9", "0.0.0.0"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects RFC1918 private ranges", async () => {
for (const host of ["10.0.0.1", "172.16.0.1", "172.31.255.1", "192.168.1.1"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects link-local incl. the cloud metadata address", async () => {
await expect(
assertSafeWebhookUrl("http://169.254.169.254/latest/meta-data/")
).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
});
it("rejects CGNAT, multicast and reserved ranges", async () => {
for (const host of ["100.64.0.1", "224.0.0.1", "239.1.1.1", "240.0.0.1"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects loopback/internal hostnames before any DNS lookup", async () => {
for (const host of ["localhost", "foo.localhost", "svc.internal", "db.local"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects unsafe IPv6 literals", async () => {
for (const host of ["[::1]", "[fe80::1]", "[fc00::1]", "[::ffff:127.0.0.1]"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
// Regression: bracketed IPv6 literals and Node's hex-normalized IPv4-mapped
// addresses (::ffff:127.0.0.1 -> ::ffff:7f00:1) must be caught lexically.
it("rejects bracketed and hex-mapped IPv6 loopback lexically", async () => {
for (const host of ["[::1]", "[::ffff:7f00:1]"]) {
await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf(
UnsafeWebhookUrlError
);
}
});
it("rejects malformed URLs", async () => {
await expect(assertSafeWebhookUrl("not a url")).rejects.toBeInstanceOf(UnsafeWebhookUrlError);
});
});
// assertAddressAllowed is the connect-time check that safeWebhookFetch runs
// inside the socket's DNS lookup. These cases cover its address-range logic.
describe("assertAddressAllowed", () => {
it("allows public IPv4 / IPv6 addresses", () => {
expect(() => assertAddressAllowed("93.184.216.34", 4)).not.toThrow();
expect(() => assertAddressAllowed("2606:2800:220:1:248:1893:25c8:1946", 6)).not.toThrow();
});
it("rejects loopback / private / CGNAT / link-local IPv4 (incl. metadata)", () => {
for (const addr of [
"127.0.0.1",
"0.0.0.0",
"10.1.2.3",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
]) {
expect(() => assertAddressAllowed(addr, 4)).toThrow(UnsafeWebhookUrlError);
}
});
it("rejects loopback / ULA / link-local / mapped IPv6", () => {
for (const addr of ["::1", "fe80::1", "fc00::1", "::ffff:127.0.0.1"]) {
expect(() => assertAddressAllowed(addr, 6)).toThrow(UnsafeWebhookUrlError);
}
});
});