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.
| Package | Affected versions | Patched versions |
|---|---|---|
@fedify/fedifynpm | < 0.9.2 | 0.9.2 |
@fedify/fedifynpm | >= 0.10.0, < 0.10.2 | 0.10.2 |
@fedify/fedifynpm | >= 0.11.0, < 0.11.2 | 0.11.2 |
Patches
5944d1aee1f0630f9cf4a1757c641e976089dc641e976089dMerge pull request from GHSA-p9cg-vqcc-grcx
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",
30f9cf4a1757Fix SSRF vulnerability in document loader
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- github.com/advisories/GHSA-p9cg-vqcc-grcxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-39687ghsaADVISORY
- github.com/dahlia/fedify/commit/30f9cf4a175704a04c874f3ea88414c5f1e00b28nvdWEB
- github.com/dahlia/fedify/commit/c641e976089dd913f649889c1bfb016df04e86banvdWEB
- github.com/dahlia/fedify/releases/tag/0.11.1ghsaWEB
- github.com/dahlia/fedify/security/advisories/GHSA-p9cg-vqcc-grcxnvdWEB
News mentions
0No linked articles in our index yet.