@fedify/webfinger 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.
- package/deno.json +1 -1
- package/dist/lookup.test.cjs +599 -227
- package/dist/lookup.test.js +600 -227
- package/dist/mod.cjs +226 -160
- package/dist/mod.d.cts +29 -1
- package/dist/mod.d.ts +30 -2
- package/dist/mod.js +227 -162
- package/package.json +3 -3
- package/src/lookup-impl.ts +338 -0
- package/src/lookup.test.ts +569 -80
- package/src/lookup.ts +34 -231
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { getUserAgent, UrlError } from "@fedify/vocab-runtime";
|
|
2
|
+
import {
|
|
3
|
+
openDirectFetch,
|
|
4
|
+
openPublicFetch,
|
|
5
|
+
type PublicFetchSession,
|
|
6
|
+
} from "@fedify/vocab-runtime/internal";
|
|
7
|
+
import { getLogger } from "@logtape/logtape";
|
|
8
|
+
import { BodyTooLargeError, MAX_BODY_SIZE, readBoundedText } from "./body.ts";
|
|
9
|
+
import type { ResourceDescriptor } from "./jrd.ts";
|
|
10
|
+
import type {
|
|
11
|
+
LookupWebFingerOptions,
|
|
12
|
+
WebFingerLookupOutcome,
|
|
13
|
+
} from "./lookup.ts";
|
|
14
|
+
|
|
15
|
+
const logger = getLogger(["fedify", "webfinger", "lookup"]);
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_REDIRECTION = 5;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The dependencies of {@link lookupWebFingerInternal}.
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export interface LookupWebFingerDependencies {
|
|
24
|
+
/**
|
|
25
|
+
* Opens a fetch session for a single hop. Defaults to `openDirectFetch()`
|
|
26
|
+
* if `allowPrivateAddress` is turned on, or `openPublicFetch()` otherwise.
|
|
27
|
+
*/
|
|
28
|
+
readonly openHop?: (url: string) => Promise<PublicFetchSession>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The outcome of a single hop: either a redirect to follow, or the final
|
|
33
|
+
* result of the lookup.
|
|
34
|
+
*/
|
|
35
|
+
type HopResult =
|
|
36
|
+
| { readonly redirect: string; readonly statusCode: number }
|
|
37
|
+
| { readonly outcome: WebFingerLookupOutcome };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Looks up a WebFinger resource, opening a fetch session per hop. This is
|
|
41
|
+
* the implementation of {@link lookupWebFinger}, which is not exported from
|
|
42
|
+
* the package entry point.
|
|
43
|
+
* @param resource The resource URL to look up.
|
|
44
|
+
* @param options Extra options for looking up the resource.
|
|
45
|
+
* @param deps The dependencies.
|
|
46
|
+
* @returns The resource descriptor and terminal lookup classification.
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export async function lookupWebFingerInternal(
|
|
50
|
+
resource: URL | string,
|
|
51
|
+
options: LookupWebFingerOptions = {},
|
|
52
|
+
deps: LookupWebFingerDependencies = {},
|
|
53
|
+
): Promise<WebFingerLookupOutcome> {
|
|
54
|
+
if (typeof resource === "string") resource = new URL(resource);
|
|
55
|
+
let protocol = "https:";
|
|
56
|
+
let server: string;
|
|
57
|
+
if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
|
|
58
|
+
// `acct:` (RFC 7565) and `mailto:` (RFC 6068, used as a WebFinger
|
|
59
|
+
// resource per RFC 7033 §4.5) are opaque-path schemes: their
|
|
60
|
+
// `user@host` authority lives in `pathname`, not in `host`. The
|
|
61
|
+
// WebFinger host is extracted from the substring after the last
|
|
62
|
+
// `@`, and the lookup always goes to https on that host.
|
|
63
|
+
const atPos = resource.pathname.lastIndexOf("@");
|
|
64
|
+
if (atPos < 0) return { resource: null, result: "invalid" };
|
|
65
|
+
server = resource.pathname.substring(atPos + 1);
|
|
66
|
+
// The authority part of both schemes must be a bare host: no
|
|
67
|
+
// path, query, or fragment characters embedded in it. The
|
|
68
|
+
// WHATWG URL parser routes anything after the first `?` or `#`
|
|
69
|
+
// into `search` / `hash`, so by the time we read `pathname` the
|
|
70
|
+
// only stray characters that can land in `server` are slashes.
|
|
71
|
+
// Reject those (along with an empty authority) for both schemes.
|
|
72
|
+
if (server === "" || /[/?#]/.test(server)) {
|
|
73
|
+
return { resource: null, result: "invalid" };
|
|
74
|
+
}
|
|
75
|
+
// `acct:` (RFC 7565 §3) is bare authority only: no `search` or
|
|
76
|
+
// `hash` allowed. `mailto:` (RFC 6068 §2) explicitly permits
|
|
77
|
+
// `?hfields=…` header fields and fragment identifiers, so we
|
|
78
|
+
// forward those to the remote WebFinger lookup unchanged and
|
|
79
|
+
// only enforce the stricter shape for `acct:`.
|
|
80
|
+
if (
|
|
81
|
+
resource.protocol === "acct:" &&
|
|
82
|
+
(resource.search !== "" || resource.hash !== "")
|
|
83
|
+
) {
|
|
84
|
+
return { resource: null, result: "invalid" };
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
protocol = resource.protocol;
|
|
88
|
+
server = resource.host;
|
|
89
|
+
}
|
|
90
|
+
let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
|
|
91
|
+
url.searchParams.set("resource", resource.href);
|
|
92
|
+
const openHop = deps.openHop ??
|
|
93
|
+
((hopUrl: string) =>
|
|
94
|
+
options.allowPrivateAddress === true
|
|
95
|
+
? openDirectFetch(hopUrl)
|
|
96
|
+
: openPublicFetch(hopUrl, { signal: options.signal }));
|
|
97
|
+
let redirected = 0;
|
|
98
|
+
while (true) {
|
|
99
|
+
logger.debug(
|
|
100
|
+
"Fetching WebFinger resource descriptor from {url}...",
|
|
101
|
+
{ url: url.href },
|
|
102
|
+
);
|
|
103
|
+
const remoteHost = url.host;
|
|
104
|
+
const session = await openSession(url, openHop, options.signal);
|
|
105
|
+
if (session == null) {
|
|
106
|
+
return { resource: null, result: "network_error", remoteHost };
|
|
107
|
+
}
|
|
108
|
+
let hop: HopResult;
|
|
109
|
+
try {
|
|
110
|
+
hop = await fetchHop(session, url, options);
|
|
111
|
+
} finally {
|
|
112
|
+
await session.close();
|
|
113
|
+
}
|
|
114
|
+
if ("outcome" in hop) return hop.outcome;
|
|
115
|
+
redirected++;
|
|
116
|
+
const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
117
|
+
if (redirected > maxRedirection) {
|
|
118
|
+
logger.error(
|
|
119
|
+
"Too many redirections ({redirections}) while fetching WebFinger " +
|
|
120
|
+
"resource descriptor.",
|
|
121
|
+
{ redirections: redirected },
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
resource: null,
|
|
125
|
+
result: "invalid",
|
|
126
|
+
statusCode: hop.statusCode,
|
|
127
|
+
remoteHost,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// The request URL is the base, since a pinned response's url can be an
|
|
131
|
+
// IP address URL:
|
|
132
|
+
let redirectedUrl: URL;
|
|
133
|
+
try {
|
|
134
|
+
redirectedUrl = new URL(hop.redirect, url);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
logger.error(
|
|
137
|
+
"Invalid Location header while following WebFinger redirect: {error}",
|
|
138
|
+
{ url: url.href, error },
|
|
139
|
+
);
|
|
140
|
+
return {
|
|
141
|
+
resource: null,
|
|
142
|
+
result: "invalid",
|
|
143
|
+
statusCode: hop.statusCode,
|
|
144
|
+
remoteHost,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (redirectedUrl.protocol !== url.protocol) {
|
|
148
|
+
logger.error(
|
|
149
|
+
"Redirected to a different protocol ({protocol} to " +
|
|
150
|
+
"{redirectedProtocol}) while fetching WebFinger resource " +
|
|
151
|
+
"descriptor.",
|
|
152
|
+
{
|
|
153
|
+
protocol: url.protocol,
|
|
154
|
+
redirectedProtocol: redirectedUrl.protocol,
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
return {
|
|
158
|
+
resource: null,
|
|
159
|
+
result: "invalid",
|
|
160
|
+
statusCode: hop.statusCode,
|
|
161
|
+
remoteHost,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
url = redirectedUrl;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Opens a fetch session for a hop. A disallowed URL, a failed DNS lookup,
|
|
170
|
+
* or an aborted lookup yields `null` instead of an error.
|
|
171
|
+
*/
|
|
172
|
+
async function openSession(
|
|
173
|
+
url: URL,
|
|
174
|
+
openHop: (url: string) => Promise<PublicFetchSession>,
|
|
175
|
+
signal: AbortSignal | undefined,
|
|
176
|
+
): Promise<PublicFetchSession | null> {
|
|
177
|
+
try {
|
|
178
|
+
return await openHop(url.href);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (e instanceof UrlError) {
|
|
181
|
+
if (e.reason === "dns") {
|
|
182
|
+
logger.debug("DNS lookup failed for {url}", {
|
|
183
|
+
url: url.href,
|
|
184
|
+
error: e,
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
logger.error(
|
|
188
|
+
"Invalid URL for WebFinger resource descriptor: {error}",
|
|
189
|
+
{ error: e },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (signal?.aborted) {
|
|
195
|
+
logger.debug(
|
|
196
|
+
"Failed to fetch WebFinger resource descriptor: {error}",
|
|
197
|
+
{ url: url.href, error: e },
|
|
198
|
+
);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Fetches a hop with the given session, and consumes the response body or
|
|
207
|
+
* starts cancelling it before returning. The caller closes the session.
|
|
208
|
+
*/
|
|
209
|
+
async function fetchHop(
|
|
210
|
+
session: PublicFetchSession,
|
|
211
|
+
url: URL,
|
|
212
|
+
options: LookupWebFingerOptions,
|
|
213
|
+
): Promise<HopResult> {
|
|
214
|
+
let response: Response;
|
|
215
|
+
try {
|
|
216
|
+
response = await session.fetch(url, {
|
|
217
|
+
headers: {
|
|
218
|
+
Accept: "application/jrd+json",
|
|
219
|
+
"User-Agent": typeof options.userAgent === "string"
|
|
220
|
+
? options.userAgent
|
|
221
|
+
: getUserAgent(options.userAgent),
|
|
222
|
+
},
|
|
223
|
+
redirect: "manual",
|
|
224
|
+
signal: options.signal,
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
logger.debug(
|
|
228
|
+
"Failed to fetch WebFinger resource descriptor: {error}",
|
|
229
|
+
{ url: url.href, error },
|
|
230
|
+
);
|
|
231
|
+
return {
|
|
232
|
+
outcome: {
|
|
233
|
+
resource: null,
|
|
234
|
+
result: "network_error",
|
|
235
|
+
remoteHost: url.host,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (
|
|
240
|
+
response.status >= 300 && response.status < 400 &&
|
|
241
|
+
response.headers.has("Location")
|
|
242
|
+
) {
|
|
243
|
+
cancelBody(response, url);
|
|
244
|
+
return {
|
|
245
|
+
redirect: response.headers.get("Location")!,
|
|
246
|
+
statusCode: response.status,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
logger.debug(
|
|
251
|
+
"Failed to fetch WebFinger resource descriptor: {status} {statusText}.",
|
|
252
|
+
{
|
|
253
|
+
url: url.href,
|
|
254
|
+
status: response.status,
|
|
255
|
+
statusText: response.statusText,
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
cancelBody(response, url);
|
|
259
|
+
return {
|
|
260
|
+
outcome: {
|
|
261
|
+
resource: null,
|
|
262
|
+
result: response.status === 404 || response.status === 410
|
|
263
|
+
? "not_found"
|
|
264
|
+
: "error",
|
|
265
|
+
statusCode: response.status,
|
|
266
|
+
remoteHost: url.host,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const parsed: unknown = JSON.parse(
|
|
272
|
+
await readBoundedText(response, MAX_BODY_SIZE, url),
|
|
273
|
+
);
|
|
274
|
+
// A JSON primitive, null, or array is not a resource descriptor.
|
|
275
|
+
if (
|
|
276
|
+
typeof parsed !== "object" || parsed === null || Array.isArray(parsed)
|
|
277
|
+
) {
|
|
278
|
+
return {
|
|
279
|
+
outcome: {
|
|
280
|
+
resource: null,
|
|
281
|
+
result: "invalid",
|
|
282
|
+
statusCode: response.status,
|
|
283
|
+
remoteHost: url.host,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
outcome: {
|
|
289
|
+
resource: parsed as ResourceDescriptor,
|
|
290
|
+
result: "found",
|
|
291
|
+
statusCode: response.status,
|
|
292
|
+
remoteHost: url.host,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
} catch (e) {
|
|
296
|
+
if (e instanceof BodyTooLargeError) {
|
|
297
|
+
return {
|
|
298
|
+
outcome: {
|
|
299
|
+
resource: null,
|
|
300
|
+
result: "invalid",
|
|
301
|
+
statusCode: response.status,
|
|
302
|
+
remoteHost: url.host,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (e instanceof SyntaxError) {
|
|
307
|
+
logger.debug(
|
|
308
|
+
"Failed to parse WebFinger resource descriptor as JSON: {error}",
|
|
309
|
+
{ error: e },
|
|
310
|
+
);
|
|
311
|
+
return {
|
|
312
|
+
outcome: {
|
|
313
|
+
resource: null,
|
|
314
|
+
result: "invalid",
|
|
315
|
+
statusCode: response.status,
|
|
316
|
+
remoteHost: url.host,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Starts cancelling a response body that is not read, without waiting for it
|
|
326
|
+
* to settle. The cancellation of a branch of a tee (e.g., a body cloned by
|
|
327
|
+
* a fetch mock) settles only after the other branch is cancelled too, per
|
|
328
|
+
* the Streams standard, so awaiting it could block forever. A failure is
|
|
329
|
+
* only logged.
|
|
330
|
+
*/
|
|
331
|
+
function cancelBody(response: Response, url: URL): void {
|
|
332
|
+
response.body?.cancel().catch((error) => {
|
|
333
|
+
logger.debug(
|
|
334
|
+
"Failed to cancel the response body from {url}: {error}",
|
|
335
|
+
{ url: url.href, error },
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
}
|