@fedify/vocab-runtime 2.3.14 → 2.3.15
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 -253
- package/dist/mod.d.cts +5 -28
- package/dist/mod.d.ts +6 -28
- package/dist/mod.js +98 -245
- package/dist/public-fetch-BC-nWJNM.d.ts +143 -0
- package/dist/public-fetch-Bk32IaYB.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-DfWmIUIQ.cjs → body-BtW9TZGZ.cjs} +1 -1
- package/dist/tests/{body-DMMAC8hw.mjs → body-CmdrGt1w.mjs} +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-zzQIwiJk.cjs → docloader-D34pSxKd.cjs} +106 -35
- package/dist/tests/{docloader-Bx4CBPlL.mjs → docloader-zoX3z1bj.mjs} +102 -37
- package/dist/tests/docloader.test.cjs +523 -1273
- package/dist/tests/docloader.test.mjs +469 -1238
- package/dist/tests/esm-C_ftyt-G.cjs +1208 -0
- package/dist/tests/esm-LaZ1p80h.mjs +1222 -0
- package/dist/tests/internal/multicodec.test.cjs +1 -1
- package/dist/tests/internal/multicodec.test.mjs +1 -1
- package/dist/tests/{key-Qj7u1qmH.mjs → key-2lZR0Cht.mjs} +2 -2
- package/dist/tests/{key-CWDxavIR.cjs → key-CnsHrQSU.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-C0iftNaR.cjs +90 -0
- package/dist/tests/node-C3bjFjL1.mjs +84 -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-D4UQ-HXm.cjs +221 -0
- package/dist/tests/pinners-ZgZDx9y6.mjs +203 -0
- package/dist/tests/public-fetch-BtiBjQPL.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-BBCe2UzV.mjs → request-BKFirRrO.mjs} +1 -1
- package/dist/tests/{request-Bgv8wfpZ.cjs → request-CqKju-Ll.cjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/support-Cj0T6TP_.cjs +363 -0
- package/dist/tests/support-M5ClN-no.mjs +272 -0
- package/dist/tests/{url-4PAkYhZC.cjs → url-BR7UVTkW.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 +700 -32
- package/src/docloader.ts +174 -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-BL45eko3.cjs → multibase-CPwWBzL3.cjs} +0 -0
- /package/dist/tests/{multibase-Da9VJCw8.mjs → multibase-D85wf3ZR.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
|
@@ -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
|
@@ -88,6 +88,17 @@ test("validatePublicUrl()", async () => {
|
|
|
88
88
|
await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
test("validatePublicUrl() keeps rejecting fedify-test.internal on Bun", {
|
|
92
|
+
skip: !("Bun" in globalThis),
|
|
93
|
+
}, async () => {
|
|
94
|
+
// openPublicFetch() has no hostname exceptions, but validatePublicUrl()
|
|
95
|
+
// keeps its Bun-specific ones for compatibility:
|
|
96
|
+
await rejects(
|
|
97
|
+
() => validatePublicUrl("https://fedify-test.internal/"),
|
|
98
|
+
(error) => error instanceof UrlError && error.reason === "disallowed",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
91
102
|
test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
|
|
92
103
|
validateLookupAddresses([
|
|
93
104
|
{ 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"`.
|
|
@@ -25,25 +36,16 @@ export class UrlError extends Error {
|
|
|
25
36
|
|
|
26
37
|
/**
|
|
27
38
|
* Validates a URL to prevent SSRF attacks.
|
|
39
|
+
*
|
|
40
|
+
* This function only checks the addresses that the hostname currently
|
|
41
|
+
* resolves to; it does not pin the connection to those addresses. A request
|
|
42
|
+
* made after this check resolves the hostname again, so this function alone
|
|
43
|
+
* does not prevent DNS rebinding.
|
|
28
44
|
*/
|
|
29
45
|
export async function validatePublicUrl(url: string): Promise<void> {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
let hostname = parsed.hostname;
|
|
35
|
-
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
36
|
-
hostname = hostname.slice(1, -1);
|
|
37
|
-
}
|
|
38
|
-
if (hostname === "localhost") {
|
|
39
|
-
throw new UrlError("Localhost is not allowed");
|
|
40
|
-
}
|
|
41
|
-
const hostnameFamily = isIP(hostname);
|
|
42
|
-
if (hostnameFamily !== 0) {
|
|
43
|
-
validatePublicIpAddress(hostname, hostnameFamily);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
if ("Deno" in globalThis && !isIP(hostname)) {
|
|
46
|
+
const { hostname, family } = parsePublicUrl(url);
|
|
47
|
+
if (family !== 0) return;
|
|
48
|
+
if ("Deno" in globalThis) {
|
|
47
49
|
// If the `net` permission is not granted, we can't resolve the hostname.
|
|
48
50
|
// However, we can safely assume that it cannot gain access to private
|
|
49
51
|
// resources.
|
|
@@ -52,7 +54,8 @@ export async function validatePublicUrl(url: string): Promise<void> {
|
|
|
52
54
|
}
|
|
53
55
|
// FIXME: This is a temporary workaround for the `Bun` runtime; for unknown
|
|
54
56
|
// reasons, the Web Crypto API does not work as expected after a DNS lookup.
|
|
55
|
-
// This workaround purposes to prevent unit tests from hanging up
|
|
57
|
+
// This workaround purposes to prevent unit tests from hanging up.
|
|
58
|
+
// It only applies to this function; openPublicFetch() never skips lookups:
|
|
56
59
|
if ("Bun" in globalThis) {
|
|
57
60
|
if (hostname === "example.com" || hostname.endsWith(".example.com")) {
|
|
58
61
|
return;
|
|
@@ -62,13 +65,101 @@ export async function validatePublicUrl(url: string): Promise<void> {
|
|
|
62
65
|
}
|
|
63
66
|
// To prevent SSRF via DNS rebinding, we need to resolve all IP addresses
|
|
64
67
|
// and ensure that they are all public:
|
|
65
|
-
|
|
68
|
+
await lookupPublicAddresses(hostname, lookupAllAddresses);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A function that resolves all addresses of a hostname, like
|
|
73
|
+
* `node:dns.lookup()` with `{ all: true }`.
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
export type LookupAllAddresses = (
|
|
77
|
+
hostname: string,
|
|
78
|
+
) => Promise<readonly ResolvedAddress[]>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolves all addresses of the given hostname through `node:dns/promises`.
|
|
82
|
+
* The default `dns` object is looked up on every call so that tests can still
|
|
83
|
+
* stub `dns.lookup()`.
|
|
84
|
+
* @internal
|
|
85
|
+
*/
|
|
86
|
+
export function lookupAllAddresses(
|
|
87
|
+
hostname: string,
|
|
88
|
+
): Promise<readonly ResolvedAddress[]> {
|
|
89
|
+
return dns.lookup(hostname, { all: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The result of {@link parsePublicUrl}.
|
|
94
|
+
* @internal
|
|
95
|
+
*/
|
|
96
|
+
export interface ParsedPublicUrl {
|
|
97
|
+
readonly url: URL;
|
|
98
|
+
/** The hostname without brackets around IPv6 literals. */
|
|
99
|
+
readonly hostname: string;
|
|
100
|
+
/** The IP family of the hostname, or `0` if it is not an IP literal. */
|
|
101
|
+
readonly family: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Checks the parts of a URL that can be validated without resolving it:
|
|
106
|
+
* the protocol, the `localhost` hostname, and IP literal hostnames.
|
|
107
|
+
* @throws {UrlError} If the URL is not allowed.
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
export function parsePublicUrl(url: string | URL): ParsedPublicUrl {
|
|
111
|
+
const parsed = new URL(url);
|
|
112
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
113
|
+
throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
|
|
114
|
+
}
|
|
115
|
+
let hostname = parsed.hostname;
|
|
116
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
117
|
+
hostname = hostname.slice(1, -1);
|
|
118
|
+
}
|
|
119
|
+
if (hostname === "localhost") {
|
|
120
|
+
throw new UrlError("Localhost is not allowed");
|
|
121
|
+
}
|
|
122
|
+
const family = isIP(hostname);
|
|
123
|
+
if (family !== 0) validatePublicIpAddress(hostname, family);
|
|
124
|
+
return { url: parsed, hostname, family };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolves the given hostname and checks that every returned IP address is
|
|
129
|
+
* public.
|
|
130
|
+
* @returns The IP addresses of the hostname, without non-IP entries.
|
|
131
|
+
* @throws {UrlError} If the lookup fails, returns no IP address, or returns
|
|
132
|
+
* any non-public address.
|
|
133
|
+
* @internal
|
|
134
|
+
*/
|
|
135
|
+
export async function lookupPublicAddresses(
|
|
136
|
+
hostname: string,
|
|
137
|
+
lookup: LookupAllAddresses,
|
|
138
|
+
): Promise<ResolvedAddress[]> {
|
|
139
|
+
let addresses: readonly ResolvedAddress[];
|
|
66
140
|
try {
|
|
67
|
-
addresses = await
|
|
141
|
+
addresses = await lookup(hostname);
|
|
68
142
|
} catch (error) {
|
|
69
143
|
throw new UrlError("DNS lookup failed", { cause: error, reason: "dns" });
|
|
70
144
|
}
|
|
71
145
|
validateLookupAddresses(addresses);
|
|
146
|
+
return addresses
|
|
147
|
+
.filter(({ address }) => isIP(address) !== 0)
|
|
148
|
+
.map(({ address }) => ({ address, family: isIP(address) }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Checks that the given IP address is public. This is used right before
|
|
153
|
+
* connecting to an address that passed {@link lookupPublicAddresses}.
|
|
154
|
+
* @throws {UrlError} If the address is not a public IP address.
|
|
155
|
+
* @internal
|
|
156
|
+
*/
|
|
157
|
+
export function assertPublicAddress(address: ResolvedAddress): void {
|
|
158
|
+
const family = isIP(address.address);
|
|
159
|
+
if (family === 0 || family !== address.family) {
|
|
160
|
+
throw new UrlError(`Invalid or private address: ${address.address}`);
|
|
161
|
+
}
|
|
162
|
+
validatePublicIpAddress(address.address, family);
|
|
72
163
|
}
|
|
73
164
|
|
|
74
165
|
/**
|
|
@@ -89,7 +180,7 @@ export async function validatePublicUrl(url: string): Promise<void> {
|
|
|
89
180
|
* @internal
|
|
90
181
|
*/
|
|
91
182
|
export function validateLookupAddresses(
|
|
92
|
-
addresses: readonly
|
|
183
|
+
addresses: readonly ResolvedAddress[],
|
|
93
184
|
): void {
|
|
94
185
|
let ipAddressCount = 0;
|
|
95
186
|
for (const { address } of addresses) {
|
package/tsdown.config.ts
CHANGED
|
@@ -4,7 +4,12 @@ import { defineConfig } from "tsdown";
|
|
|
4
4
|
|
|
5
5
|
export default [
|
|
6
6
|
defineConfig({
|
|
7
|
-
entry: [
|
|
7
|
+
entry: [
|
|
8
|
+
"src/mod.ts",
|
|
9
|
+
"src/internal.ts",
|
|
10
|
+
"src/jsonld.ts",
|
|
11
|
+
"src/temporal.ts",
|
|
12
|
+
],
|
|
8
13
|
dts: { compilerOptions: { isolatedDeclarations: true, declaration: true } },
|
|
9
14
|
format: ["esm", "cjs"],
|
|
10
15
|
platform: "neutral",
|
|
@@ -15,8 +20,12 @@ export default [
|
|
|
15
20
|
}),
|
|
16
21
|
defineConfig({
|
|
17
22
|
outDir: "dist/tests",
|
|
18
|
-
entry:
|
|
19
|
-
.
|
|
23
|
+
entry: [
|
|
24
|
+
...await Array.fromAsync(glob(`src/**/*.test.ts`)),
|
|
25
|
+
// Run by the Node.js pinner tests in a child process, since Node.js
|
|
26
|
+
// cannot run TypeScript sources without flags before 22.6.0:
|
|
27
|
+
"src/pinners/testdata/node-child.ts",
|
|
28
|
+
].map((f) => f.replace(sep, "/")),
|
|
20
29
|
format: ["esm", "cjs"],
|
|
21
30
|
platform: "node",
|
|
22
31
|
deps: { neverBundle: [/^node:/, "@fedify/fixture"] },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|