diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c56bd28..ee6855cb 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -71,6 +71,7 @@ "rels", "setext", "spki", + "SSRF", "subproperty", "superproperty", "tempserver", diff --git a/CHANGES.md b/CHANGES.md index b19587b3..213e8c1c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,17 @@ Version 0.9.2 To be released. + - Fixed a SSRF vulnerability in the built-in document loader. + [[CVE-2024-39687]] + + - The `fetchDocumentLoader()` function now throws an error when the given + URL is not an HTTP or HTTPS URL or refers to a private network address. + - The `getAuthenticatedDocumentLoader()` function now returns a document + loader that throws an error when the given URL is not an HTTP or HTTPS + URL or refers to a private network address. + +[CVE-2024-39687]: https://github.com/dahlia/fedify/security/advisories/GHSA-p9cg-vqcc-grcx + Version 0.9.1 ------------- diff --git a/runtime/docloader.test.ts b/runtime/docloader.test.ts index 25f7c962..65998e90 100644 --- a/runtime/docloader.test.ts +++ b/runtime/docloader.test.ts @@ -10,6 +10,7 @@ import { getAuthenticatedDocumentLoader, kvCache, } from "./docloader.ts"; +import { UrlError } from "./url.ts"; Deno.test("new FetchError()", () => { const e = new FetchError("https://example.com/", "An error message."); @@ -60,6 +61,20 @@ Deno.test("fetchDocumentLoader()", async (t) => { }); mf.uninstall(); + + await t.step("deny non-HTTP/HTTPS", async () => { + await assertRejects( + () => fetchDocumentLoader("ftp://localhost"), + UrlError, + ); + }); + + await t.step("deny private network", async () => { + await assertRejects( + () => fetchDocumentLoader("https://localhost"), + UrlError, + ); + }); }); Deno.test("getAuthenticatedDocumentLoader()", async (t) => { @@ -92,6 +107,22 @@ Deno.test("getAuthenticatedDocumentLoader()", async (t) => { }); mf.uninstall(); + + await t.step("deny non-HTTP/HTTPS", async () => { + const loader = await getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/key2"), + privateKey: privateKey2, + }); + assertRejects(() => loader("ftp://localhost"), UrlError); + }); + + await t.step("deny private network", async () => { + const loader = await getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/key2"), + privateKey: privateKey2, + }); + assertRejects(() => loader("http://localhost"), UrlError); + }); }); Deno.test("kvCache()", async (t) => { diff --git a/runtime/docloader.ts b/runtime/docloader.ts index d03ce3b1..3a29a989 100644 --- a/runtime/docloader.ts +++ b/runtime/docloader.ts @@ -2,6 +2,7 @@ import { getLogger } from "@logtape/logtape"; import type { KvKey, KvStore } from "../federation/kv.ts"; import { signRequest } from "../sig/http.ts"; import { validateCryptoKey } from "../sig/key.ts"; +import { validatePublicUrl } from "./url.ts"; const logger = getLogger(["fedify", "runtime", "docloader"]); @@ -119,6 +120,7 @@ async function getRemoteDocument( export async function fetchDocumentLoader( url: string, ): Promise { + await validatePublicUrl(url); const request = createRequest(url); logRequest(request); const response = await fetch(request, { @@ -152,6 +154,7 @@ export function getAuthenticatedDocumentLoader( ): DocumentLoader { validateCryptoKey(identity.privateKey); async function load(url: string): Promise { + await validatePublicUrl(url); let request = createRequest(url); request = await signRequest(request, identity.privateKey, identity.keyId); logRequest(request); diff --git a/runtime/url.test.ts b/runtime/url.test.ts new file mode 100644 index 00000000..71b24a54 --- /dev/null +++ b/runtime/url.test.ts @@ -0,0 +1,61 @@ +import { assert } from "@std/assert/assert"; +import { assertEquals } from "@std/assert/assert-equals"; +import { assertFalse } from "@std/assert/assert-false"; +import { assertRejects } from "@std/assert/assert-rejects"; +import { + expandIPv6Address, + isValidPublicIPv4Address, + isValidPublicIPv6Address, + UrlError, + validatePublicUrl, +} from "./url.ts"; + +Deno.test("validatePublicUrl()", async () => { + await assertRejects(() => validatePublicUrl("ftp://localhost"), UrlError); + await assertRejects( + // cSpell: disable + () => validatePublicUrl("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="), + // cSpell: enable + UrlError, + ); + await assertRejects(() => validatePublicUrl("https://localhost"), UrlError); + await assertRejects(() => validatePublicUrl("https://127.0.0.1"), UrlError); + await assertRejects(() => validatePublicUrl("https://[::1]"), UrlError); +}); + +Deno.test("isValidPublicIPv4Address()", () => { + assert(isValidPublicIPv4Address("8.8.8.8")); // Google DNS + assertFalse(isValidPublicIPv4Address("192.168.1.1")); // private + assertFalse(isValidPublicIPv4Address("127.0.0.1")); // localhost + assertFalse(isValidPublicIPv4Address("10.0.0.1")); // private + assertFalse(isValidPublicIPv4Address("127.16.0.1")); // private + assertFalse(isValidPublicIPv4Address("169.254.0.1")); // link-local +}); + +Deno.test("isValidPublicIPv6Address()", () => { + assert(isValidPublicIPv6Address("2001:db8::1")); + assertFalse(isValidPublicIPv6Address("::1")); // localhost + assertFalse(isValidPublicIPv6Address("fc00::1")); // ULA + assertFalse(isValidPublicIPv6Address("fe80::1")); // link-local + assertFalse(isValidPublicIPv6Address("ff00::1")); // multicast + assertFalse(isValidPublicIPv6Address("::")); // unspecified +}); + +Deno.test("expandIPv6Address()", () => { + assertEquals( + expandIPv6Address("::"), + "0000:0000:0000:0000:0000:0000:0000:0000", + ); + assertEquals( + expandIPv6Address("::1"), + "0000:0000:0000:0000:0000:0000:0000:0001", + ); + assertEquals( + expandIPv6Address("2001:db8::"), + "2001:0db8:0000:0000:0000:0000:0000:0000", + ); + assertEquals( + expandIPv6Address("2001:db8::1"), + "2001:0db8:0000:0000:0000:0000:0000:0001", + ); +}); diff --git a/runtime/url.ts b/runtime/url.ts new file mode 100644 index 00000000..edc64b4b --- /dev/null +++ b/runtime/url.ts @@ -0,0 +1,76 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +export class UrlError extends Error { + constructor(message: string) { + super(message); + this.name = "UrlError"; + } +} + +/** + * Validates a URL to prevent SSRF attacks. + */ +export async function validatePublicUrl(url: string): Promise { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new UrlError(`Unsupported protocol: ${parsed.protocol}`); + } + let hostname = parsed.hostname; + if (hostname.startsWith("[") && hostname.endsWith("]")) { + hostname = hostname.substring(1, hostname.length - 2); + } + if (hostname === "localhost") { + throw new UrlError("Localhost is not allowed"); + } + if ("Deno" in globalThis && !isIP(hostname)) { + // If the `net` permission is not granted, we can't resolve the hostname. + // However, we can safely assume that it cannot gain access to private + // resources. + const netPermission = await Deno.permissions.query({ name: "net" }); + if (netPermission.state !== "granted") return; + } + const { address, family } = await lookup(hostname); + if ( + family === 4 && !isValidPublicIPv4Address(address) || + family === 6 && !isValidPublicIPv6Address(address) || + family < 4 || family === 5 || family > 6 + ) { + throw new UrlError(`Invalid or private address: ${address}`); + } +} + +export function isValidPublicIPv4Address(address: string): boolean { + const parts = address.split("."); + const first = parseInt(parts[0]); + if (first === 0 || first === 10 || first === 127) return false; + const second = parseInt(parts[1]); + if (first === 169 && second === 254) return false; + if (first === 172 && second >= 16 && second <= 31) return false; + if (first === 192 && second === 168) return false; + return true; +} + +export function isValidPublicIPv6Address(address: string) { + address = expandIPv6Address(address); + if (address.at(4) !== ":") return false; + const firstWord = parseInt(address.substring(0, 4), 16); + return !( + (firstWord >= 0xfc00 && firstWord <= 0xfdff) || // ULA + (firstWord >= 0xfe80 && firstWord <= 0xfebf) || // Link-local + firstWord === 0 || firstWord >= 0xff00 // Multicast + ); +} + +export function expandIPv6Address(address: string): string { + address = address.toLowerCase(); + if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000"; + if (address.startsWith("::")) address = "0000" + address; + if (address.endsWith("::")) address = address + "0000"; + address = address.replace( + "::", + ":0000".repeat(8 - (address.match(/:/g) || []).length) + ":", + ); + const parts = address.split(":"); + return parts.map((part) => part.padStart(4, "0")).join(":"); +}