diff --git a/server/utils/url.test.ts b/server/utils/url.test.ts index ca7f34b2e3..576785882b 100644 --- a/server/utils/url.test.ts +++ b/server/utils/url.test.ts @@ -50,6 +50,41 @@ describe("validateUrlNotPrivate", () => { ).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"]; diff --git a/server/utils/url.ts b/server/utils/url.ts index 61b5017c5d..fbf82073be 100644 --- a/server/utils/url.ts +++ b/server/utils/url.ts @@ -7,15 +7,6 @@ import { InvalidRequestError } from "@server/errors"; const UrlIdLength = 10; -/** IP ranges that are not allowed for outbound requests. */ -const privateRanges = new Set([ - "private", - "loopback", - "linkLocal", - "uniqueLocal", - "unspecified", -]); - export const generateUrlId = () => randomString(UrlIdLength); // Paths probed by vulnerability scanners. @@ -53,7 +44,9 @@ export function isPrivateIP(ip: string): boolean { if (!ipaddr.isValid(ip)) { return false; } - return privateRanges.has(ipaddr.parse(ip).range()); + + // Only globally-routable unicast addresses are permitted + return ipaddr.parse(ip).range() !== "unicast"; } /** @@ -102,7 +95,9 @@ function isAllowedPrivateIP(ip: string): boolean { * @throws InternalError if the URL resolves to a private IP that is not allowed. */ export async function validateUrlNotPrivate(url: string) { - const { hostname } = new URL(url); + // URL.hostname keeps the square brackets around IPv6 literals (e.g. + // "[::1]"), which net.isIP does not accept, so strip them before checking. + const hostname = new URL(url).hostname.replace(/^\[|\]$/g, ""); if (net.isIP(hostname)) { if (isPrivateIP(hostname) && !isAllowedPrivateIP(hostname)) {