Files
outline/server/utils/url.test.ts
T

169 lines
5.3 KiB
TypeScript

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);
});
});