@fedify/vocab-runtime 2.5.0-dev.2448 → 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 (120) 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 +1 -1
  9. package/dist/internal/portable-dereference.d.cts +2 -2
  10. package/dist/internal/portable-dereference.d.ts +2 -2
  11. package/dist/internal/portable-dereference.js +1 -1
  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-DP2u2wUX.cjs → body-C8d1PmDa.cjs} +1 -1
  25. package/dist/tests/{body-oV99TVdA.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-CwxZguqN.cjs → docloader-D1fNCGam.cjs} +111 -39
  31. package/dist/tests/{docloader-D3mA7aPJ.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-D7ClSjFO.cjs → portable-media-BM9zgj9g.cjs} +3 -3
  71. package/dist/tests/{portable-media-B-oCeotd.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-B77_oZnk.cjs → request-7q-Zfe1p.cjs} +2 -2
  83. package/dist/tests/{request-BlLYI4x3.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.ts +13 -0
  100. package/src/pin-grant.test.ts +118 -0
  101. package/src/pin-grant.ts +99 -0
  102. package/src/pinners/bun.test.ts +278 -0
  103. package/src/pinners/bun.ts +223 -0
  104. package/src/pinners/mod.ts +59 -0
  105. package/src/pinners/node.test.ts +415 -0
  106. package/src/pinners/node.ts +144 -0
  107. package/src/pinners/pinners.test.ts +316 -0
  108. package/src/pinners/testdata/node-child.ts +147 -0
  109. package/src/pinners/testdata/proxy-child.ts +82 -0
  110. package/src/pinners/testdata/support.ts +343 -0
  111. package/src/pinners/testdata/tls-cert.pem +12 -0
  112. package/src/pinners/testdata/tls-child.ts +88 -0
  113. package/src/pinners/testdata/tls-key.pem +5 -0
  114. package/src/pinners/types.ts +48 -0
  115. package/src/public-fetch.test.ts +1286 -0
  116. package/src/public-fetch.ts +338 -0
  117. package/src/testdata/public-fetch-deno-worker.ts +74 -0
  118. package/src/url.test.ts +11 -0
  119. package/src/url.ts +113 -22
  120. package/tsdown.config.ts +7 -2
@@ -0,0 +1,415 @@
1
+ import { test } from "@fedify/fixture";
2
+ import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert";
3
+ import { readFileSync } from "node:fs";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { createServer as createHttpsServer } from "node:https";
6
+ import process from "node:process";
7
+ import type * as Undici from "undici";
8
+ import { issuePinGrant, type PinGrant, revokePinGrant } from "../pin-grant.ts";
9
+ import { createPublicFetch } from "../public-fetch.ts";
10
+ import { assertPublicAddress, type ResolvedAddress, UrlError } from "../url.ts";
11
+ import { createNodePinner } from "./node.ts";
12
+ import {
13
+ allowAll,
14
+ closeServer,
15
+ getLoopbackSupport,
16
+ ipv4,
17
+ ipv6,
18
+ isNode,
19
+ listen,
20
+ resolveCompiledTestdata,
21
+ resolveTestdata,
22
+ runChild,
23
+ startCountingServer,
24
+ startRecordingProxy,
25
+ startRecordingServer,
26
+ } from "./testdata/support.ts";
27
+ import type { ConnectionPinner, ConnectionPinnerOptions } from "./types.ts";
28
+
29
+ // The behaviors that the Node.js pinner shares with the other pinners are
30
+ // tested in pinners.test.ts.
31
+
32
+ /**
33
+ * Creates a Node.js pinner whose undici agent the test can also use
34
+ * directly, by injecting an `Agent` class that remembers its instance.
35
+ */
36
+ async function createPinnerWithAgent(options: ConnectionPinnerOptions) {
37
+ const undici = await import("undici");
38
+ const agents: Undici.Agent[] = [];
39
+ class Agent extends undici.Agent {
40
+ constructor(options?: Undici.Agent.Options) {
41
+ super(options);
42
+ agents.push(this);
43
+ }
44
+ }
45
+ const pinner = createNodePinner({ ...options, undici: { Agent } });
46
+ strictEqual(agents.length, 1);
47
+ return { pinner, agent: agents[0] };
48
+ }
49
+
50
+ /**
51
+ * Sends a request through the agent directly, without redeeming a grant.
52
+ */
53
+ function fetchThrough(
54
+ agent: Undici.Agent,
55
+ url: URL | string,
56
+ ): Promise<Response> {
57
+ return fetch(url, { dispatcher: agent } as RequestInit);
58
+ }
59
+
60
+ /**
61
+ * Whether the error is the failure of the agent's lookup to find a live
62
+ * grant for the hostname.
63
+ */
64
+ function isNoValidatedAddress(error: unknown): boolean {
65
+ return error instanceof TypeError && error.cause instanceof UrlError &&
66
+ error.cause.message.startsWith("No validated address to connect to:");
67
+ }
68
+
69
+ /** Responds without keeping the connection alive. */
70
+ function respondAndClose(_: IncomingMessage, res: ServerResponse): void {
71
+ res.setHeader("Connection", "close");
72
+ res.end("pinned");
73
+ }
74
+
75
+ test("createNodePinner() checks IP literal hostnames before fetching", {
76
+ ignore: !isNode,
77
+ }, async () => {
78
+ const { server, port, requests } = await startRecordingServer();
79
+ try {
80
+ // Connections to IP addresses do not go through the agent's lookup:
81
+ const pinner = createNodePinner({
82
+ verifyAddress: assertPublicAddress,
83
+ undici: await import("undici"),
84
+ });
85
+ const url = new URL(`http://127.0.0.1:${port}/`);
86
+ const grant = issuePinGrant(url, [ipv4("127.0.0.1")]);
87
+ await rejects(() => pinner.fetch(grant, new Request(url)), UrlError);
88
+ deepStrictEqual(requests, []);
89
+ } finally {
90
+ await closeServer(server);
91
+ }
92
+ });
93
+
94
+ test("createNodePinner() connects only for live grants", {
95
+ ignore: !isNode,
96
+ }, async () => {
97
+ // Connections are not kept alive, so that every request through the agent
98
+ // needs a lookup:
99
+ const { server, port, requests } = await startRecordingServer(
100
+ "127.0.0.1",
101
+ respondAndClose,
102
+ );
103
+ try {
104
+ const { pinner, agent } = await createPinnerWithAgent(allowAll);
105
+ const url = new URL(`http://pinned-host.invalid:${port}/`);
106
+ // No grant has been used yet:
107
+ await rejects(() => fetchThrough(agent, url), isNoValidatedAddress);
108
+ const grant = issuePinGrant(url, [ipv4("127.0.0.1")]);
109
+ const response = await pinner.fetch(grant, new Request(url));
110
+ deepStrictEqual(await response.text(), "pinned");
111
+ // While the grant is live, the agent connects to its addresses, but only
112
+ // for its hostname:
113
+ deepStrictEqual(await (await fetchThrough(agent, url)).text(), "pinned");
114
+ await rejects(
115
+ () => fetchThrough(agent, `http://other-host.invalid:${port}/`),
116
+ isNoValidatedAddress,
117
+ );
118
+ // A revoked grant no longer counts, even before it is released:
119
+ revokePinGrant(grant);
120
+ await rejects(() => pinner.fetch(grant, new Request(url)), TypeError);
121
+ await rejects(() => fetchThrough(agent, url), isNoValidatedAddress);
122
+ deepStrictEqual(requests.length, 2);
123
+ } finally {
124
+ await closeServer(server);
125
+ }
126
+ });
127
+
128
+ test("createNodePinner() forgets released grants", {
129
+ ignore: !isNode,
130
+ }, async () => {
131
+ const { server, port, requests } = await startRecordingServer(
132
+ "127.0.0.1",
133
+ respondAndClose,
134
+ );
135
+ try {
136
+ const { pinner, agent } = await createPinnerWithAgent(allowAll);
137
+ const url = new URL(`http://pinned-host.invalid:${port}/`);
138
+ const grants = [
139
+ issuePinGrant(url, [ipv4("127.0.0.1")]),
140
+ issuePinGrant(url, [ipv4("127.0.0.1")]),
141
+ ];
142
+ for (const grant of grants) {
143
+ deepStrictEqual(
144
+ await (await pinner.fetch(grant, new Request(url))).text(),
145
+ "pinned",
146
+ );
147
+ }
148
+ // Without revoking them, so that only the release removes them:
149
+ await pinner.release(grants[0]);
150
+ deepStrictEqual(await (await fetchThrough(agent, url)).text(), "pinned");
151
+ await pinner.release(grants[1]);
152
+ await rejects(() => fetchThrough(agent, url), isNoValidatedAddress);
153
+ // Releasing again has no effect:
154
+ await pinner.release(grants[1]);
155
+ deepStrictEqual(requests.length, 3);
156
+ } finally {
157
+ await closeServer(server);
158
+ }
159
+ });
160
+
161
+ test("createNodePinner() connects to the next address on failure", {
162
+ ignore: !isNode,
163
+ }, async () => {
164
+ // Skipped where IPv6 is unavailable:
165
+ if (!(await getLoopbackSupport()).ipv6) return;
166
+ // The server listens only on 127.0.0.1, so the first address, ::1, refuses
167
+ // the connection. Node.js tries the next address when connecting fails,
168
+ // before anything is sent, so this applies to POST requests too:
169
+ const { server, port, requests } = await startRecordingServer();
170
+ try {
171
+ const pinner = createNodePinner({
172
+ ...allowAll,
173
+ undici: await import("undici"),
174
+ });
175
+ const url = new URL(`http://pinned-host.invalid:${port}/`);
176
+ const grant = issuePinGrant(url, [ipv6("::1"), ipv4("127.0.0.1")]);
177
+ const response = await pinner.fetch(grant, new Request(url));
178
+ deepStrictEqual(await response.text(), "pinned");
179
+ const post = await pinner.fetch(
180
+ grant,
181
+ new Request(url, { method: "POST", body: "payload" }),
182
+ );
183
+ deepStrictEqual(await post.text(), "pinned");
184
+ deepStrictEqual(
185
+ requests.map(({ method, localAddress }) => [method, localAddress]),
186
+ [["GET", "127.0.0.1"], ["POST", "127.0.0.1"]],
187
+ );
188
+ } finally {
189
+ await closeServer(server);
190
+ }
191
+ });
192
+
193
+ /**
194
+ * Replaces the global `fetch()` while running the given function, as
195
+ * fetch-mock does, but returns the given responses as they are, while
196
+ * fetch-mock returns clones (tees) of them.
197
+ */
198
+ async function withFetchStub<T>(
199
+ respond: (init: unknown) => Response,
200
+ fn: () => Promise<T>,
201
+ ): Promise<T> {
202
+ const original = globalThis.fetch;
203
+ // The options are unknown, since their type differs among runtimes (e.g.,
204
+ // Cloudflare Workers types make RequestInit generic):
205
+ globalThis.fetch = (_: unknown, init?: unknown) =>
206
+ Promise.resolve(respond(init));
207
+ try {
208
+ return await fn();
209
+ } finally {
210
+ globalThis.fetch = original;
211
+ }
212
+ }
213
+
214
+ test("createNodePinner() forgets the grants of closed sessions", {
215
+ ignore: !isNode,
216
+ }, async (t) => {
217
+ for (const cancelFails of [false, true]) {
218
+ await t.step(`cancelFails: ${cancelFails}`, async () => {
219
+ // The session's address is public as openPublicFetch() requires, so
220
+ // every address is refused when connecting; whether the agent's lookup
221
+ // reaches the check shows whether the session's grant is live:
222
+ const checked: string[] = [];
223
+ const { pinner, agent } = await createPinnerWithAgent({
224
+ verifyAddress({ address }) {
225
+ checked.push(address);
226
+ throw new UrlError(`Refused for the test: ${address}`);
227
+ },
228
+ });
229
+ const released: PinGrant[] = [];
230
+ const observed: ConnectionPinner = {
231
+ supports: (url) => pinner.supports(url),
232
+ fetch: (grant, request, signal) => pinner.fetch(grant, request, signal),
233
+ async release(grant) {
234
+ released.push(grant);
235
+ await pinner.release(grant);
236
+ },
237
+ };
238
+ const { openPublicFetch } = createPublicFetch({
239
+ pinner: () => Promise.resolve(observed),
240
+ lookup: () => Promise.reject(new Error("Not used.")),
241
+ });
242
+ const url = "http://pinned-host.invalid/";
243
+ const session = await openPublicFetch(url, {
244
+ lookup: (): Promise<readonly ResolvedAddress[]> =>
245
+ Promise.resolve([ipv4("8.8.8.8")]),
246
+ });
247
+ const dispatchers: unknown[] = [];
248
+ let cancelled = 0;
249
+ const response = await withFetchStub(
250
+ (init) => {
251
+ dispatchers.push((init as { dispatcher?: unknown }).dispatcher);
252
+ return new Response(
253
+ new ReadableStream({
254
+ cancel() {
255
+ cancelled++;
256
+ if (cancelFails) throw new Error("Failed to cancel.");
257
+ },
258
+ }),
259
+ );
260
+ },
261
+ () => session.fetch(url),
262
+ );
263
+ deepStrictEqual(dispatchers, [agent]);
264
+ // The session's grant is live:
265
+ await rejects(
266
+ () => fetchThrough(agent, url),
267
+ (error) =>
268
+ error instanceof TypeError && error.cause instanceof UrlError &&
269
+ error.cause.message === "Refused for the test: 8.8.8.8",
270
+ );
271
+ deepStrictEqual(checked, ["8.8.8.8"]);
272
+ ok(!response.bodyUsed);
273
+ // Failures to cancel the body are only logged:
274
+ await session.close();
275
+ deepStrictEqual(cancelled, 1);
276
+ deepStrictEqual(released.length, 1);
277
+ // The agent fails closed without checking any address:
278
+ await rejects(() => fetchThrough(agent, url), isNoValidatedAddress);
279
+ deepStrictEqual(checked, ["8.8.8.8"]);
280
+ });
281
+ }
282
+ });
283
+
284
+ interface ChildResult {
285
+ readonly status?: number;
286
+ readonly body?: string;
287
+ readonly error?: string;
288
+ }
289
+
290
+ interface ProxyChildOutput {
291
+ readonly version: string;
292
+ readonly http: ChildResult;
293
+ readonly https: ChildResult;
294
+ readonly control: ChildResult;
295
+ }
296
+
297
+ test("createNodePinner() bypasses environment proxies", {
298
+ ignore: !isNode,
299
+ }, async (t) => {
300
+ const major = Number(process.versions.node.split(".")[0]);
301
+ const names = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] as const;
302
+ // NODE_USE_ENV_PROXY=1 makes the global dispatcher read the proxy variables
303
+ // since Node.js 24 (and some later 22.x versions), but the pinner always
304
+ // passes its own dispatcher:
305
+ for (const useEnvProxy of [false, true]) {
306
+ await t.step(`NODE_USE_ENV_PROXY: ${useEnvProxy}`, async () => {
307
+ const proxy = await startRecordingProxy({ refuseTunnels: true });
308
+ // The HTTP origin server:
309
+ const origin = await startRecordingServer();
310
+ // The HTTPS target, which only counts connections since it has no
311
+ // certificate:
312
+ const tls = await startCountingServer();
313
+ try {
314
+ const proxyUrl = `http://127.0.0.1:${proxy.port}`;
315
+ const output = await runChild<ProxyChildOutput>(
316
+ resolveCompiledTestdata("node-child.mjs", import.meta.url),
317
+ ["proxy", String(origin.port), String(tls.port)],
318
+ {
319
+ ...Object.fromEntries(names.map((name) => [name, proxyUrl])),
320
+ ...(useEnvProxy ? { NODE_USE_ENV_PROXY: "1" } : {}),
321
+ },
322
+ );
323
+ const targets = JSON.stringify(proxy.targets);
324
+ deepStrictEqual(output.version, process.version);
325
+ // The control request is sent either in absolute form or through
326
+ // a tunnel, depending on the Node.js version:
327
+ const controlTargets = [
328
+ `GET http://127.0.0.1:${origin.port}/control`,
329
+ `CONNECT 127.0.0.1:${origin.port}`,
330
+ ];
331
+ if (useEnvProxy && major >= 24) {
332
+ ok(
333
+ proxy.targets.some((target) => controlTargets.includes(target)),
334
+ targets,
335
+ );
336
+ }
337
+ // Pinned requests never reach the proxy, and connect directly to
338
+ // the pinned address:
339
+ deepStrictEqual(
340
+ proxy.targets.filter((target) => !controlTargets.includes(target)),
341
+ [],
342
+ targets,
343
+ );
344
+ deepStrictEqual(output.http, { status: 200, body: "pinned" });
345
+ deepStrictEqual(
346
+ origin.requests
347
+ .filter(({ url }) => url === "/pinned")
348
+ .map(({ host }) => host),
349
+ [`pinned-host.invalid:${origin.port}`],
350
+ );
351
+ // The HTTPS target has no certificate:
352
+ ok(output.https.error != null, JSON.stringify(output.https));
353
+ deepStrictEqual(tls.counter.connections, 1);
354
+ } finally {
355
+ await closeServer(proxy.server);
356
+ await closeServer(origin.server);
357
+ await closeServer(tls.server);
358
+ }
359
+ });
360
+ }
361
+ });
362
+
363
+ type TlsChildResult =
364
+ | { readonly case: string; readonly status: number; readonly body: string }
365
+ | { readonly case: string; readonly error: string };
366
+
367
+ interface TlsChildOutput {
368
+ readonly version: string;
369
+ readonly results: readonly TlsChildResult[];
370
+ }
371
+
372
+ test("createNodePinner() verifies certificates against the hostname", {
373
+ ignore: !isNode,
374
+ }, async () => {
375
+ const certPath = resolveTestdata("tls-cert.pem", import.meta.url);
376
+ const hosts: string[] = [];
377
+ const server = createHttpsServer({
378
+ cert: readFileSync(certPath),
379
+ key: readFileSync(resolveTestdata("tls-key.pem", import.meta.url)),
380
+ }, (req, res) => {
381
+ hosts.push(req.headers.host ?? "");
382
+ res.end(req.headers.host);
383
+ });
384
+ const port = await listen(server);
385
+ try {
386
+ // tls-cert.pem is self-signed for pinned-host.invalid and
387
+ // alternate-host.invalid, and is trusted only by the child process:
388
+ const output = await runChild<TlsChildOutput>(
389
+ resolveCompiledTestdata("node-child.mjs", import.meta.url),
390
+ ["tls", String(port)],
391
+ { NODE_EXTRA_CA_CERTS: certPath },
392
+ );
393
+ deepStrictEqual(output.version, process.version);
394
+ deepStrictEqual(output.results, [
395
+ { case: "pinned", status: 200, body: `pinned-host.invalid:${port}` },
396
+ { case: "unlisted", error: "ERR_TLS_CERT_ALTNAME_INVALID" },
397
+ { case: "unlisted-again", error: "ERR_TLS_CERT_ALTNAME_INVALID" },
398
+ {
399
+ case: "alternate",
400
+ status: 200,
401
+ body: `alternate-host.invalid:${port}`,
402
+ },
403
+ { case: "signals", status: 200, body: `pinned-host.invalid:${port}` },
404
+ ], JSON.stringify(output.results));
405
+ // No request reached the server for the hostname the certificate does
406
+ // not cover:
407
+ deepStrictEqual(hosts, [
408
+ `pinned-host.invalid:${port}`,
409
+ `alternate-host.invalid:${port}`,
410
+ `pinned-host.invalid:${port}`,
411
+ ]);
412
+ } finally {
413
+ await closeServer(server);
414
+ }
415
+ });
@@ -0,0 +1,144 @@
1
+ import { isIP } from "node:net";
2
+ import type * as Undici from "undici";
3
+ import {
4
+ type PinGrant,
5
+ type PinGrantRecord,
6
+ redeemPinGrant,
7
+ } from "../pin-grant.ts";
8
+ import { type ResolvedAddress, UrlError } from "../url.ts";
9
+ import type { ConnectionPinner, ConnectionPinnerOptions } from "./types.ts";
10
+
11
+ /**
12
+ * Options for {@link createNodePinner}.
13
+ */
14
+ export interface NodePinnerOptions extends ConnectionPinnerOptions {
15
+ /**
16
+ * The npm `undici` module, whose `Agent` is passed to the global `fetch()`
17
+ * as the `dispatcher`.
18
+ */
19
+ readonly undici: Pick<typeof Undici, "Agent">;
20
+ }
21
+
22
+ type LookupCallback = (
23
+ error: Error | null,
24
+ address: string | ResolvedAddress[],
25
+ family?: number,
26
+ ) => void;
27
+
28
+ /**
29
+ * Creates a pinner for Node.js. Every request is sent through the global
30
+ * `fetch()` with an undici `Agent` as the `dispatcher`. The agent's
31
+ * `connect.lookup` answers only with the addresses of live grants for
32
+ * the hostname, checking each of them again right before connecting, and
33
+ * fails when there are none, so a connection can never be made to an address
34
+ * that was not validated.
35
+ *
36
+ * The agent is shared by the requests of all grants, so keep-alive
37
+ * connections are reused. A pooled connection was made to an address of
38
+ * a grant that was valid when it was connected, so reusing it from another
39
+ * session of the same origin still reaches a validated address. Response
40
+ * bodies have to be consumed or cancelled for their connections to return to
41
+ * the pool, which sessions do when they are closed.
42
+ *
43
+ * Since the dispatcher is passed explicitly, neither proxy environment
44
+ * variables nor the global dispatcher (e.g., `NODE_USE_ENV_PROXY=1`) apply.
45
+ * @param options The pinner options.
46
+ * @returns The pinner.
47
+ */
48
+ export function createNodePinner(
49
+ { verifyAddress, undici }: NodePinnerOptions,
50
+ ): ConnectionPinner {
51
+ const grants = new Map<string, Map<PinGrant, PinGrantRecord>>();
52
+
53
+ function lookup(
54
+ hostname: string,
55
+ options: { all?: boolean; family?: number | string },
56
+ callback: LookupCallback,
57
+ ): void {
58
+ let addresses: ResolvedAddress[];
59
+ try {
60
+ addresses = getGrantedAddresses(hostname, options.family);
61
+ } catch (error) {
62
+ callback(error as Error, []);
63
+ return;
64
+ }
65
+ if (options.all) callback(null, addresses);
66
+ else callback(null, addresses[0].address, addresses[0].family);
67
+ }
68
+
69
+ function getGrantedAddresses(
70
+ hostname: string,
71
+ family: number | string | undefined,
72
+ ): ResolvedAddress[] {
73
+ const addresses = new Map<string, ResolvedAddress>();
74
+ for (const [grant, record] of grants.get(hostname) ?? []) {
75
+ try {
76
+ redeemPinGrant(grant, new URL(record.origin));
77
+ } catch {
78
+ continue; // Revoked grants no longer count.
79
+ }
80
+ for (const address of record.addresses) {
81
+ addresses.set(address.address, address);
82
+ }
83
+ }
84
+ const familyNumber = family === "IPv4"
85
+ ? 4
86
+ : family === "IPv6"
87
+ ? 6
88
+ : typeof family === "number"
89
+ ? family
90
+ : 0;
91
+ const result = [...addresses.values()].filter((address) =>
92
+ familyNumber === 0 || address.family === familyNumber
93
+ );
94
+ if (result.length < 1) {
95
+ throw new UrlError(`No validated address to connect to: ${hostname}`);
96
+ }
97
+ // Check every address again right before connecting:
98
+ for (const address of result) verifyAddress(address);
99
+ return result;
100
+ }
101
+
102
+ const agent = new undici.Agent({ connect: { lookup } });
103
+
104
+ return {
105
+ supports(url: URL): boolean {
106
+ return url.protocol === "http:" || url.protocol === "https:";
107
+ },
108
+
109
+ async fetch(
110
+ grant: PinGrant,
111
+ request: Request,
112
+ signal?: AbortSignal,
113
+ ): Promise<Response> {
114
+ const record = redeemPinGrant(grant, new URL(request.url));
115
+ // Connections to IP literals do not go through the lookup:
116
+ if (isIP(record.hostname) !== 0) {
117
+ for (const address of record.addresses) verifyAddress(address);
118
+ }
119
+ let registered = grants.get(record.hostname);
120
+ if (registered == null) {
121
+ registered = new Map();
122
+ grants.set(record.hostname, registered);
123
+ }
124
+ registered.set(grant, record);
125
+ return await fetch(request, {
126
+ redirect: "manual",
127
+ // A signal in the options replaces the request's own signal, so both
128
+ // are combined:
129
+ signal: signal == null || signal === request.signal
130
+ ? request.signal
131
+ : AbortSignal.any([signal, request.signal]),
132
+ dispatcher: agent,
133
+ } as RequestInit);
134
+ },
135
+
136
+ release(grant: PinGrant): void {
137
+ for (const [hostname, registered] of grants) {
138
+ if (!registered.delete(grant)) continue;
139
+ if (registered.size < 1) grants.delete(hostname);
140
+ break;
141
+ }
142
+ },
143
+ };
144
+ }