VYPR
High severity7.2NVD Advisory· Published Jul 5, 2024· Updated Apr 15, 2026

CVE-2024-39687

CVE-2024-39687

Description

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.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@fedify/fedifynpm
< 0.9.20.9.2
@fedify/fedifynpm
>= 0.10.0, < 0.10.20.10.2
@fedify/fedifynpm
>= 0.11.0, < 0.11.20.11.2

Patches

5
c641e976089d

Merge pull request from GHSA-p9cg-vqcc-grcx

https://github.com/dahlia/fedifyHong Minhee (洪 民憙)Jul 5, 2024via ghsa
6 files changed · +183 0
  • CHANGES.md+11 0 modified
    @@ -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
     -------------
    
  • runtime/docloader.test.ts+31 0 modified
    @@ -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) => {
    
  • runtime/docloader.ts+3 0 modified
    @@ -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);
    
  • runtime/url.test.ts+61 0 added
    @@ -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",
    +  );
    +});
    
  • runtime/url.ts+76 0 added
    @@ -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(":");
    +}
    
  • .vscode/settings.json+1 0 modified
    @@ -71,6 +71,7 @@
         "rels",
         "setext",
         "spki",
    +    "SSRF",
         "subproperty",
         "superproperty",
         "tempserver",
    
30f9cf4a1757

Fix SSRF vulnerability in document loader

https://github.com/dahlia/fedifyHong MinheeJul 4, 2024via ghsa
6 files changed · +183 0
  • CHANGES.md+11 0 modified
    @@ -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
     -------------
    
  • runtime/docloader.test.ts+31 0 modified
    @@ -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) => {
    
  • runtime/docloader.ts+3 0 modified
    @@ -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);
    
  • runtime/url.test.ts+61 0 added
    @@ -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",
    +  );
    +});
    
  • runtime/url.ts+76 0 added
    @@ -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(":");
    +}
    
  • .vscode/settings.json+1 0 modified
    @@ -71,6 +71,7 @@
         "rels",
         "setext",
         "spki",
    +    "SSRF",
         "subproperty",
         "superproperty",
         "tempserver",
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.