@fedify/vocab-runtime 2.0.0-pr.451.1730

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.
@@ -0,0 +1,103 @@
1
+ import type { CodecFactory } from "./types.d.ts";
2
+
3
+ const decode = (
4
+ string: string,
5
+ alphabet: string,
6
+ bitsPerChar: number,
7
+ ): Uint8Array => {
8
+ // Build the character lookup table:
9
+ const codes: Record<string, number> = {};
10
+ for (let i = 0; i < alphabet.length; ++i) {
11
+ codes[alphabet[i]] = i;
12
+ }
13
+
14
+ // Count the padding bytes:
15
+ let end = string.length;
16
+ while (string[end - 1] === "=") {
17
+ --end;
18
+ }
19
+
20
+ // Allocate the output:
21
+ const out = new Uint8Array((end * bitsPerChar / 8) | 0);
22
+
23
+ // Parse the data:
24
+ let bits = 0; // Number of bits currently in the buffer
25
+ let buffer = 0; // Bits waiting to be written out, MSB first
26
+ let written = 0; // Next byte to write
27
+ for (let i = 0; i < end; ++i) {
28
+ // Read one character from the string:
29
+ const value = codes[string[i]];
30
+ if (value === undefined) {
31
+ throw new SyntaxError("Invalid character " + string[i]);
32
+ }
33
+
34
+ // Append the bits to the buffer:
35
+ buffer = (buffer << bitsPerChar) | value;
36
+ bits += bitsPerChar;
37
+
38
+ // Write out some bits if the buffer has a byte's worth:
39
+ if (bits >= 8) {
40
+ bits -= 8;
41
+ out[written++] = 0xff & (buffer >> bits);
42
+ }
43
+ }
44
+
45
+ // Verify that we have received just enough bits:
46
+ if (bits >= bitsPerChar || 0xff & (buffer << (8 - bits))) {
47
+ throw new SyntaxError("Unexpected end of data");
48
+ }
49
+
50
+ return out;
51
+ };
52
+
53
+ const encode = (
54
+ data: Uint8Array,
55
+ alphabet: string,
56
+ bitsPerChar: number,
57
+ ): string => {
58
+ const pad = alphabet[alphabet.length - 1] === "=";
59
+ const mask = (1 << bitsPerChar) - 1;
60
+ let out = "";
61
+
62
+ let bits = 0; // Number of bits currently in the buffer
63
+ let buffer = 0; // Bits waiting to be written out, MSB first
64
+ for (let i = 0; i < data.length; ++i) {
65
+ // Slurp data into the buffer:
66
+ buffer = (buffer << 8) | data[i];
67
+ bits += 8;
68
+
69
+ // Write out as much as we can:
70
+ while (bits > bitsPerChar) {
71
+ bits -= bitsPerChar;
72
+ out += alphabet[mask & (buffer >> bits)];
73
+ }
74
+ }
75
+
76
+ // Partial character:
77
+ if (bits) {
78
+ out += alphabet[mask & (buffer << (bitsPerChar - bits))];
79
+ }
80
+
81
+ // Add padding characters until we hit a byte boundary:
82
+ if (pad) {
83
+ while ((out.length * bitsPerChar) & 7) {
84
+ out += "=";
85
+ }
86
+ }
87
+
88
+ return out;
89
+ };
90
+
91
+ /**
92
+ * RFC4648 Factory
93
+ */
94
+ export const rfc4648 = (bitsPerChar: number): CodecFactory => (alphabet) => {
95
+ return {
96
+ encode(input: Uint8Array): string {
97
+ return encode(input, alphabet, bitsPerChar);
98
+ },
99
+ decode(input: string): Uint8Array {
100
+ return decode(input, alphabet, bitsPerChar);
101
+ },
102
+ };
103
+ };
@@ -0,0 +1,61 @@
1
+ export type BaseCode =
2
+ | "\x00"
3
+ | "0"
4
+ | "7"
5
+ | "9"
6
+ | "f"
7
+ | "F"
8
+ | "v"
9
+ | "V"
10
+ | "t"
11
+ | "T"
12
+ | "b"
13
+ | "B"
14
+ | "c"
15
+ | "C"
16
+ | "h"
17
+ | "k"
18
+ | "K"
19
+ | "z"
20
+ | "Z"
21
+ | "m"
22
+ | "M"
23
+ | "u"
24
+ | "U";
25
+
26
+ /**
27
+ * - Names of the supported encodings
28
+ */
29
+ export type BaseName =
30
+ | "identity"
31
+ | "base2"
32
+ | "base8"
33
+ | "base10"
34
+ | "base16"
35
+ | "base16upper"
36
+ | "base32hex"
37
+ | "base32hexupper"
38
+ | "base32hexpad"
39
+ | "base32hexpadupper"
40
+ | "base32"
41
+ | "base32upper"
42
+ | "base32pad"
43
+ | "base32padupper"
44
+ | "base32z"
45
+ | "base36"
46
+ | "base36upper"
47
+ | "base58btc"
48
+ | "base58flickr"
49
+ | "base64"
50
+ | "base64pad"
51
+ | "base64url"
52
+ | "base64urlpad";
53
+
54
+ export type BaseNameOrCode = BaseCode | BaseName;
55
+ export interface Codec {
56
+ encode: (buffer: Uint8Array) => string;
57
+ decode: (hash: string) => Uint8Array;
58
+ }
59
+ export interface CodecFactory {
60
+ (input: string): Codec;
61
+ }
@@ -0,0 +1,22 @@
1
+ const textDecoder = new TextDecoder();
2
+ export const decodeText = (bytes: DataView | Uint8Array): string =>
3
+ textDecoder.decode(bytes);
4
+
5
+ const textEncoder = new TextEncoder();
6
+ export const encodeText = (text: string): Uint8Array =>
7
+ textEncoder.encode(text);
8
+
9
+ export function concat(
10
+ arrs: Array<ArrayLike<number>>,
11
+ length: number,
12
+ ): Uint8Array {
13
+ const output = new Uint8Array(length);
14
+ let offset = 0;
15
+
16
+ for (const arr of arrs) {
17
+ output.set(arr, offset);
18
+ offset += arr.length;
19
+ }
20
+
21
+ return output;
22
+ }
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 createRequest(
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) {
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
+ });