Skip to content

Commit

Permalink
Fix SSRF vulnerability in document loader
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Jul 4, 2024
1 parent 1a3a9b8 commit 30f9cf4
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 0 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"rels",
"setext",
"spki",
"SSRF",
"subproperty",
"superproperty",
"tempserver",
Expand Down
11 changes: 11 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
31 changes: 31 additions & 0 deletions runtime/docloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions runtime/docloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions runtime/url.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
76 changes: 76 additions & 0 deletions runtime/url.ts
Original file line number Diff line number Diff line change
@@ -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(":");
}

0 comments on commit 30f9cf4

Please sign in to comment.