import dns from "node:dns"; import type { MockInstance } from "vitest"; import env from "@server/env"; import { isInvalidAppPath, validateUrlNotPrivate } from "./url"; describe("validateUrlNotPrivate", () => { let lookupSpy: MockInstance; beforeEach(() => { lookupSpy = vi .spyOn(dns.promises, "lookup") .mockResolvedValue({ address: "93.184.216.34", family: 4 }); }); afterEach(() => { lookupSpy.mockRestore(); env.ALLOWED_PRIVATE_IP_ADDRESSES = undefined; }); it("should allow public IP addresses", async () => { lookupSpy.mockResolvedValue({ address: "93.184.216.34", family: 4 }); await expect( validateUrlNotPrivate("https://example.com") ).resolves.toBeUndefined(); }); it("should reject private IP in URL", async () => { await expect(validateUrlNotPrivate("https://10.0.0.1/api")).rejects.toThrow( "is not allowed" ); }); it("should reject URL resolving to private IP", async () => { lookupSpy.mockResolvedValue({ address: "192.168.1.1", family: 4 }); await expect( validateUrlNotPrivate("https://internal.example.com") ).rejects.toThrow("is not allowed"); }); it("should reject loopback address", async () => { await expect( validateUrlNotPrivate("https://127.0.0.1/api") ).rejects.toThrow("is not allowed"); }); it("should reject link-local address", async () => { lookupSpy.mockResolvedValue({ address: "169.254.169.254", family: 4 }); await expect( validateUrlNotPrivate("https://metadata.internal") ).rejects.toThrow("is not allowed"); }); it.each([ ["::ffff:169.254.169.254", "metadata via IPv4-mapped IPv6"], ["::ffff:127.0.0.1", "loopback via IPv4-mapped IPv6"], ["::ffff:10.0.0.1", "RFC1918 via IPv4-mapped IPv6"], ["::ffff:192.168.1.1", "RFC1918 via IPv4-mapped IPv6"], ["64:ff9b::a9fe:a9fe", "metadata via NAT64"], ["2002:a9fe:a9fe::", "metadata via 6to4"], ])("should reject %s in URL (%s)", async (address) => { await expect( validateUrlNotPrivate(`https://[${address}]/api`) ).rejects.toThrow("is not allowed"); }); it("should reject IPv4-mapped IPv6 address resolved via DNS", async () => { lookupSpy.mockResolvedValue({ address: "::ffff:169.254.169.254", family: 6, }); await expect( validateUrlNotPrivate("https://metadata.example.com") ).rejects.toThrow("is not allowed"); }); it("should reject carrier-grade NAT address", async () => { await expect( validateUrlNotPrivate("https://100.64.0.1/api") ).rejects.toThrow("is not allowed"); }); it("should reject IPv4-mapped IPv6 addresses outright", async () => { await expect( validateUrlNotPrivate("https://[::ffff:8.8.8.8]/") ).rejects.toThrow("is not allowed"); }); describe("with ALLOWED_PRIVATE_IP_ADDRESSES", () => { it("should allow exact IP match", async () => { env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"]; await expect( validateUrlNotPrivate("https://10.0.0.1/api") ).resolves.toBeUndefined(); }); it("should allow IP within CIDR range", async () => { env.ALLOWED_PRIVATE_IP_ADDRESSES = ["192.168.1.0/24"]; lookupSpy.mockResolvedValue({ address: "192.168.1.50", family: 4 }); await expect( validateUrlNotPrivate("https://gitlab.internal") ).resolves.toBeUndefined(); }); it("should reject IP outside CIDR range", async () => { env.ALLOWED_PRIVATE_IP_ADDRESSES = ["192.168.1.0/24"]; lookupSpy.mockResolvedValue({ address: "192.168.2.1", family: 4 }); await expect( validateUrlNotPrivate("https://gitlab.internal") ).rejects.toThrow("is not allowed"); }); it("should allow resolved hostname matching allowlist", async () => { env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.5"]; lookupSpy.mockResolvedValue({ address: "10.0.0.5", family: 4 }); await expect( validateUrlNotPrivate("https://gitlab.internal") ).resolves.toBeUndefined(); }); it("should still reject non-matching private IPs", async () => { env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"]; await expect( validateUrlNotPrivate("https://10.0.0.2/api") ).rejects.toThrow("is not allowed"); }); it("should support multiple entries in allowlist", async () => { env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1", "172.16.0.0/12"]; lookupSpy.mockResolvedValue({ address: "172.20.5.10", family: 4 }); await expect( validateUrlNotPrivate("https://gitlab.internal") ).resolves.toBeUndefined(); }); }); }); describe("isInvalidAppPath", () => { it.each([ "/.well-known/gpc.json", "/.env", "/.env.production", "/.git/config", "/.DS_Store", "/cgi-bin/test.cgi", "/wp-admin/setup-config.php", "/wp-login.php", "/wp-content/plugins/foo", "/xmlrpc.php", "/admin.php", "/phpmyadmin/index.php", "/actuator/health", "/HNAP1/", "/index.php", ])("returns true for scanner path %s", (path) => { expect(isInvalidAppPath(path)).toBe(true); }); it.each([ "/", "/home", "/doc/document-slug", "/collection/abc123", "/settings/account", "/api/documents.list", ])("returns false for legitimate path %s", (path) => { expect(isInvalidAppPath(path)).toBe(false); }); });