Webhooks for the AT Protocol
airglow.run
atproto
atprotocol
automation
webhook
1import { describe, it, expect, vi, beforeEach } from "vitest";
2import { assertPublicUrl, UrlGuardError } from "./url-guard.js";
3
4// Mock DNS resolution to control test behavior without network
5vi.mock("node:dns/promises", () => ({
6 resolve4: vi.fn(),
7 resolve6: vi.fn(),
8}));
9
10import { resolve4, resolve6 } from "node:dns/promises";
11
12const mockResolve4 = vi.mocked(resolve4);
13const mockResolve6 = vi.mocked(resolve6);
14
15function resolveAs(ipv4: string[] = [], ipv6: string[] = []) {
16 mockResolve4.mockResolvedValue(ipv4);
17 mockResolve6.mockResolvedValue(ipv6);
18}
19
20function resolveNothing() {
21 mockResolve4.mockRejectedValue(new Error("ENOTFOUND"));
22 mockResolve6.mockRejectedValue(new Error("ENOTFOUND"));
23}
24
25describe("assertPublicUrl", () => {
26 beforeEach(() => {
27 mockResolve4.mockReset();
28 mockResolve6.mockReset();
29 });
30
31 it("accepts a public URL", async () => {
32 resolveAs(["93.184.216.34"]);
33 const url = await assertPublicUrl("https://example.com/hook");
34 expect(url.hostname).toBe("example.com");
35 });
36
37 it("rejects non-HTTP schemes", async () => {
38 await expect(assertPublicUrl("ftp://example.com")).rejects.toThrow(UrlGuardError);
39 await expect(assertPublicUrl("file:///etc/passwd")).rejects.toThrow(UrlGuardError);
40 });
41
42 it("rejects invalid URLs", async () => {
43 await expect(assertPublicUrl("not a url")).rejects.toThrow();
44 });
45
46 it("rejects localhost IP literal", async () => {
47 await expect(assertPublicUrl("http://127.0.0.1/hook")).rejects.toThrow(UrlGuardError);
48 });
49
50 it("rejects private IP 10.x.x.x", async () => {
51 await expect(assertPublicUrl("http://10.0.0.1/hook")).rejects.toThrow(UrlGuardError);
52 });
53
54 it("rejects private IP 192.168.x.x", async () => {
55 await expect(assertPublicUrl("http://192.168.1.1/hook")).rejects.toThrow(UrlGuardError);
56 });
57
58 it("rejects private IP 172.16.x.x", async () => {
59 await expect(assertPublicUrl("http://172.16.0.1/hook")).rejects.toThrow(UrlGuardError);
60 });
61
62 it("rejects cloud metadata IP", async () => {
63 await expect(assertPublicUrl("http://169.254.169.254/latest")).rejects.toThrow(UrlGuardError);
64 });
65
66 it("rejects localhost hostname", async () => {
67 await expect(assertPublicUrl("http://localhost/hook")).rejects.toThrow(UrlGuardError);
68 });
69
70 it("rejects .local hostname", async () => {
71 await expect(assertPublicUrl("http://myhost.local/hook")).rejects.toThrow(UrlGuardError);
72 });
73
74 it("rejects when DNS resolves to private IP", async () => {
75 resolveAs(["127.0.0.1"]);
76 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError);
77 });
78
79 it("rejects when DNS resolves to 10.x.x.x", async () => {
80 resolveAs(["10.0.0.5"]);
81 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError);
82 });
83
84 it("rejects when any resolved IP is private", async () => {
85 resolveAs(["93.184.216.34", "10.0.0.1"]);
86 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError);
87 });
88
89 it("rejects unresolvable hostnames", async () => {
90 resolveNothing();
91 await expect(assertPublicUrl("https://nonexistent.invalid/hook")).rejects.toThrow(
92 UrlGuardError,
93 );
94 });
95
96 it("rejects IPv6 loopback", async () => {
97 await expect(assertPublicUrl("http://[::1]/hook")).rejects.toThrow(UrlGuardError);
98 });
99
100 it("rejects when DNS resolves to IPv6 link-local", async () => {
101 resolveAs([], ["fe80::1"]);
102 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError);
103 });
104
105 it("rejects when DNS resolves to IPv6 unique local", async () => {
106 resolveAs([], ["fd12::1"]);
107 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError);
108 });
109
110 it("rejects CGN/Tailscale IP 100.64.x.x", async () => {
111 resolveAs(["100.64.0.1"]);
112 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError);
113 });
114
115 it("rejects benchmarking IP 198.18.x.x", async () => {
116 resolveAs(["198.18.0.1"]);
117 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError);
118 });
119});