Skip to content

Commit c641e97

Browse files
authored
Merge pull request from GHSA-p9cg-vqcc-grcx
Address a SSRF vulnerability in the built-in document loader
2 parents 1a3a9b8 + 30f9cf4 commit c641e97

File tree

6 files changed

+183
-0
lines changed

6 files changed

+183
-0
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"rels",
7272
"setext",
7373
"spki",
74+
"SSRF",
7475
"subproperty",
7576
"superproperty",
7677
"tempserver",

CHANGES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ Version 0.9.2
88

99
To be released.
1010

11+
- Fixed a SSRF vulnerability in the built-in document loader.
12+
[[CVE-2024-39687]]
13+
14+
- The `fetchDocumentLoader()` function now throws an error when the given
15+
URL is not an HTTP or HTTPS URL or refers to a private network address.
16+
- The `getAuthenticatedDocumentLoader()` function now returns a document
17+
loader that throws an error when the given URL is not an HTTP or HTTPS
18+
URL or refers to a private network address.
19+
20+
[CVE-2024-39687]: https://github.com/dahlia/fedify/security/advisories/GHSA-p9cg-vqcc-grcx
21+
1122

1223
Version 0.9.1
1324
-------------

runtime/docloader.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getAuthenticatedDocumentLoader,
1111
kvCache,
1212
} from "./docloader.ts";
13+
import { UrlError } from "./url.ts";
1314

1415
Deno.test("new FetchError()", () => {
1516
const e = new FetchError("https://example.com/", "An error message.");
@@ -60,6 +61,20 @@ Deno.test("fetchDocumentLoader()", async (t) => {
6061
});
6162

6263
mf.uninstall();
64+
65+
await t.step("deny non-HTTP/HTTPS", async () => {
66+
await assertRejects(
67+
() => fetchDocumentLoader("ftp://localhost"),
68+
UrlError,
69+
);
70+
});
71+
72+
await t.step("deny private network", async () => {
73+
await assertRejects(
74+
() => fetchDocumentLoader("https://localhost"),
75+
UrlError,
76+
);
77+
});
6378
});
6479

6580
Deno.test("getAuthenticatedDocumentLoader()", async (t) => {
@@ -92,6 +107,22 @@ Deno.test("getAuthenticatedDocumentLoader()", async (t) => {
92107
});
93108

94109
mf.uninstall();
110+
111+
await t.step("deny non-HTTP/HTTPS", async () => {
112+
const loader = await getAuthenticatedDocumentLoader({
113+
keyId: new URL("https://example.com/key2"),
114+
privateKey: privateKey2,
115+
});
116+
assertRejects(() => loader("ftp://localhost"), UrlError);
117+
});
118+
119+
await t.step("deny private network", async () => {
120+
const loader = await getAuthenticatedDocumentLoader({
121+
keyId: new URL("https://example.com/key2"),
122+
privateKey: privateKey2,
123+
});
124+
assertRejects(() => loader("http://localhost"), UrlError);
125+
});
95126
});
96127

97128
Deno.test("kvCache()", async (t) => {

runtime/docloader.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getLogger } from "@logtape/logtape";
22
import type { KvKey, KvStore } from "../federation/kv.ts";
33
import { signRequest } from "../sig/http.ts";
44
import { validateCryptoKey } from "../sig/key.ts";
5+
import { validatePublicUrl } from "./url.ts";
56

67
const logger = getLogger(["fedify", "runtime", "docloader"]);
78

@@ -119,6 +120,7 @@ async function getRemoteDocument(
119120
export async function fetchDocumentLoader(
120121
url: string,
121122
): Promise<RemoteDocument> {
123+
await validatePublicUrl(url);
122124
const request = createRequest(url);
123125
logRequest(request);
124126
const response = await fetch(request, {
@@ -152,6 +154,7 @@ export function getAuthenticatedDocumentLoader(
152154
): DocumentLoader {
153155
validateCryptoKey(identity.privateKey);
154156
async function load(url: string): Promise<RemoteDocument> {
157+
await validatePublicUrl(url);
155158
let request = createRequest(url);
156159
request = await signRequest(request, identity.privateKey, identity.keyId);
157160
logRequest(request);

runtime/url.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { assert } from "@std/assert/assert";
2+
import { assertEquals } from "@std/assert/assert-equals";
3+
import { assertFalse } from "@std/assert/assert-false";
4+
import { assertRejects } from "@std/assert/assert-rejects";
5+
import {
6+
expandIPv6Address,
7+
isValidPublicIPv4Address,
8+
isValidPublicIPv6Address,
9+
UrlError,
10+
validatePublicUrl,
11+
} from "./url.ts";
12+
13+
Deno.test("validatePublicUrl()", async () => {
14+
await assertRejects(() => validatePublicUrl("ftp://localhost"), UrlError);
15+
await assertRejects(
16+
// cSpell: disable
17+
() => validatePublicUrl("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="),
18+
// cSpell: enable
19+
UrlError,
20+
);
21+
await assertRejects(() => validatePublicUrl("https://localhost"), UrlError);
22+
await assertRejects(() => validatePublicUrl("https://127.0.0.1"), UrlError);
23+
await assertRejects(() => validatePublicUrl("https://[::1]"), UrlError);
24+
});
25+
26+
Deno.test("isValidPublicIPv4Address()", () => {
27+
assert(isValidPublicIPv4Address("8.8.8.8")); // Google DNS
28+
assertFalse(isValidPublicIPv4Address("192.168.1.1")); // private
29+
assertFalse(isValidPublicIPv4Address("127.0.0.1")); // localhost
30+
assertFalse(isValidPublicIPv4Address("10.0.0.1")); // private
31+
assertFalse(isValidPublicIPv4Address("127.16.0.1")); // private
32+
assertFalse(isValidPublicIPv4Address("169.254.0.1")); // link-local
33+
});
34+
35+
Deno.test("isValidPublicIPv6Address()", () => {
36+
assert(isValidPublicIPv6Address("2001:db8::1"));
37+
assertFalse(isValidPublicIPv6Address("::1")); // localhost
38+
assertFalse(isValidPublicIPv6Address("fc00::1")); // ULA
39+
assertFalse(isValidPublicIPv6Address("fe80::1")); // link-local
40+
assertFalse(isValidPublicIPv6Address("ff00::1")); // multicast
41+
assertFalse(isValidPublicIPv6Address("::")); // unspecified
42+
});
43+
44+
Deno.test("expandIPv6Address()", () => {
45+
assertEquals(
46+
expandIPv6Address("::"),
47+
"0000:0000:0000:0000:0000:0000:0000:0000",
48+
);
49+
assertEquals(
50+
expandIPv6Address("::1"),
51+
"0000:0000:0000:0000:0000:0000:0000:0001",
52+
);
53+
assertEquals(
54+
expandIPv6Address("2001:db8::"),
55+
"2001:0db8:0000:0000:0000:0000:0000:0000",
56+
);
57+
assertEquals(
58+
expandIPv6Address("2001:db8::1"),
59+
"2001:0db8:0000:0000:0000:0000:0000:0001",
60+
);
61+
});

runtime/url.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { lookup } from "node:dns/promises";
2+
import { isIP } from "node:net";
3+
4+
export class UrlError extends Error {
5+
constructor(message: string) {
6+
super(message);
7+
this.name = "UrlError";
8+
}
9+
}
10+
11+
/**
12+
* Validates a URL to prevent SSRF attacks.
13+
*/
14+
export async function validatePublicUrl(url: string): Promise<void> {
15+
const parsed = new URL(url);
16+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
17+
throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
18+
}
19+
let hostname = parsed.hostname;
20+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
21+
hostname = hostname.substring(1, hostname.length - 2);
22+
}
23+
if (hostname === "localhost") {
24+
throw new UrlError("Localhost is not allowed");
25+
}
26+
if ("Deno" in globalThis && !isIP(hostname)) {
27+
// If the `net` permission is not granted, we can't resolve the hostname.
28+
// However, we can safely assume that it cannot gain access to private
29+
// resources.
30+
const netPermission = await Deno.permissions.query({ name: "net" });
31+
if (netPermission.state !== "granted") return;
32+
}
33+
const { address, family } = await lookup(hostname);
34+
if (
35+
family === 4 && !isValidPublicIPv4Address(address) ||
36+
family === 6 && !isValidPublicIPv6Address(address) ||
37+
family < 4 || family === 5 || family > 6
38+
) {
39+
throw new UrlError(`Invalid or private address: ${address}`);
40+
}
41+
}
42+
43+
export function isValidPublicIPv4Address(address: string): boolean {
44+
const parts = address.split(".");
45+
const first = parseInt(parts[0]);
46+
if (first === 0 || first === 10 || first === 127) return false;
47+
const second = parseInt(parts[1]);
48+
if (first === 169 && second === 254) return false;
49+
if (first === 172 && second >= 16 && second <= 31) return false;
50+
if (first === 192 && second === 168) return false;
51+
return true;
52+
}
53+
54+
export function isValidPublicIPv6Address(address: string) {
55+
address = expandIPv6Address(address);
56+
if (address.at(4) !== ":") return false;
57+
const firstWord = parseInt(address.substring(0, 4), 16);
58+
return !(
59+
(firstWord >= 0xfc00 && firstWord <= 0xfdff) || // ULA
60+
(firstWord >= 0xfe80 && firstWord <= 0xfebf) || // Link-local
61+
firstWord === 0 || firstWord >= 0xff00 // Multicast
62+
);
63+
}
64+
65+
export function expandIPv6Address(address: string): string {
66+
address = address.toLowerCase();
67+
if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000";
68+
if (address.startsWith("::")) address = "0000" + address;
69+
if (address.endsWith("::")) address = address + "0000";
70+
address = address.replace(
71+
"::",
72+
":0000".repeat(8 - (address.match(/:/g) || []).length) + ":",
73+
);
74+
const parts = address.split(":");
75+
return parts.map((part) => part.padStart(4, "0")).join(":");
76+
}

0 commit comments

Comments
 (0)