mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
169 lines
5.3 KiB
TypeScript
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);
|
|
});
|
|
});
|