CVE-2024-39687 (published 2024-07-05T18:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/dahlia/fedify
commit_details: repo_url: https://github.com/dahlia/fedify
author: Hong Minhee
author_date: 2024-07-05 00:27:52+09:00
msg: Fix SSRF vulnerability in document loader

https://github.com/dahlia/fedify/security/advisories/GHSA-p9cg-vqcc-grcx
repository: repo_url: https://github.com/dahlia/fedify
repo_name: dahlia/fedify
description: ActivityPub server framework in TypeScript
repo_language: TypeScript
owner: dahlia
file_changes: filename: settings.json
change_type: ModificationType.MODIFY
diff: @@ -71,6 +71,7 @@
     "rels",
     "setext",
     "spki",
+    "SSRF",
     "subproperty",
     "superproperty",
     "tempserver",

programming_language: JavaScript
filename: CHANGES.md
change_type: ModificationType.MODIFY
diff: @@ -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
 -------------

programming_language: Markdown
filename: docloader.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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) => {

programming_language: TypeScript
filename: docloader.ts
change_type: ModificationType.MODIFY
diff: @@ -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<RemoteDocument> {
+  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<RemoteDocument> {
+    await validatePublicUrl(url);
     let request = createRequest(url);
     request = await signRequest(request, identity.privateKey, identity.keyId);
     logRequest(request);

programming_language: TypeScript
filename: url.test.ts
change_type: ModificationType.ADD
diff: @@ -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",
+  );
+});

programming_language: Java
filename: url.ts
change_type: ModificationType.ADD
diff: @@ -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(":");
+}

programming_language: TypeScript
repo_url: https://github.com/dahlia/fedify
commit_details: repo_url: https://github.com/dahlia/fedify
author: Hong Minhee (洪 民憙)
author_date: 2024-07-05 11:00:44+09:00
msg: Merge pull request from GHSA-p9cg-vqcc-grcx

Address a SSRF vulnerability in the built-in document loader
repository: repo_url: https://github.com/dahlia/fedify
repo_name: dahlia/fedify
description: ActivityPub server framework in TypeScript
repo_language: TypeScript
owner: dahlia