@fedify/vocab-runtime 2.0.0-dev.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/request.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { Logger } from "@logtape/logtape";
2
+ import process from "node:process";
3
+ import metadata from "../deno.json" with { type: "json" };
4
+
5
+ /**
6
+ * Error thrown when fetching a JSON-LD document failed.
7
+ */
8
+ export class FetchError extends Error {
9
+ /**
10
+ * The URL that failed to fetch.
11
+ */
12
+ url: URL;
13
+
14
+ /**
15
+ * Constructs a new `FetchError`.
16
+ *
17
+ * @param url The URL that failed to fetch.
18
+ * @param message Error message.
19
+ */
20
+ constructor(url: URL | string, message?: string) {
21
+ super(message == null ? url.toString() : `${url}: ${message}`);
22
+ this.name = "FetchError";
23
+ this.url = typeof url === "string" ? new URL(url) : url;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Options for creating a request.
29
+ * @internal
30
+ */
31
+ export interface CreateRequestOptions {
32
+ userAgent?: GetUserAgentOptions | string;
33
+ }
34
+
35
+ /**
36
+ * Creates a request for the given URL.
37
+ * @param url The URL to create the request for.
38
+ * @param options The options for the request.
39
+ * @returns The created request.
40
+ * @internal
41
+ */
42
+ export function createActivityPubRequest(
43
+ url: string,
44
+ options: CreateRequestOptions = {},
45
+ ): Request {
46
+ return new Request(url, {
47
+ headers: {
48
+ Accept: "application/activity+json, application/ld+json",
49
+ "User-Agent": typeof options.userAgent === "string"
50
+ ? options.userAgent
51
+ : getUserAgent(options.userAgent),
52
+ },
53
+ redirect: "manual",
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Options for making `User-Agent` string.
59
+ * @see {@link getUserAgent}
60
+ * @since 1.3.0
61
+ */
62
+ export interface GetUserAgentOptions {
63
+ /**
64
+ * An optional software name and version, e.g., `"Hollo/1.0.0"`.
65
+ */
66
+ software?: string | null;
67
+ /**
68
+ * An optional URL to append to the user agent string.
69
+ * Usually the URL of the ActivityPub instance.
70
+ */
71
+ url?: string | URL | null;
72
+ }
73
+
74
+ /**
75
+ * Gets the user agent string for the given application and URL.
76
+ * @param options The options for making the user agent string.
77
+ * @returns The user agent string.
78
+ * @since 1.3.0
79
+ */
80
+ export function getUserAgent(
81
+ { software, url }: GetUserAgentOptions = {},
82
+ ): string {
83
+ const fedify = `Fedify/${metadata.version}`;
84
+ const runtime = globalThis.Deno?.version?.deno != null
85
+ ? `Deno/${Deno.version.deno}`
86
+ : globalThis.process?.versions?.bun != null
87
+ ? `Bun/${process.versions.bun}`
88
+ : "navigator" in globalThis &&
89
+ navigator.userAgent === "Cloudflare-Workers"
90
+ ? navigator.userAgent
91
+ : globalThis.process?.versions?.node != null
92
+ ? `Node.js/${process.versions.node}`
93
+ : null;
94
+ const userAgent = software == null ? [fedify] : [software, fedify];
95
+ if (runtime != null) userAgent.push(runtime);
96
+ if (url != null) userAgent.push(`+${url.toString()}`);
97
+ const first = userAgent.shift();
98
+ return `${first} (${userAgent.join("; ")})`;
99
+ }
100
+
101
+ /**
102
+ * Logs the request.
103
+ * @param request The request to log.
104
+ * @internal
105
+ */
106
+ export function logRequest(logger: Logger, request: Request) {
107
+ logger.debug(
108
+ "Fetching document: {method} {url} {headers}",
109
+ {
110
+ method: request.method,
111
+ url: request.url,
112
+ headers: Object.fromEntries(request.headers.entries()),
113
+ },
114
+ );
115
+ }
@@ -0,0 +1,59 @@
1
+ import { deepStrictEqual, ok, rejects } from "node:assert";
2
+ import { test } from "node:test";
3
+ import {
4
+ expandIPv6Address,
5
+ isValidPublicIPv4Address,
6
+ isValidPublicIPv6Address,
7
+ UrlError,
8
+ validatePublicUrl,
9
+ } from "./url.ts";
10
+
11
+ test("validatePublicUrl()", async () => {
12
+ await rejects(() => validatePublicUrl("ftp://localhost"), UrlError);
13
+ await rejects(
14
+ // cSpell: disable
15
+ () => validatePublicUrl("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="),
16
+ // cSpell: enable
17
+ UrlError,
18
+ );
19
+ await rejects(() => validatePublicUrl("https://localhost"), UrlError);
20
+ await rejects(() => validatePublicUrl("https://127.0.0.1"), UrlError);
21
+ await rejects(() => validatePublicUrl("https://[::1]"), UrlError);
22
+ });
23
+
24
+ test("isValidPublicIPv4Address()", () => {
25
+ ok(isValidPublicIPv4Address("8.8.8.8")); // Google DNS
26
+ ok(!isValidPublicIPv4Address("192.168.1.1")); // private
27
+ ok(!isValidPublicIPv4Address("127.0.0.1")); // localhost
28
+ ok(!isValidPublicIPv4Address("10.0.0.1")); // private
29
+ ok(!isValidPublicIPv4Address("127.16.0.1")); // private
30
+ ok(!isValidPublicIPv4Address("169.254.0.1")); // link-local
31
+ });
32
+
33
+ test("isValidPublicIPv6Address()", () => {
34
+ ok(isValidPublicIPv6Address("2001:db8::1"));
35
+ ok(!isValidPublicIPv6Address("::1")); // localhost
36
+ ok(!isValidPublicIPv6Address("fc00::1")); // ULA
37
+ ok(!isValidPublicIPv6Address("fe80::1")); // link-local
38
+ ok(!isValidPublicIPv6Address("ff00::1")); // multicast
39
+ ok(!isValidPublicIPv6Address("::")); // unspecified
40
+ });
41
+
42
+ test("expandIPv6Address()", () => {
43
+ deepStrictEqual(
44
+ expandIPv6Address("::"),
45
+ "0000:0000:0000:0000:0000:0000:0000:0000",
46
+ );
47
+ deepStrictEqual(
48
+ expandIPv6Address("::1"),
49
+ "0000:0000:0000:0000:0000:0000:0000:0001",
50
+ );
51
+ deepStrictEqual(
52
+ expandIPv6Address("2001:db8::"),
53
+ "2001:0db8:0000:0000:0000:0000:0000:0000",
54
+ );
55
+ deepStrictEqual(
56
+ expandIPv6Address("2001:db8::1"),
57
+ "2001:0db8:0000:0000:0000:0000:0000:0001",
58
+ );
59
+ });
package/src/url.ts ADDED
@@ -0,0 +1,96 @@
1
+ import type { LookupAddress } from "node:dns";
2
+ import { lookup } from "node:dns/promises";
3
+ import { isIP } from "node:net";
4
+
5
+ export class UrlError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "UrlError";
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Validates a URL to prevent SSRF attacks.
14
+ */
15
+ export async function validatePublicUrl(url: string): Promise<void> {
16
+ const parsed = new URL(url);
17
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
18
+ throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
19
+ }
20
+ let hostname = parsed.hostname;
21
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
22
+ hostname = hostname.substring(1, hostname.length - 2);
23
+ }
24
+ if (hostname === "localhost") {
25
+ throw new UrlError("Localhost is not allowed");
26
+ }
27
+ if ("Deno" in globalThis && !isIP(hostname)) {
28
+ // If the `net` permission is not granted, we can't resolve the hostname.
29
+ // However, we can safely assume that it cannot gain access to private
30
+ // resources.
31
+ const netPermission = await Deno.permissions.query({ name: "net" });
32
+ if (netPermission.state !== "granted") return;
33
+ }
34
+ // FIXME: This is a temporary workaround for the `Bun` runtime; for unknown
35
+ // reasons, the Web Crypto API does not work as expected after a DNS lookup.
36
+ // This workaround purposes to prevent unit tests from hanging up:
37
+ if ("Bun" in globalThis) {
38
+ if (hostname === "example.com" || hostname.endsWith(".example.com")) {
39
+ return;
40
+ } else if (hostname === "fedify-test.internal") {
41
+ throw new UrlError("Invalid or private address: fedify-test.internal");
42
+ }
43
+ }
44
+ // To prevent SSRF via DNS rebinding, we need to resolve all IP addresses
45
+ // and ensure that they are all public:
46
+ let addresses: LookupAddress[];
47
+ try {
48
+ addresses = await lookup(hostname, { all: true });
49
+ } catch {
50
+ addresses = [];
51
+ }
52
+ for (const { address, family } of addresses) {
53
+ if (
54
+ family === 4 && !isValidPublicIPv4Address(address) ||
55
+ family === 6 && !isValidPublicIPv6Address(address) ||
56
+ family < 4 || family === 5 || family > 6
57
+ ) {
58
+ throw new UrlError(`Invalid or private address: ${address}`);
59
+ }
60
+ }
61
+ }
62
+
63
+ export function isValidPublicIPv4Address(address: string): boolean {
64
+ const parts = address.split(".");
65
+ const first = parseInt(parts[0]);
66
+ if (first === 0 || first === 10 || first === 127) return false;
67
+ const second = parseInt(parts[1]);
68
+ if (first === 169 && second === 254) return false;
69
+ if (first === 172 && second >= 16 && second <= 31) return false;
70
+ if (first === 192 && second === 168) return false;
71
+ return true;
72
+ }
73
+
74
+ export function isValidPublicIPv6Address(address: string): boolean {
75
+ address = expandIPv6Address(address);
76
+ if (address.at(4) !== ":") return false;
77
+ const firstWord = parseInt(address.substring(0, 4), 16);
78
+ return !(
79
+ (firstWord >= 0xfc00 && firstWord <= 0xfdff) || // ULA
80
+ (firstWord >= 0xfe80 && firstWord <= 0xfebf) || // Link-local
81
+ firstWord === 0 || firstWord >= 0xff00 // Multicast
82
+ );
83
+ }
84
+
85
+ export function expandIPv6Address(address: string): string {
86
+ address = address.toLowerCase();
87
+ if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000";
88
+ if (address.startsWith("::")) address = "0000" + address;
89
+ if (address.endsWith("::")) address = address + "0000";
90
+ address = address.replace(
91
+ "::",
92
+ ":0000".repeat(8 - (address.match(/:/g) || []).length) + ":",
93
+ );
94
+ const parts = address.split(":");
95
+ return parts.map((part) => part.padStart(4, "0")).join(":");
96
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/mod.ts"],
5
+ dts: true,
6
+ format: ["esm", "cjs"],
7
+ platform: "node",
8
+ external: [/^node:/],
9
+ });