@fedify/vocab-runtime 2.2.18 → 2.2.20
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/deno.json +5 -2
- package/dist/internal.cjs +6 -0
- package/dist/internal.d.cts +3 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/mod.cjs +105 -252
- package/dist/mod.d.cts +5 -28
- package/dist/mod.d.ts +6 -28
- package/dist/mod.js +99 -245
- package/dist/public-fetch-BC-nWJNM.d.ts +143 -0
- package/dist/public-fetch-CFnW7XMn.cjs +821 -0
- package/dist/public-fetch-DBpt3xxf.d.cts +143 -0
- package/dist/public-fetch-Dfmc_Kfy.js +772 -0
- package/dist/tests/{body-CbvYxP3V.mjs → body-B9_hb-GO.mjs} +1 -1
- package/dist/tests/{body-CaJBg0tr.cjs → body-pOSG2RFb.cjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +6 -6
- package/dist/tests/decimal.test.mjs +6 -6
- package/dist/tests/{docloader-jQMjHIRI.mjs → docloader-Bdlp7FkK.mjs} +103 -37
- package/dist/tests/{docloader-k2T5kUlV.cjs → docloader-Ck3VfY29.cjs} +107 -35
- package/dist/tests/docloader.test.cjs +569 -1273
- package/dist/tests/docloader.test.mjs +515 -1238
- package/dist/tests/esm-CnLt0wAF.mjs +1225 -0
- package/dist/tests/esm-CxbqfkLP.cjs +1208 -0
- package/dist/tests/internal/multicodec.test.cjs +1 -1
- package/dist/tests/internal/multicodec.test.mjs +1 -1
- package/dist/tests/{key-BDIsC6hV.mjs → key-GEdpxA4M.mjs} +2 -2
- package/dist/tests/{key-BarKGLQz.cjs → key-L6ReMuUb.cjs} +2 -2
- package/dist/tests/key.test.cjs +2 -2
- package/dist/tests/key.test.mjs +2 -2
- package/dist/tests/{langstr-BsqFJupv.cjs → langstr-CZpWmpo5.cjs} +1 -1
- package/dist/tests/{langstr-ABdAOKlF.mjs → langstr-DTB4sl0a.mjs} +1 -1
- package/dist/tests/langstr.test.cjs +1 -1
- package/dist/tests/langstr.test.mjs +1 -1
- package/dist/tests/langtag.test.cjs +1 -1
- package/dist/tests/langtag.test.mjs +1 -1
- package/dist/tests/link.test.mjs +1 -1
- package/dist/tests/multibase/multibase.test.cjs +1 -1
- package/dist/tests/multibase/multibase.test.mjs +1 -1
- package/dist/tests/node-C3bjFjL1.mjs +84 -0
- package/dist/tests/node-DWD76vPo.cjs +90 -0
- package/dist/tests/pin-grant-CU-o367k.cjs +70 -0
- package/dist/tests/pin-grant-HW3B6SKv.mjs +53 -0
- package/dist/tests/pin-grant.test.cjs +88 -0
- package/dist/tests/pin-grant.test.d.cts +1 -0
- package/dist/tests/pin-grant.test.d.mts +1 -0
- package/dist/tests/pin-grant.test.mjs +88 -0
- package/dist/tests/pinners/bun.test.cjs +167 -0
- package/dist/tests/pinners/bun.test.d.cts +1 -0
- package/dist/tests/pinners/bun.test.d.mts +1 -0
- package/dist/tests/pinners/bun.test.mjs +167 -0
- package/dist/tests/pinners/node.test.cjs +269 -0
- package/dist/tests/pinners/node.test.d.cts +1 -0
- package/dist/tests/pinners/node.test.d.mts +1 -0
- package/dist/tests/pinners/node.test.mjs +268 -0
- package/dist/tests/pinners/pinners.test.cjs +197 -0
- package/dist/tests/pinners/pinners.test.d.cts +1 -0
- package/dist/tests/pinners/pinners.test.d.mts +1 -0
- package/dist/tests/pinners/pinners.test.mjs +197 -0
- package/dist/tests/pinners/testdata/node-child.cjs +135 -0
- package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
- package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
- package/dist/tests/pinners/testdata/node-child.mjs +134 -0
- package/dist/tests/pinners-BjE-YJLL.cjs +221 -0
- package/dist/tests/pinners-ZgZDx9y6.mjs +203 -0
- package/dist/tests/public-fetch-Ddjfxbwc.cjs +187 -0
- package/dist/tests/public-fetch-Yi7Vks9s.mjs +170 -0
- package/dist/tests/public-fetch.test.cjs +1074 -0
- package/dist/tests/public-fetch.test.d.cts +1 -0
- package/dist/tests/public-fetch.test.d.mts +1 -0
- package/dist/tests/public-fetch.test.mjs +1074 -0
- package/dist/tests/{request-BngWcP0t.cjs → request-B3NUKbyk.cjs} +1 -1
- package/dist/tests/{request-BE7XIrGw.mjs → request-BSg3IQzf.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/support-D5-KVjWZ.cjs +363 -0
- package/dist/tests/support-M5ClN-no.mjs +272 -0
- package/dist/tests/{url-vZTgkIZg.cjs → url-DLuSj57U.cjs} +88 -12
- package/dist/tests/{url-3fyJuqC0.mjs → url-DQWrMGhu.mjs} +65 -13
- package/dist/tests/url.test.cjs +4 -1
- package/dist/tests/url.test.mjs +4 -1
- package/package.json +14 -3
- package/src/docloader.test.ts +784 -32
- package/src/docloader.ts +179 -67
- package/src/internal.ts +13 -0
- package/src/pin-grant.test.ts +118 -0
- package/src/pin-grant.ts +99 -0
- package/src/pinners/bun.test.ts +278 -0
- package/src/pinners/bun.ts +223 -0
- package/src/pinners/mod.ts +59 -0
- package/src/pinners/node.test.ts +415 -0
- package/src/pinners/node.ts +144 -0
- package/src/pinners/pinners.test.ts +316 -0
- package/src/pinners/testdata/node-child.ts +147 -0
- package/src/pinners/testdata/proxy-child.ts +82 -0
- package/src/pinners/testdata/support.ts +343 -0
- package/src/pinners/testdata/tls-cert.pem +12 -0
- package/src/pinners/testdata/tls-child.ts +88 -0
- package/src/pinners/testdata/tls-key.pem +5 -0
- package/src/pinners/types.ts +48 -0
- package/src/public-fetch.test.ts +1286 -0
- package/src/public-fetch.ts +338 -0
- package/src/testdata/public-fetch-deno-worker.ts +74 -0
- package/src/url.test.ts +11 -0
- package/src/url.ts +113 -22
- package/tsdown.config.ts +12 -3
- /package/dist/tests/{langtag-LDBqlxHv.mjs → langtag-D8FWxjv8.mjs} +0 -0
- /package/dist/tests/{langtag-DgySESmk.cjs → langtag-a2-s9uyi.cjs} +0 -0
- /package/dist/tests/{link-CYszIegt.mjs → link-CHMtH2-k.mjs} +0 -0
- /package/dist/tests/{multibase-8zhYPa7o.cjs → multibase-BmWaK56H.cjs} +0 -0
- /package/dist/tests/{multibase-Ds75BvXs.mjs → multibase-D642Uv3S.mjs} +0 -0
- /package/dist/tests/{multicodec-ByS9XLsw.cjs → multicodec-B-_CSt1M.cjs} +0 -0
- /package/dist/tests/{multicodec-BBPZjRfg.mjs → multicodec-caWdG98t.mjs} +0 -0
package/src/docloader.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
|
-
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
|
+
import { type Span, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
3
3
|
import metadata from "../deno.json" with { type: "json" };
|
|
4
4
|
import { MAX_BODY_SIZE, readBoundedText } from "./body.ts";
|
|
5
5
|
import preloadedContexts from "./contexts.ts";
|
|
6
6
|
import { HttpHeaderLink } from "./link.ts";
|
|
7
|
+
import {
|
|
8
|
+
openDirectFetch,
|
|
9
|
+
openPublicFetch,
|
|
10
|
+
type PublicFetchSession,
|
|
11
|
+
} from "./public-fetch.ts";
|
|
7
12
|
import {
|
|
8
13
|
createActivityPubRequest,
|
|
9
14
|
FetchError,
|
|
10
15
|
type GetUserAgentOptions,
|
|
11
16
|
logRequest,
|
|
12
17
|
} from "./request.ts";
|
|
13
|
-
import { UrlError
|
|
18
|
+
import { UrlError } from "./url.ts";
|
|
14
19
|
|
|
15
20
|
const logger = getLogger(["fedify", "runtime", "docloader"]);
|
|
16
21
|
const DEFAULT_MAX_REDIRECTION = 20;
|
|
@@ -117,7 +122,9 @@ export type AuthenticatedDocumentLoaderFactory = (
|
|
|
117
122
|
* Gets a {@link RemoteDocument} from the given response.
|
|
118
123
|
* @param url The URL of the document to load.
|
|
119
124
|
* @param response The response to get the document from.
|
|
120
|
-
* @param fetch The function to fetch the document.
|
|
125
|
+
* @param fetch The function to fetch the document. It is called with the URL
|
|
126
|
+
* of an alternate document, if any, after the response body has
|
|
127
|
+
* been either read or its cancellation has been started.
|
|
121
128
|
* @returns The loaded remote document.
|
|
122
129
|
* @throws {FetchError} If the response is not OK.
|
|
123
130
|
* @internal
|
|
@@ -130,7 +137,54 @@ export async function getRemoteDocument(
|
|
|
130
137
|
options?: DocumentLoaderOptions,
|
|
131
138
|
) => Promise<RemoteDocument>,
|
|
132
139
|
): Promise<RemoteDocument> {
|
|
133
|
-
const
|
|
140
|
+
const result = await parseRemoteDocument(url, response);
|
|
141
|
+
if ("alternate" in result) return await fetch(result.alternate);
|
|
142
|
+
return result.document;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Starts cancelling the body of a response without waiting for it to settle.
|
|
147
|
+
* The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
|
|
148
|
+
* settles only after the other branch is cancelled too, per the Streams
|
|
149
|
+
* standard, so awaiting it could block forever. Failures are only logged.
|
|
150
|
+
* @param response The response whose body is cancelled.
|
|
151
|
+
* @param url The URL of the response, for logging.
|
|
152
|
+
*/
|
|
153
|
+
function cancelResponseBody(response: Response, url: string): void {
|
|
154
|
+
response.body?.cancel().catch((error) => {
|
|
155
|
+
logger.debug(
|
|
156
|
+
"Failed to cancel the response body from {url}: {error}",
|
|
157
|
+
{ url, error },
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The result of {@link parseRemoteDocument}: either the loaded document, or
|
|
164
|
+
* the URL of an alternate document to load instead.
|
|
165
|
+
*/
|
|
166
|
+
type ParsedRemoteDocument =
|
|
167
|
+
| { readonly document: RemoteDocument }
|
|
168
|
+
| { readonly alternate: string };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Gets a {@link RemoteDocument} from the given response, or the URL of an
|
|
172
|
+
* alternate document to load instead. When an alternate document is found,
|
|
173
|
+
* the response body has been either read or its cancellation has been started
|
|
174
|
+
* (without waiting for it), so that the alternate document can be loaded after
|
|
175
|
+
* the response's session is closed.
|
|
176
|
+
* @param url The URL of the document to load.
|
|
177
|
+
* @param response The response to get the document from.
|
|
178
|
+
* @returns The loaded remote document, or the alternate document URL.
|
|
179
|
+
* @throws {FetchError} If the response is not OK.
|
|
180
|
+
*/
|
|
181
|
+
async function parseRemoteDocument(
|
|
182
|
+
url: string,
|
|
183
|
+
response: Response,
|
|
184
|
+
): Promise<ParsedRemoteDocument> {
|
|
185
|
+
// Since the response URL is an IP address URL on runtimes that pin
|
|
186
|
+
// connections, the request URL is used instead:
|
|
187
|
+
const documentUrl = url;
|
|
134
188
|
const docUrl = new URL(documentUrl);
|
|
135
189
|
if (!response.ok) {
|
|
136
190
|
logger.error(
|
|
@@ -189,7 +243,8 @@ export async function getRemoteDocument(
|
|
|
189
243
|
"Found alternate document: {alternateUrl} from {url}",
|
|
190
244
|
{ alternateUrl: altUri.href, url: documentUrl },
|
|
191
245
|
);
|
|
192
|
-
|
|
246
|
+
cancelResponseBody(response, documentUrl);
|
|
247
|
+
return { alternate: altUri.href };
|
|
193
248
|
}
|
|
194
249
|
}
|
|
195
250
|
}
|
|
@@ -241,7 +296,7 @@ export async function getRemoteDocument(
|
|
|
241
296
|
"Found alternate document: {alternateUrl} from {url}",
|
|
242
297
|
{ alternateUrl: attribs.href, url: documentUrl },
|
|
243
298
|
);
|
|
244
|
-
return
|
|
299
|
+
return { alternate: new URL(attribs.href, docUrl).href };
|
|
245
300
|
}
|
|
246
301
|
}
|
|
247
302
|
document = JSON.parse(html);
|
|
@@ -258,7 +313,7 @@ export async function getRemoteDocument(
|
|
|
258
313
|
headers: Object.fromEntries(response.headers.entries()),
|
|
259
314
|
},
|
|
260
315
|
);
|
|
261
|
-
return { contextUrl, document, documentUrl };
|
|
316
|
+
return { document: { contextUrl, document, documentUrl } };
|
|
262
317
|
}
|
|
263
318
|
|
|
264
319
|
/**
|
|
@@ -297,13 +352,99 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
|
|
|
297
352
|
* @since 1.3.0
|
|
298
353
|
*/
|
|
299
354
|
export function getDocumentLoader(
|
|
355
|
+
options: GetDocumentLoaderOptions = {},
|
|
356
|
+
): DocumentLoader {
|
|
357
|
+
return createDocumentLoader(options);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The dependencies of {@link createDocumentLoader}.
|
|
362
|
+
*/
|
|
363
|
+
export interface DocumentLoaderDependencies {
|
|
364
|
+
/**
|
|
365
|
+
* Opens the fetch session for a single hop, instead of
|
|
366
|
+
* {@link openPublicFetch} (or {@link openDirectFetch} if private addresses
|
|
367
|
+
* are allowed).
|
|
368
|
+
*/
|
|
369
|
+
readonly openHop?: (url: string) => Promise<PublicFetchSession>;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The outcome of a single hop of {@link createDocumentLoader}, decided while
|
|
374
|
+
* its fetch session is open.
|
|
375
|
+
*/
|
|
376
|
+
type HopOutcome = ParsedRemoteDocument | { readonly location: string };
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The implementation of {@link getDocumentLoader}, with injectable
|
|
380
|
+
* dependencies. This is not exported from any entry point.
|
|
381
|
+
* @param options Options for the document loader.
|
|
382
|
+
* @param deps The dependencies.
|
|
383
|
+
* @returns The document loader.
|
|
384
|
+
*/
|
|
385
|
+
export function createDocumentLoader(
|
|
300
386
|
{ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent }:
|
|
301
387
|
GetDocumentLoaderOptions = {},
|
|
388
|
+
deps: DocumentLoaderDependencies = {},
|
|
302
389
|
): DocumentLoader {
|
|
303
390
|
const tracerProvider = trace.getTracerProvider();
|
|
304
391
|
const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
|
|
305
392
|
const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
306
393
|
|
|
394
|
+
async function openHop(
|
|
395
|
+
url: string,
|
|
396
|
+
signal?: AbortSignal,
|
|
397
|
+
): Promise<PublicFetchSession> {
|
|
398
|
+
try {
|
|
399
|
+
if (deps.openHop != null) return await deps.openHop(url);
|
|
400
|
+
return allowPrivateAddress
|
|
401
|
+
? await openDirectFetch(url)
|
|
402
|
+
: await openPublicFetch(url, { signal });
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (error instanceof UrlError) {
|
|
405
|
+
if (error.reason === "dns") {
|
|
406
|
+
logger.debug("DNS lookup failed for {url}", { url, error });
|
|
407
|
+
} else {
|
|
408
|
+
logger.error("Disallowed private URL: {url}", { url, error });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function fetchHop(
|
|
416
|
+
session: PublicFetchSession,
|
|
417
|
+
url: string,
|
|
418
|
+
span: Span,
|
|
419
|
+
options?: DocumentLoaderOptions,
|
|
420
|
+
): Promise<HopOutcome> {
|
|
421
|
+
try {
|
|
422
|
+
const request = createActivityPubRequest(url, { userAgent });
|
|
423
|
+
logRequest(logger, request);
|
|
424
|
+
const response = await session.fetch(request, {
|
|
425
|
+
// Since Bun has a bug that ignores the `Request.redirect` option,
|
|
426
|
+
// to work around it we specify `redirect: "manual"` here too:
|
|
427
|
+
// https://github.com/oven-sh/bun/issues/10754
|
|
428
|
+
redirect: "manual",
|
|
429
|
+
signal: options?.signal,
|
|
430
|
+
});
|
|
431
|
+
span.setAttribute("http.response.status_code", response.status);
|
|
432
|
+
|
|
433
|
+
// Follow redirects manually to get the final URL:
|
|
434
|
+
if (
|
|
435
|
+
response.status >= 300 && response.status < 400 &&
|
|
436
|
+
response.headers.has("Location")
|
|
437
|
+
) {
|
|
438
|
+
const location = response.headers.get("Location")!;
|
|
439
|
+
cancelResponseBody(response, url);
|
|
440
|
+
return { location };
|
|
441
|
+
}
|
|
442
|
+
return await parseRemoteDocument(url, response);
|
|
443
|
+
} finally {
|
|
444
|
+
await session.close();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
307
448
|
async function load(
|
|
308
449
|
url: string,
|
|
309
450
|
options?: DocumentLoaderOptions,
|
|
@@ -315,6 +456,11 @@ export function getDocumentLoader(
|
|
|
315
456
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
316
457
|
throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
|
|
317
458
|
}
|
|
459
|
+
// When merging forward, keep this guard independent of allowPrivateAddress:
|
|
460
|
+
// private-address opt-in must not permit credentials on any followed URL.
|
|
461
|
+
if (parsedUrl.username !== "" || parsedUrl.password !== "") {
|
|
462
|
+
throw new UrlError("URLs with credentials are not allowed");
|
|
463
|
+
}
|
|
318
464
|
const currentUrl = parsedUrl.href;
|
|
319
465
|
if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
|
|
320
466
|
logger.debug("Using preloaded context: {url}.", { url: currentUrl });
|
|
@@ -324,26 +470,9 @@ export function getDocumentLoader(
|
|
|
324
470
|
documentUrl: currentUrl,
|
|
325
471
|
};
|
|
326
472
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
} catch (error) {
|
|
331
|
-
if (error instanceof UrlError) {
|
|
332
|
-
if (error.reason === "dns") {
|
|
333
|
-
logger.debug("DNS lookup failed for {url}", {
|
|
334
|
-
url: currentUrl,
|
|
335
|
-
error,
|
|
336
|
-
});
|
|
337
|
-
} else {
|
|
338
|
-
logger.error("Disallowed private URL: {url}", {
|
|
339
|
-
url: currentUrl,
|
|
340
|
-
error,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
throw error;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
473
|
+
// Opening the session validates the URL; the session is closed by
|
|
474
|
+
// fetchHop() before the next hop is opened:
|
|
475
|
+
const session = await openHop(currentUrl, options?.signal);
|
|
347
476
|
visited.add(currentUrl);
|
|
348
477
|
|
|
349
478
|
return await tracer.startActiveSpan(
|
|
@@ -356,22 +485,8 @@ export function getDocumentLoader(
|
|
|
356
485
|
},
|
|
357
486
|
async (span) => {
|
|
358
487
|
try {
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
const response = await fetch(request, {
|
|
362
|
-
// Since Bun has a bug that ignores the `Request.redirect` option,
|
|
363
|
-
// to work around it we specify `redirect: "manual"` here too:
|
|
364
|
-
// https://github.com/oven-sh/bun/issues/10754
|
|
365
|
-
redirect: "manual",
|
|
366
|
-
signal: options?.signal,
|
|
367
|
-
});
|
|
368
|
-
span.setAttribute("http.response.status_code", response.status);
|
|
369
|
-
|
|
370
|
-
// Follow redirects manually to get the final URL:
|
|
371
|
-
if (
|
|
372
|
-
response.status >= 300 && response.status < 400 &&
|
|
373
|
-
response.headers.has("Location")
|
|
374
|
-
) {
|
|
488
|
+
const outcome = await fetchHop(session, currentUrl, span, options);
|
|
489
|
+
if ("location" in outcome) {
|
|
375
490
|
if (redirected >= maximumRedirection) {
|
|
376
491
|
logger.error(
|
|
377
492
|
"Too many redirections ({redirections}) while fetching document.",
|
|
@@ -382,10 +497,7 @@ export function getDocumentLoader(
|
|
|
382
497
|
`Too many redirections (${redirected + 1})`,
|
|
383
498
|
);
|
|
384
499
|
}
|
|
385
|
-
const redirectUrl = new URL(
|
|
386
|
-
response.headers.get("Location")!,
|
|
387
|
-
response.url === "" ? currentUrl : response.url,
|
|
388
|
-
).href;
|
|
500
|
+
const redirectUrl = new URL(outcome.location, currentUrl).href;
|
|
389
501
|
span.setAttribute("http.redirect.url", redirectUrl);
|
|
390
502
|
if (visited.has(redirectUrl)) {
|
|
391
503
|
logger.error(
|
|
@@ -401,26 +513,26 @@ export function getDocumentLoader(
|
|
|
401
513
|
return await load(redirectUrl, options, redirected + 1, visited);
|
|
402
514
|
}
|
|
403
515
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
516
|
+
let result: RemoteDocument;
|
|
517
|
+
if ("alternate" in outcome) {
|
|
518
|
+
const alternateUrl = outcome.alternate;
|
|
519
|
+
options?.signal?.throwIfAborted();
|
|
520
|
+
if (redirected >= DEFAULT_MAX_REDIRECTION) {
|
|
521
|
+
throw new FetchError(
|
|
522
|
+
currentUrl,
|
|
523
|
+
`Too many redirections (${redirected + 1})`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
if (visited.has(alternateUrl)) {
|
|
527
|
+
throw new FetchError(
|
|
528
|
+
currentUrl,
|
|
529
|
+
`Redirect loop detected: ${alternateUrl}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
result = await load(alternateUrl, options, redirected + 1, visited);
|
|
533
|
+
} else {
|
|
534
|
+
result = outcome.document;
|
|
535
|
+
}
|
|
424
536
|
span.setAttribute("docloader.document_url", result.documentUrl);
|
|
425
537
|
if (result.contextUrl != null) {
|
|
426
538
|
span.setAttribute("docloader.context_url", result.contextUrl);
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal APIs shared among Fedify packages. They are not part of the public
|
|
3
|
+
* API and have no semver guarantees; do not use them outside Fedify.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
openDirectFetch,
|
|
9
|
+
openPublicFetch,
|
|
10
|
+
type PublicFetchOptions,
|
|
11
|
+
type PublicFetchSession,
|
|
12
|
+
withPublicFetch,
|
|
13
|
+
} from "./public-fetch.ts";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test } from "@fedify/fixture";
|
|
2
|
+
import { deepStrictEqual, ok, strictEqual, throws } from "node:assert";
|
|
3
|
+
import type { LookupAddress } from "node:dns";
|
|
4
|
+
import {
|
|
5
|
+
issuePinGrant,
|
|
6
|
+
type PinGrant,
|
|
7
|
+
redeemPinGrant,
|
|
8
|
+
revokePinGrant,
|
|
9
|
+
} from "./pin-grant.ts";
|
|
10
|
+
|
|
11
|
+
const addresses: readonly LookupAddress[] = [
|
|
12
|
+
{ address: "8.8.8.8", family: 4 },
|
|
13
|
+
{ address: "2001:4860:4860::8888", family: 6 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
test("redeemPinGrant() returns what the grant was issued for", () => {
|
|
17
|
+
const url = new URL("https://pinned-host.invalid:8443/path?query");
|
|
18
|
+
const input = addresses.map((address) => ({ ...address }));
|
|
19
|
+
const grant = issuePinGrant(url, input);
|
|
20
|
+
const record = redeemPinGrant(grant, url);
|
|
21
|
+
deepStrictEqual(record.origin, "https://pinned-host.invalid:8443");
|
|
22
|
+
deepStrictEqual(record.hostname, "pinned-host.invalid");
|
|
23
|
+
deepStrictEqual(record.addresses, addresses);
|
|
24
|
+
|
|
25
|
+
// The record holds a frozen copy of the addresses:
|
|
26
|
+
input[0].address = "127.0.0.1";
|
|
27
|
+
input.push({ address: "10.0.0.1", family: 4 });
|
|
28
|
+
deepStrictEqual(redeemPinGrant(grant, url).addresses, addresses);
|
|
29
|
+
ok(Object.isFrozen(record));
|
|
30
|
+
ok(Object.isFrozen(record.addresses));
|
|
31
|
+
ok(Object.isFrozen(record.addresses[0]));
|
|
32
|
+
|
|
33
|
+
// Other paths and queries of the same origin are accepted:
|
|
34
|
+
strictEqual(
|
|
35
|
+
redeemPinGrant(grant, new URL("https://pinned-host.invalid:8443/other")),
|
|
36
|
+
record,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("redeemPinGrant() strips brackets from IPv6 hostnames", () => {
|
|
41
|
+
const url = new URL("http://[2001:4860:4860::8888]/");
|
|
42
|
+
const grant = issuePinGrant(url, [addresses[1]]);
|
|
43
|
+
const record = redeemPinGrant(grant, url);
|
|
44
|
+
deepStrictEqual(record.hostname, "2001:4860:4860::8888");
|
|
45
|
+
deepStrictEqual(record.origin, "http://[2001:4860:4860::8888]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("redeemPinGrant() rejects grants not issued by issuePinGrant()", () => {
|
|
49
|
+
const url = new URL("https://pinned-host.invalid/");
|
|
50
|
+
const issued = issuePinGrant(url, addresses);
|
|
51
|
+
const forgeries: Record<string, unknown> = {
|
|
52
|
+
"object literal": {},
|
|
53
|
+
"null-prototype object": Object.create(null),
|
|
54
|
+
"frozen null-prototype object": Object.freeze(Object.create(null)),
|
|
55
|
+
"copy of a grant": { ...issued },
|
|
56
|
+
"structured clone of a grant": structuredClone(issued),
|
|
57
|
+
"object inheriting from a grant": Object.create(issued),
|
|
58
|
+
"null": null,
|
|
59
|
+
"undefined": undefined,
|
|
60
|
+
"string": "grant",
|
|
61
|
+
};
|
|
62
|
+
for (const [name, forgery] of Object.entries(forgeries)) {
|
|
63
|
+
throws(
|
|
64
|
+
() => redeemPinGrant(forgery as PinGrant, url),
|
|
65
|
+
TypeError,
|
|
66
|
+
name,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
// The issued grant itself still works:
|
|
70
|
+
ok(redeemPinGrant(issued, url));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("redeemPinGrant() rejects URLs of other origins", () => {
|
|
74
|
+
const grant = issuePinGrant(
|
|
75
|
+
new URL("https://pinned-host.invalid/"),
|
|
76
|
+
addresses,
|
|
77
|
+
);
|
|
78
|
+
for (
|
|
79
|
+
const url of [
|
|
80
|
+
"http://pinned-host.invalid/",
|
|
81
|
+
"https://other-host.invalid/",
|
|
82
|
+
"https://sub.pinned-host.invalid/",
|
|
83
|
+
"https://pinned-host.invalid:8443/",
|
|
84
|
+
"https://8.8.8.8/",
|
|
85
|
+
]
|
|
86
|
+
) {
|
|
87
|
+
throws(() => redeemPinGrant(grant, new URL(url)), TypeError, url);
|
|
88
|
+
}
|
|
89
|
+
// The default port is the same origin:
|
|
90
|
+
ok(redeemPinGrant(grant, new URL("https://pinned-host.invalid:443/")));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("redeemPinGrant() rejects revoked grants", () => {
|
|
94
|
+
const url = new URL("https://pinned-host.invalid/");
|
|
95
|
+
const grant = issuePinGrant(url, addresses);
|
|
96
|
+
const other = issuePinGrant(url, addresses);
|
|
97
|
+
revokePinGrant(grant);
|
|
98
|
+
throws(() => redeemPinGrant(grant, url), TypeError);
|
|
99
|
+
// Revoking is idempotent, and does not affect other grants:
|
|
100
|
+
revokePinGrant(grant);
|
|
101
|
+
throws(() => redeemPinGrant(grant, url), TypeError);
|
|
102
|
+
ok(redeemPinGrant(other, url));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("revokePinGrant() ignores unknown objects", () => {
|
|
106
|
+
revokePinGrant({} as PinGrant);
|
|
107
|
+
revokePinGrant(Object.create(null) as PinGrant);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("issuePinGrant() returns a distinct frozen object every time", () => {
|
|
111
|
+
const url = new URL("https://pinned-host.invalid/");
|
|
112
|
+
const a = issuePinGrant(url, addresses);
|
|
113
|
+
const b = issuePinGrant(url, addresses);
|
|
114
|
+
ok(a !== b);
|
|
115
|
+
ok(Object.isFrozen(a));
|
|
116
|
+
strictEqual(Object.getPrototypeOf(a), null);
|
|
117
|
+
deepStrictEqual(Reflect.ownKeys(a), []);
|
|
118
|
+
});
|
package/src/pin-grant.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unforgeable capabilities that hand the addresses validated by
|
|
3
|
+
* `openPublicFetch()` over to a connection pinner.
|
|
4
|
+
*
|
|
5
|
+
* This module is not exported from any entry point of the package.
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ResolvedAddress } from "./url.ts";
|
|
10
|
+
|
|
11
|
+
declare const brand: unique symbol;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An opaque token that stands for a set of validated addresses of an origin.
|
|
15
|
+
* Only objects returned by {@link issuePinGrant} are accepted by
|
|
16
|
+
* {@link redeemPinGrant}; objects of the same shape are not.
|
|
17
|
+
*/
|
|
18
|
+
export type PinGrant = { readonly [brand]: never };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a {@link PinGrant} stands for.
|
|
22
|
+
*/
|
|
23
|
+
export interface PinGrantRecord {
|
|
24
|
+
/** The origin (scheme, host, and port) the grant was issued for. */
|
|
25
|
+
readonly origin: string;
|
|
26
|
+
/** The hostname the grant was issued for, without IPv6 brackets. */
|
|
27
|
+
readonly hostname: string;
|
|
28
|
+
/** The validated addresses, in lookup order. */
|
|
29
|
+
readonly addresses: readonly ResolvedAddress[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface GrantState {
|
|
33
|
+
readonly record: PinGrantRecord;
|
|
34
|
+
revoked: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const grants = new WeakMap<PinGrant, GrantState>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Issues a grant for the given URL's origin and addresses. This does not
|
|
41
|
+
* validate the addresses; callers validate them before issuing, and pinners
|
|
42
|
+
* validate them again right before connecting.
|
|
43
|
+
* @param url The URL whose origin the grant is bound to.
|
|
44
|
+
* @param addresses The addresses to connect to.
|
|
45
|
+
* @returns A new grant.
|
|
46
|
+
*/
|
|
47
|
+
export function issuePinGrant(
|
|
48
|
+
url: URL,
|
|
49
|
+
addresses: readonly ResolvedAddress[],
|
|
50
|
+
): PinGrant {
|
|
51
|
+
const grant = Object.freeze(Object.create(null)) as PinGrant;
|
|
52
|
+
let hostname = url.hostname;
|
|
53
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
54
|
+
hostname = hostname.slice(1, -1);
|
|
55
|
+
}
|
|
56
|
+
const record: PinGrantRecord = Object.freeze({
|
|
57
|
+
origin: url.origin,
|
|
58
|
+
hostname,
|
|
59
|
+
addresses: Object.freeze(
|
|
60
|
+
addresses.map(({ address, family }) =>
|
|
61
|
+
Object.freeze({ address, family })
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
grants.set(grant, { record, revoked: false });
|
|
66
|
+
return grant;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Checks that the given grant was issued by {@link issuePinGrant}, has not
|
|
71
|
+
* been revoked, and was issued for the origin of the given URL.
|
|
72
|
+
* @param grant The grant to redeem.
|
|
73
|
+
* @param url The URL to connect to.
|
|
74
|
+
* @returns What the grant stands for.
|
|
75
|
+
* @throws {TypeError} If the grant cannot be used for the URL.
|
|
76
|
+
*/
|
|
77
|
+
export function redeemPinGrant(grant: PinGrant, url: URL): PinGrantRecord {
|
|
78
|
+
const state = typeof grant === "object" && grant !== null
|
|
79
|
+
? grants.get(grant)
|
|
80
|
+
: undefined;
|
|
81
|
+
if (state == null) throw new TypeError("Invalid pin grant.");
|
|
82
|
+
if (state.revoked) throw new TypeError("The pin grant has been revoked.");
|
|
83
|
+
if (url.origin !== state.record.origin) {
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
`The pin grant for ${state.record.origin} cannot be used for ` +
|
|
86
|
+
`${url.origin}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return state.record;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Revokes the given grant so that it can no longer be redeemed.
|
|
94
|
+
* @param grant The grant to revoke.
|
|
95
|
+
*/
|
|
96
|
+
export function revokePinGrant(grant: PinGrant): void {
|
|
97
|
+
const state = grants.get(grant);
|
|
98
|
+
if (state != null) state.revoked = true;
|
|
99
|
+
}
|