CVE-2024-39687: Insufficient Information (NVD-CWE-noinfo)
value: Fedify is a TypeScript library for building federated server apps powered by ActivityPub and other standards. At present, when Fedify needs to retrieve an object or activity from a remote activitypub server, it makes a HTTP request to the `@id` or other resources present within the activity it has received from the web. This activity could reference an `@id` that points to an internal IP address, allowing an attacker to send request to resources internal to the fedify server's network. This applies to not just resolution of documents containing activities or objects, but also to media URLs as well. Specifically this is a Server Side Request Forgery attack. Users should upgrade to Fedify version 0.9.2, 0.10.1, or 0.11.1 to receive a patch for this issue.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-07-05T18:15Z
Repository: https://github.com/dahlia/fedify
Language: TypeScript
Files changed: .vscode/settings.json, CHANGES.md, runtime/docloader.test.ts, runtime/docloader.ts, runtime/url.test.ts, runtime/url.ts
Commit date: 2024-07-05 00:27:52 +0900
Commit message: Fix SSRF vulnerability in document loader

https://github.com/dahlia/fedify/security/advisories/GHSA-p9cg-vqcc-grcx

Diff: diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2c56bd2..ee6855c 100755
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -69,10 +69,11 @@
     "Pixelfed",
     "redirections",
     "rels",
     "setext",
     "spki",
+    "SSRF",
     "subproperty",
     "superproperty",
     "tempserver",
     "unfollow",
     "unfollowing",
diff --git a/CHANGES.md b/CHANGES.md
index b19587b..213e8c1 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -6,10 +6,21 @@ Fedify changelog
 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
 -------------
 
 Released on June 13, 2024.
diff --git a/runtime/docloader.test.ts b/runtime/docloader.test.ts
index 25f7c96..65998e9 100644
--- a/runtime/docloader.test.ts
+++ b/runtime/docloader.test.ts
@@ -8,10 +8,11 @@ import {
   fetchDocumentLoader,
   FetchError,
   getAuthenticatedDocumentLoader,
   kvCache,
 } from "./docloader.ts";
+import { UrlError } from "./url.ts";
 
 Deno.test("new FetchError()", () => {
   const e = new FetchError("https://example.com/", "An error message.");
   assertEquals(e.name, "FetchError");
   assertEquals(e.url, new URL("https://example.com/"));
@@ -58,10 +59,24 @@ Deno.test("fetchDocumentLoader()", async (t) => {
       "HTTP 404: https://example.com/404",
     );
   });
 
   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) => {
   mf.install();
 
@@ -90,10 +105,26 @@ Deno.test("getAuthenticatedDocumentLoader()", async (t) => {
       document: true,
     });
   });
 
   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) => {
   const kv = new MemoryKvStore();
 
diff --git a/runtime/docloader.ts b/runtime/docloader.ts
index d03ce3b..3a29a98 100644
--- a/runtime/docloader.ts
+++ b/runtime/docloader.ts
@@ -1,9 +1,10 @@
 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"]);
 
 /**
  * A remote JSON-LD document and its context fetched by
@@ -117,10 +118,11 @@ async function getRemoteDocument(
  * @returns The remote document.
  */
 export async function fetchDocumentLoader(
   url: string,
 ): Promise<RemoteDocument> {
+  await validatePublicUrl(url);
   const request = createRequest(url);
   logRequest(request);
   const response = await fetch(request, {
     // Since Bun has a bug that ignores the `Request.redirect` option,
     // to work around it we specify `redirect: "manual"` here too:
@@ -150,10 +152,11 @@ export async function fetchDocumentLoader(
 export function getAuthenticatedDocumentLoader(
   identity: { keyId: URL; privateKey: CryptoKey },
 ): DocumentLoader {
   validateCryptoKey(identity.privateKey);
   async function load(url: string): Promise<RemoteDocument> {
+    await validatePublicUrl(url);
     let request = createRequest(url);
     request = await signRequest(request, identity.privateKey, identity.keyId);
     logRequest(request);
     const response = await fetch(request, {
       // Since Bun has a bug that ignores the `Request.redirect` option,
diff --git a/runtime/url.test.ts b/runtime/url.test.ts
new file mode 100644
index 0000000..71b24a5
--- /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 0000000..edc64b4
--- /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<void> {
+  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(":");
+}

Fixed code:     "SSRF",
 -  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

import { UrlError } from "./url.ts";

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

  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);
  });
import { validatePublicUrl } from "./url.ts";
  await validatePublicUrl(url);
    await validatePublicUrl(url);
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",
  );
});
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<void> {
  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(":");
}