@fedify/vocab-runtime 2.5.0-dev.2446 → 2.5.0-dev.2492

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.
Files changed (121) hide show
  1. package/deno.json +5 -2
  2. package/dist/{docloader-BBnBpbsG.d.cts → docloader-DD6binpN.d.cts} +4 -8
  3. package/dist/{docloader-BBnBpbsG.d.ts → docloader-DD6binpN.d.ts} +4 -8
  4. package/dist/internal/jsonld-cache.cjs +1 -1
  5. package/dist/internal/jsonld-cache.d.cts +1 -1
  6. package/dist/internal/jsonld-cache.d.ts +1 -1
  7. package/dist/internal/jsonld-cache.js +1 -1
  8. package/dist/internal/portable-dereference.cjs +72 -7
  9. package/dist/internal/portable-dereference.d.cts +63 -3
  10. package/dist/internal/portable-dereference.d.ts +63 -3
  11. package/dist/internal/portable-dereference.js +72 -8
  12. package/dist/internal.cjs +6 -0
  13. package/dist/internal.d.cts +108 -0
  14. package/dist/internal.d.ts +108 -0
  15. package/dist/internal.js +3 -0
  16. package/dist/mod.cjs +104 -38
  17. package/dist/mod.d.cts +3 -336
  18. package/dist/mod.d.ts +4 -336
  19. package/dist/mod.js +104 -38
  20. package/dist/{portable-BDydjp5H.d.ts → portable-1VSnSJFj.d.ts} +1 -1
  21. package/dist/{portable-Dfvcg_Wy.d.cts → portable-Cr7NwZwb.d.cts} +1 -1
  22. package/dist/public-fetch-Dvfvawei.cjs +527 -0
  23. package/dist/public-fetch-ziZ39L5I.js +510 -0
  24. package/dist/tests/{body-Ddx6WgR5.cjs → body-C8d1PmDa.cjs} +1 -1
  25. package/dist/tests/{body-CQi2bVi5.mjs → body-HSLxhexN.mjs} +1 -1
  26. package/dist/tests/body.test.cjs +1 -1
  27. package/dist/tests/body.test.mjs +1 -1
  28. package/dist/tests/decimal.test.cjs +4 -4
  29. package/dist/tests/decimal.test.mjs +4 -4
  30. package/dist/tests/{docloader-C3vDXwn1.cjs → docloader-D1fNCGam.cjs} +111 -39
  31. package/dist/tests/{docloader-CrVannnU.mjs → docloader-DkEUFJui.mjs} +106 -40
  32. package/dist/tests/docloader.test.cjs +531 -1293
  33. package/dist/tests/docloader.test.mjs +472 -1252
  34. package/dist/tests/esm-Ds-eHTfY.mjs +1229 -0
  35. package/dist/tests/esm-Q2tdh5f3.cjs +1215 -0
  36. package/dist/tests/internal/portable-dereference.test.cjs +3 -3
  37. package/dist/tests/internal/portable-dereference.test.mjs +3 -3
  38. package/dist/tests/{jsonld-cache-BEXiiM-V.cjs → jsonld-cache-DKIWKxEX.cjs} +1 -1
  39. package/dist/tests/{jsonld-cache-DFnWsLuZ.mjs → jsonld-cache-KH40z9_7.mjs} +1 -1
  40. package/dist/tests/jsonld-cache.test.cjs +2 -2
  41. package/dist/tests/jsonld-cache.test.mjs +2 -2
  42. package/dist/tests/node-CbBR-th-.mjs +84 -0
  43. package/dist/tests/node-Ch29PDXR.cjs +89 -0
  44. package/dist/tests/pin-grant-CPrpk1l4.cjs +70 -0
  45. package/dist/tests/pin-grant-H5XhM1Xs.mjs +53 -0
  46. package/dist/tests/pin-grant.test.cjs +87 -0
  47. package/dist/tests/pin-grant.test.d.cts +1 -0
  48. package/dist/tests/pin-grant.test.d.mts +1 -0
  49. package/dist/tests/pin-grant.test.mjs +88 -0
  50. package/dist/tests/pinners/bun.test.cjs +166 -0
  51. package/dist/tests/pinners/bun.test.d.cts +1 -0
  52. package/dist/tests/pinners/bun.test.d.mts +1 -0
  53. package/dist/tests/pinners/bun.test.mjs +167 -0
  54. package/dist/tests/pinners/node.test.cjs +269 -0
  55. package/dist/tests/pinners/node.test.d.cts +1 -0
  56. package/dist/tests/pinners/node.test.d.mts +1 -0
  57. package/dist/tests/pinners/node.test.mjs +268 -0
  58. package/dist/tests/pinners/pinners.test.cjs +196 -0
  59. package/dist/tests/pinners/pinners.test.d.cts +1 -0
  60. package/dist/tests/pinners/pinners.test.d.mts +1 -0
  61. package/dist/tests/pinners/pinners.test.mjs +197 -0
  62. package/dist/tests/pinners/testdata/node-child.cjs +135 -0
  63. package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
  64. package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
  65. package/dist/tests/pinners/testdata/node-child.mjs +134 -0
  66. package/dist/tests/pinners-DLH_npLf.cjs +220 -0
  67. package/dist/tests/pinners-tGcl49uF.mjs +203 -0
  68. package/dist/tests/{portable-dereference-DaTBBvCj.cjs → portable-dereference-BUoDzMxk.cjs} +2 -2
  69. package/dist/tests/{portable-dereference-sGRs_qdT.mjs → portable-dereference-Du2k3ngE.mjs} +2 -2
  70. package/dist/tests/{portable-media-BJf_gGQz.cjs → portable-media-BM9zgj9g.cjs} +3 -3
  71. package/dist/tests/{portable-media-Cc58dUeE.mjs → portable-media-CTxGibcj.mjs} +3 -3
  72. package/dist/tests/portable-media.test.cjs +1 -1
  73. package/dist/tests/portable-media.test.mjs +1 -1
  74. package/dist/tests/portable-workers.test.cjs +2 -2
  75. package/dist/tests/portable-workers.test.mjs +2 -2
  76. package/dist/tests/public-fetch-BGj5pzUD.cjs +186 -0
  77. package/dist/tests/public-fetch-Cxo3Yzdf.mjs +170 -0
  78. package/dist/tests/public-fetch.test.cjs +1073 -0
  79. package/dist/tests/public-fetch.test.d.cts +1 -0
  80. package/dist/tests/public-fetch.test.d.mts +1 -0
  81. package/dist/tests/public-fetch.test.mjs +1074 -0
  82. package/dist/tests/{request-glvxfx2U.cjs → request-7q-Zfe1p.cjs} +2 -2
  83. package/dist/tests/{request-1pPyYz3e.mjs → request-D5uGtmmq.mjs} +2 -2
  84. package/dist/tests/request.test.cjs +2 -2
  85. package/dist/tests/request.test.mjs +2 -2
  86. package/dist/tests/support-Bj44xlQu.mjs +272 -0
  87. package/dist/tests/support-BrdlbLJk.cjs +363 -0
  88. package/dist/tests/{url-BNakuZ8k.cjs → url-Bdj-rTcC.cjs} +88 -12
  89. package/dist/tests/{url-DMxmp7ZG.mjs → url-COj5Q8mb.mjs} +65 -13
  90. package/dist/tests/url.test.cjs +4 -1
  91. package/dist/tests/url.test.mjs +4 -1
  92. package/dist/url-4EE56sNP.d.cts +352 -0
  93. package/dist/url-4EE56sNP.d.ts +352 -0
  94. package/dist/{url-DrGTR8yv.cjs → url-DIKZnaJv.cjs} +88 -12
  95. package/dist/{url-Dzyp-NsC.js → url-mWveR5GA.js} +65 -13
  96. package/package.json +14 -3
  97. package/src/docloader.test.ts +696 -48
  98. package/src/docloader.ts +186 -75
  99. package/src/internal/portable-dereference.ts +116 -6
  100. package/src/internal.ts +13 -0
  101. package/src/pin-grant.test.ts +118 -0
  102. package/src/pin-grant.ts +99 -0
  103. package/src/pinners/bun.test.ts +278 -0
  104. package/src/pinners/bun.ts +223 -0
  105. package/src/pinners/mod.ts +59 -0
  106. package/src/pinners/node.test.ts +415 -0
  107. package/src/pinners/node.ts +144 -0
  108. package/src/pinners/pinners.test.ts +316 -0
  109. package/src/pinners/testdata/node-child.ts +147 -0
  110. package/src/pinners/testdata/proxy-child.ts +82 -0
  111. package/src/pinners/testdata/support.ts +343 -0
  112. package/src/pinners/testdata/tls-cert.pem +12 -0
  113. package/src/pinners/testdata/tls-child.ts +88 -0
  114. package/src/pinners/testdata/tls-key.pem +5 -0
  115. package/src/pinners/types.ts +48 -0
  116. package/src/public-fetch.test.ts +1286 -0
  117. package/src/public-fetch.ts +338 -0
  118. package/src/testdata/public-fetch-deno-worker.ts +74 -0
  119. package/src/url.test.ts +11 -0
  120. package/src/url.ts +113 -22
  121. package/tsdown.config.ts +7 -2
@@ -0,0 +1,338 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import { issuePinGrant, type PinGrant, revokePinGrant } from "./pin-grant.ts";
3
+ import {
4
+ type ConnectionPinner,
5
+ detectRuntime,
6
+ getRuntimePinner,
7
+ } from "./pinners/mod.ts";
8
+ import {
9
+ type LookupAllAddresses,
10
+ lookupAllAddresses,
11
+ lookupPublicAddresses,
12
+ parsePublicUrl,
13
+ type ResolvedAddress,
14
+ UrlError,
15
+ } from "./url.ts";
16
+
17
+ const logger = getLogger(["fedify", "runtime", "fetch"]);
18
+
19
+ /**
20
+ * Options for {@link openPublicFetch} and {@link withPublicFetch}.
21
+ */
22
+ export interface PublicFetchOptions {
23
+ /**
24
+ * An `AbortSignal` for cancelling the DNS lookup.
25
+ */
26
+ readonly signal?: AbortSignal;
27
+
28
+ /**
29
+ * A function that resolves all addresses of a hostname, used instead of
30
+ * `node:dns.lookup()`. The addresses it returns are validated in the same
31
+ * way as the default lookup's.
32
+ */
33
+ readonly lookup?: (hostname: string) => Promise<readonly ResolvedAddress[]>;
34
+ }
35
+
36
+ /**
37
+ * A session for fetching resources from a single origin. Its `fetch()`
38
+ * connects only to the addresses validated when the session was opened, on
39
+ * runtimes that support it.
40
+ */
41
+ export interface PublicFetchSession {
42
+ /**
43
+ * A copy of the URL the session was opened for.
44
+ */
45
+ readonly url: URL;
46
+
47
+ /**
48
+ * Fetches a resource from the session's origin. Redirects are never
49
+ * followed; the redirect mode is always `"manual"`.
50
+ * @throws {TypeError} If the input's origin differs from the session's, or
51
+ * if the session is closed.
52
+ */
53
+ fetch(input: Request | URL | string, init?: RequestInit): Promise<Response>;
54
+
55
+ /**
56
+ * Closes the session. The cancellation of response bodies that have been
57
+ * neither consumed nor cancelled is started (without waiting for it), and
58
+ * the session can no longer fetch. Calling this more than once has no
59
+ * effect.
60
+ */
61
+ close(): Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * The functions created by {@link createPublicFetch}.
66
+ */
67
+ export interface PublicFetch {
68
+ /**
69
+ * Validates the URL, resolves its hostname, checks that every resolved
70
+ * address is public, and opens a session that connects only to those
71
+ * addresses.
72
+ * @param url The URL to open a session for.
73
+ * @param options The options.
74
+ * @returns The session. It must be closed.
75
+ * @throws {UrlError} If the URL is not allowed, or its hostname cannot be
76
+ * resolved (with `reason: "dns"`).
77
+ */
78
+ openPublicFetch(
79
+ url: URL | string,
80
+ options?: PublicFetchOptions,
81
+ ): Promise<PublicFetchSession>;
82
+
83
+ /**
84
+ * Opens a session with {@link PublicFetch.openPublicFetch}, runs the given
85
+ * function with its `fetch()`, and closes the session afterwards.
86
+ * @param url The URL to open a session for.
87
+ * @param fn The function to run. It must not return a `Response`, since
88
+ * the response bodies are cancelled when the session is closed.
89
+ * @param options The options.
90
+ * @returns The value returned by `fn`.
91
+ * @throws {UrlError} If the URL is not allowed.
92
+ * @throws {TypeError} If `fn` returns a `Response`.
93
+ */
94
+ withPublicFetch<T>(
95
+ url: URL | string,
96
+ fn: (fetch: PublicFetchSession["fetch"]) => Promise<T>,
97
+ options?: PublicFetchOptions,
98
+ ): Promise<T>;
99
+ }
100
+
101
+ /**
102
+ * The dependencies of {@link createPublicFetch}.
103
+ */
104
+ export interface PublicFetchDependencies {
105
+ /**
106
+ * Gets the pinner to connect with, or `null` to use the global `fetch()`.
107
+ */
108
+ readonly pinner: () => Promise<ConnectionPinner | null>;
109
+
110
+ /**
111
+ * The default lookup function.
112
+ */
113
+ readonly lookup: LookupAllAddresses;
114
+ }
115
+
116
+ /**
117
+ * Creates {@link PublicFetch.openPublicFetch} and
118
+ * {@link PublicFetch.withPublicFetch} bound to the given pinner and lookup.
119
+ * This is exported only for tests; it is not exported from any entry point.
120
+ * @param dependencies The pinner and the default lookup function.
121
+ * @returns The functions.
122
+ */
123
+ export function createPublicFetch(
124
+ dependencies: PublicFetchDependencies,
125
+ ): PublicFetch {
126
+ async function openPublicFetch(
127
+ url: URL | string,
128
+ options: PublicFetchOptions = {},
129
+ ): Promise<PublicFetchSession> {
130
+ const { url: parsed, hostname, family } = parsePublicUrl(url);
131
+ if (parsed.username !== "" || parsed.password !== "") {
132
+ throw new UrlError("URLs with credentials are not allowed");
133
+ }
134
+ const lookup = options.lookup ?? dependencies.lookup;
135
+ let addresses: readonly ResolvedAddress[];
136
+ if (family !== 0) {
137
+ addresses = [{ address: hostname, family }];
138
+ } else if (lookup === lookupAllAddresses && !await canResolve()) {
139
+ // Without the `net` permission on Deno, the hostname cannot be resolved,
140
+ // but fetch() cannot connect to it either:
141
+ return createSession(parsed, globalFetch);
142
+ } else {
143
+ options.signal?.throwIfAborted();
144
+ addresses = await lookupPublicAddresses(hostname, lookup);
145
+ options.signal?.throwIfAborted();
146
+ }
147
+ const pinner = await dependencies.pinner();
148
+ if (pinner == null || !pinner.supports(parsed)) {
149
+ logUnpinned(parsed);
150
+ return createSession(parsed, globalFetch);
151
+ }
152
+ const grant = issuePinGrant(parsed, addresses);
153
+ return createSession(
154
+ parsed,
155
+ (input, init) =>
156
+ pinner.fetch(grant, new Request(input, init), init.signal ?? undefined),
157
+ { grant, pinner },
158
+ );
159
+ }
160
+
161
+ async function withPublicFetch<T>(
162
+ url: URL | string,
163
+ fn: (fetch: PublicFetchSession["fetch"]) => Promise<T>,
164
+ options?: PublicFetchOptions,
165
+ ): Promise<T> {
166
+ const session = await openPublicFetch(url, options);
167
+ try {
168
+ const result = await fn(session.fetch);
169
+ if (result instanceof Response) {
170
+ throw new TypeError(
171
+ "withPublicFetch() callbacks must not return a Response, since " +
172
+ "its body is cancelled when the session is closed.",
173
+ );
174
+ }
175
+ return result;
176
+ } catch (error) {
177
+ logger.debug(
178
+ "Failed while fetching {url}; closing the session: {error}",
179
+ { url: session.url.href, error },
180
+ );
181
+ throw error;
182
+ } finally {
183
+ await session.close();
184
+ }
185
+ }
186
+
187
+ return { openPublicFetch, withPublicFetch };
188
+ }
189
+
190
+ const runtimePublicFetch: PublicFetch = createPublicFetch({
191
+ pinner: getRuntimePinner,
192
+ lookup: lookupAllAddresses,
193
+ });
194
+
195
+ /**
196
+ * Validates the URL, resolves its hostname, checks that every resolved
197
+ * address is public, and opens a session that connects only to those
198
+ * addresses. Connections are pinned on Node.js and Bun. On other runtimes,
199
+ * the session uses the global `fetch()` after the check.
200
+ * @param url The URL to open a session for.
201
+ * @param options The options.
202
+ * @returns The session. It must be closed.
203
+ * @throws {UrlError} If the URL is not allowed, or its hostname cannot be
204
+ * resolved (with `reason: "dns"`).
205
+ */
206
+ export const openPublicFetch: PublicFetch["openPublicFetch"] =
207
+ runtimePublicFetch.openPublicFetch;
208
+
209
+ /**
210
+ * Opens a session with {@link openPublicFetch}, runs the given function with
211
+ * its `fetch()`, and closes the session afterwards.
212
+ * @param url The URL to open a session for.
213
+ * @param fn The function to run. It must not return a `Response`, since
214
+ * the response bodies are cancelled when the session is closed.
215
+ * @param options The options.
216
+ * @returns The value returned by `fn`.
217
+ * @throws {UrlError} If the URL is not allowed.
218
+ * @throws {TypeError} If `fn` returns a `Response`.
219
+ */
220
+ export const withPublicFetch: PublicFetch["withPublicFetch"] =
221
+ runtimePublicFetch.withPublicFetch;
222
+
223
+ /**
224
+ * Opens a session that neither validates nor pins addresses, for callers that
225
+ * allow private addresses. It has the same rules as {@link openPublicFetch}
226
+ * sessions otherwise: requests are limited to the URL's origin, redirects are
227
+ * never followed, and unconsumed response bodies are cancelled on close.
228
+ * It grants nothing beyond calling the global `fetch()` directly.
229
+ * @param url The URL to open a session for.
230
+ * @returns The session. It must be closed.
231
+ */
232
+ export function openDirectFetch(
233
+ url: URL | string,
234
+ ): Promise<PublicFetchSession> {
235
+ return Promise.resolve(createSession(new URL(url), globalFetch));
236
+ }
237
+
238
+ /**
239
+ * Starts cancelling the body of a response without waiting for it to settle.
240
+ * The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
241
+ * settles only after the other branch is cancelled too, per the Streams
242
+ * standard, so awaiting it could block forever. Failures are only logged.
243
+ */
244
+ function cancelResponseBody(response: Response, url: URL): void {
245
+ response.body?.cancel().catch((error) => {
246
+ logger.debug(
247
+ "Failed to cancel a response body from {url}: {error}",
248
+ { url: url.href, error },
249
+ );
250
+ });
251
+ }
252
+
253
+ function globalFetch(
254
+ input: Request | URL | string,
255
+ init: RequestInit,
256
+ ): Promise<Response> {
257
+ return fetch(input, init);
258
+ }
259
+
260
+ function createSession(
261
+ url: URL,
262
+ send: (input: Request | URL | string, init: RequestInit) => Promise<Response>,
263
+ pin?: { readonly grant: PinGrant; readonly pinner: ConnectionPinner },
264
+ ): PublicFetchSession {
265
+ const origin = url.origin;
266
+ const tracked: Response[] = [];
267
+ let closed = false;
268
+
269
+ async function sessionFetch(
270
+ input: Request | URL | string,
271
+ init?: RequestInit,
272
+ ): Promise<Response> {
273
+ if (closed) throw new TypeError("The fetch session is closed.");
274
+ const target = new URL(input instanceof Request ? input.url : input);
275
+ if (target.origin !== origin) {
276
+ throw new TypeError(
277
+ `A fetch session for ${origin} cannot fetch ${target.origin}.`,
278
+ );
279
+ }
280
+ // Override the default redirect mode ("follow") of Request objects too;
281
+ // Bun also needs the option on fetch() itself:
282
+ // https://github.com/oven-sh/bun/issues/10754
283
+ const response = await send(input, { ...init, redirect: "manual" });
284
+ if (closed) {
285
+ cancelResponseBody(response, url);
286
+ throw new TypeError("The fetch session is closed.");
287
+ }
288
+ tracked.push(response);
289
+ return response;
290
+ }
291
+
292
+ async function close(): Promise<void> {
293
+ if (closed) return;
294
+ closed = true;
295
+ for (const response of tracked) {
296
+ if (response.body == null || response.bodyUsed) continue;
297
+ if (response.body.locked) {
298
+ logger.debug(
299
+ "Cannot cancel a locked response body from {url}.",
300
+ { url: url.href },
301
+ );
302
+ continue;
303
+ }
304
+ cancelResponseBody(response, url);
305
+ }
306
+ if (pin == null) return;
307
+ revokePinGrant(pin.grant);
308
+ await pin.pinner.release(pin.grant);
309
+ }
310
+
311
+ return {
312
+ get url() {
313
+ return new URL(url);
314
+ },
315
+ fetch: sessionFetch,
316
+ close,
317
+ };
318
+ }
319
+
320
+ async function canResolve(): Promise<boolean> {
321
+ if (!("Deno" in globalThis)) return true;
322
+ const permission = await Deno.permissions.query({ name: "net" });
323
+ return permission.state === "granted";
324
+ }
325
+
326
+ const loggedUnpinned = new Set<string>();
327
+
328
+ function logUnpinned(url: URL): void {
329
+ const runtime = detectRuntime();
330
+ const key = `${runtime} ${url.protocol}`;
331
+ if (loggedUnpinned.has(key)) return;
332
+ loggedUnpinned.add(key);
333
+ logger.debug(
334
+ "Outbound {protocol} connections are not pinned to validated addresses " +
335
+ "on {runtime}; the hostname is resolved again when connecting.",
336
+ { runtime, protocol: url.protocol },
337
+ );
338
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A worker for the Deno test of `openPublicFetch()` in
3
+ * *../public-fetch.test.ts*. The debug log about unpinned connections is
4
+ * emitted once per runtime and protocol in a module instance, and other tests
5
+ * in the same process may already have emitted it for Deno, so the test runs
6
+ * this module in a worker, which has its own module instances.
7
+ *
8
+ * It opens three sessions through the global `fetch()`, which it replaces
9
+ * with a recording function, for `https://deno-host.invalid/1`, `/2`, and
10
+ * `http://deno-host.invalid/3`, and posts the results as a message.
11
+ * @module
12
+ */
13
+ import { configure, type LogRecord } from "@logtape/logtape";
14
+ import { openPublicFetch } from "../public-fetch.ts";
15
+
16
+ const records: LogRecord[] = [];
17
+ await configure({
18
+ sinks: { buffer: (record) => records.push(record) },
19
+ loggers: [
20
+ {
21
+ category: ["fedify", "runtime", "fetch"],
22
+ sinks: ["buffer"],
23
+ lowestLevel: "debug",
24
+ },
25
+ { category: ["logtape", "meta"], sinks: [] },
26
+ ],
27
+ });
28
+
29
+ const calls: [string, string | undefined][] = [];
30
+ globalThis.fetch = (input, init) => {
31
+ const url = input instanceof Request ? input.url : String(input);
32
+ calls.push([url, init?.redirect]);
33
+ return Promise.resolve(new Response("ok"));
34
+ };
35
+
36
+ const hostnames: string[] = [];
37
+ const lookup = (hostname: string) => {
38
+ hostnames.push(hostname);
39
+ return Promise.resolve([{ address: "93.184.215.14", family: 4 }]);
40
+ };
41
+
42
+ const bodies: string[] = [];
43
+ for (
44
+ const url of [
45
+ "https://deno-host.invalid/1",
46
+ "https://deno-host.invalid/2",
47
+ "http://deno-host.invalid/3",
48
+ ]
49
+ ) {
50
+ const session = await openPublicFetch(url, { lookup });
51
+ try {
52
+ const response = await session.fetch(url, { redirect: "follow" });
53
+ bodies.push(await response.text());
54
+ } finally {
55
+ await session.close();
56
+ }
57
+ }
58
+
59
+ (globalThis as unknown as { postMessage(message: unknown): void })
60
+ .postMessage({
61
+ calls,
62
+ hostnames,
63
+ bodies,
64
+ logs: records
65
+ .filter((record) =>
66
+ typeof record.rawMessage === "string" &&
67
+ record.rawMessage.startsWith("Outbound {protocol} connections are not")
68
+ )
69
+ .map((record) => [
70
+ record.level,
71
+ record.properties.runtime,
72
+ record.properties.protocol,
73
+ ]),
74
+ });
package/src/url.test.ts CHANGED
@@ -1216,6 +1216,17 @@ test("validatePublicUrl()", async () => {
1216
1216
  await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
1217
1217
  });
1218
1218
 
1219
+ test("validatePublicUrl() keeps rejecting fedify-test.internal on Bun", {
1220
+ skip: !("Bun" in globalThis),
1221
+ }, async () => {
1222
+ // openPublicFetch() has no hostname exceptions, but validatePublicUrl()
1223
+ // keeps its Bun-specific ones for compatibility:
1224
+ await rejects(
1225
+ () => validatePublicUrl("https://fedify-test.internal/"),
1226
+ (error) => error instanceof UrlError && error.reason === "disallowed",
1227
+ );
1228
+ });
1229
+
1219
1230
  test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
1220
1231
  validateLookupAddresses([
1221
1232
  { address: "app-host.example.net.", family: 4 },
package/src/url.ts CHANGED
@@ -1,10 +1,21 @@
1
- import type { LookupAddress } from "node:dns";
2
1
  // FIXME: the default dns import exists since tests can stub `dns.lookup()`.
3
2
  // This should be replaced by injecting the lookup function such as an internal
4
3
  // option on `validatePublicUrl()`
5
4
  import dns from "node:dns/promises";
6
5
  import { isIP } from "node:net";
7
6
 
7
+ /**
8
+ * An address returned by a DNS lookup. It has the same shape as
9
+ * `ResolvedAddress` of `node:dns`, without depending on its type declarations.
10
+ * @internal
11
+ */
12
+ export interface ResolvedAddress {
13
+ /** An IPv4 or IPv6 address. */
14
+ readonly address: string;
15
+ /** The IP family of the address: `4` or `6`. */
16
+ readonly family: number;
17
+ }
18
+
8
19
  export class UrlError extends Error {
9
20
  /**
10
21
  * The failure category. Defaults to `"disallowed"`.
@@ -993,25 +1004,16 @@ function replaceGatewayHints(
993
1004
 
994
1005
  /**
995
1006
  * Validates a URL to prevent SSRF attacks.
1007
+ *
1008
+ * This function only checks the addresses that the hostname currently
1009
+ * resolves to; it does not pin the connection to those addresses. A request
1010
+ * made after this check resolves the hostname again, so this function alone
1011
+ * does not prevent DNS rebinding.
996
1012
  */
997
1013
  export async function validatePublicUrl(url: string): Promise<void> {
998
- const parsed = new URL(url);
999
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1000
- throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
1001
- }
1002
- let hostname = parsed.hostname;
1003
- if (hostname.startsWith("[") && hostname.endsWith("]")) {
1004
- hostname = hostname.slice(1, -1);
1005
- }
1006
- if (hostname === "localhost") {
1007
- throw new UrlError("Localhost is not allowed");
1008
- }
1009
- const hostnameFamily = isIP(hostname);
1010
- if (hostnameFamily !== 0) {
1011
- validatePublicIpAddress(hostname, hostnameFamily);
1012
- return;
1013
- }
1014
- if ("Deno" in globalThis && !isIP(hostname)) {
1014
+ const { hostname, family } = parsePublicUrl(url);
1015
+ if (family !== 0) return;
1016
+ if ("Deno" in globalThis) {
1015
1017
  // If the `net` permission is not granted, we can't resolve the hostname.
1016
1018
  // However, we can safely assume that it cannot gain access to private
1017
1019
  // resources.
@@ -1020,7 +1022,8 @@ export async function validatePublicUrl(url: string): Promise<void> {
1020
1022
  }
1021
1023
  // FIXME: This is a temporary workaround for the `Bun` runtime; for unknown
1022
1024
  // reasons, the Web Crypto API does not work as expected after a DNS lookup.
1023
- // This workaround purposes to prevent unit tests from hanging up:
1025
+ // This workaround purposes to prevent unit tests from hanging up.
1026
+ // It only applies to this function; openPublicFetch() never skips lookups:
1024
1027
  if ("Bun" in globalThis) {
1025
1028
  if (hostname === "example.com" || hostname.endsWith(".example.com")) {
1026
1029
  return;
@@ -1030,13 +1033,101 @@ export async function validatePublicUrl(url: string): Promise<void> {
1030
1033
  }
1031
1034
  // To prevent SSRF via DNS rebinding, we need to resolve all IP addresses
1032
1035
  // and ensure that they are all public:
1033
- let addresses: LookupAddress[];
1036
+ await lookupPublicAddresses(hostname, lookupAllAddresses);
1037
+ }
1038
+
1039
+ /**
1040
+ * A function that resolves all addresses of a hostname, like
1041
+ * `node:dns.lookup()` with `{ all: true }`.
1042
+ * @internal
1043
+ */
1044
+ export type LookupAllAddresses = (
1045
+ hostname: string,
1046
+ ) => Promise<readonly ResolvedAddress[]>;
1047
+
1048
+ /**
1049
+ * Resolves all addresses of the given hostname through `node:dns/promises`.
1050
+ * The default `dns` object is looked up on every call so that tests can still
1051
+ * stub `dns.lookup()`.
1052
+ * @internal
1053
+ */
1054
+ export function lookupAllAddresses(
1055
+ hostname: string,
1056
+ ): Promise<readonly ResolvedAddress[]> {
1057
+ return dns.lookup(hostname, { all: true });
1058
+ }
1059
+
1060
+ /**
1061
+ * The result of {@link parsePublicUrl}.
1062
+ * @internal
1063
+ */
1064
+ export interface ParsedPublicUrl {
1065
+ readonly url: URL;
1066
+ /** The hostname without brackets around IPv6 literals. */
1067
+ readonly hostname: string;
1068
+ /** The IP family of the hostname, or `0` if it is not an IP literal. */
1069
+ readonly family: number;
1070
+ }
1071
+
1072
+ /**
1073
+ * Checks the parts of a URL that can be validated without resolving it:
1074
+ * the protocol, the `localhost` hostname, and IP literal hostnames.
1075
+ * @throws {UrlError} If the URL is not allowed.
1076
+ * @internal
1077
+ */
1078
+ export function parsePublicUrl(url: string | URL): ParsedPublicUrl {
1079
+ const parsed = new URL(url);
1080
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1081
+ throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
1082
+ }
1083
+ let hostname = parsed.hostname;
1084
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
1085
+ hostname = hostname.slice(1, -1);
1086
+ }
1087
+ if (hostname === "localhost") {
1088
+ throw new UrlError("Localhost is not allowed");
1089
+ }
1090
+ const family = isIP(hostname);
1091
+ if (family !== 0) validatePublicIpAddress(hostname, family);
1092
+ return { url: parsed, hostname, family };
1093
+ }
1094
+
1095
+ /**
1096
+ * Resolves the given hostname and checks that every returned IP address is
1097
+ * public.
1098
+ * @returns The IP addresses of the hostname, without non-IP entries.
1099
+ * @throws {UrlError} If the lookup fails, returns no IP address, or returns
1100
+ * any non-public address.
1101
+ * @internal
1102
+ */
1103
+ export async function lookupPublicAddresses(
1104
+ hostname: string,
1105
+ lookup: LookupAllAddresses,
1106
+ ): Promise<ResolvedAddress[]> {
1107
+ let addresses: readonly ResolvedAddress[];
1034
1108
  try {
1035
- addresses = await dns.lookup(hostname, { all: true });
1109
+ addresses = await lookup(hostname);
1036
1110
  } catch (error) {
1037
1111
  throw new UrlError("DNS lookup failed", { cause: error, reason: "dns" });
1038
1112
  }
1039
1113
  validateLookupAddresses(addresses);
1114
+ return addresses
1115
+ .filter(({ address }) => isIP(address) !== 0)
1116
+ .map(({ address }) => ({ address, family: isIP(address) }));
1117
+ }
1118
+
1119
+ /**
1120
+ * Checks that the given IP address is public. This is used right before
1121
+ * connecting to an address that passed {@link lookupPublicAddresses}.
1122
+ * @throws {UrlError} If the address is not a public IP address.
1123
+ * @internal
1124
+ */
1125
+ export function assertPublicAddress(address: ResolvedAddress): void {
1126
+ const family = isIP(address.address);
1127
+ if (family === 0 || family !== address.family) {
1128
+ throw new UrlError(`Invalid or private address: ${address.address}`);
1129
+ }
1130
+ validatePublicIpAddress(address.address, family);
1040
1131
  }
1041
1132
 
1042
1133
  /**
@@ -1057,7 +1148,7 @@ export async function validatePublicUrl(url: string): Promise<void> {
1057
1148
  * @internal
1058
1149
  */
1059
1150
  export function validateLookupAddresses(
1060
- addresses: readonly LookupAddress[],
1151
+ addresses: readonly ResolvedAddress[],
1061
1152
  ): void {
1062
1153
  let ipAddressCount = 0;
1063
1154
  for (const { address } of addresses) {
package/tsdown.config.ts CHANGED
@@ -6,6 +6,7 @@ export default [
6
6
  defineConfig({
7
7
  entry: [
8
8
  "src/mod.ts",
9
+ "src/internal.ts",
9
10
  "src/internal/jsonld-cache.ts",
10
11
  "src/internal/portable-dereference.ts",
11
12
  "src/internal/signed-representation.ts",
@@ -22,8 +23,12 @@ export default [
22
23
  }),
23
24
  defineConfig({
24
25
  outDir: "dist/tests",
25
- entry: (await Array.fromAsync(glob(`src/**/*.test.ts`)))
26
- .map((f) => f.replace(sep, "/")),
26
+ entry: [
27
+ ...await Array.fromAsync(glob(`src/**/*.test.ts`)),
28
+ // Run by the Node.js pinner tests in a child process, since Node.js
29
+ // cannot run TypeScript sources without flags before 22.6.0:
30
+ "src/pinners/testdata/node-child.ts",
31
+ ].map((f) => f.replace(sep, "/")),
27
32
  format: ["esm", "cjs"],
28
33
  platform: "node",
29
34
  deps: { neverBundle: [/^node:/, "@fedify/fixture"] },