@fedify/webfinger 2.3.0-dev.994 → 2.3.0-pr.809.37
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 +2 -2
- package/dist/lookup.test.cjs +518 -143
- package/dist/lookup.test.js +520 -145
- package/dist/mod.cjs +148 -18
- package/dist/mod.d.cts +33 -2
- package/dist/mod.d.ts +33 -2
- package/dist/mod.js +148 -18
- package/package.json +7 -7
- package/src/lookup.test.ts +793 -236
- package/src/lookup.ts +253 -22
package/src/lookup.ts
CHANGED
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
} from "@fedify/vocab-runtime";
|
|
7
7
|
import { getLogger } from "@logtape/logtape";
|
|
8
8
|
import {
|
|
9
|
+
type Attributes,
|
|
10
|
+
type Counter,
|
|
11
|
+
type Histogram,
|
|
12
|
+
type MeterProvider,
|
|
9
13
|
SpanKind,
|
|
10
14
|
SpanStatusCode,
|
|
11
15
|
trace,
|
|
@@ -18,6 +22,114 @@ const logger = getLogger(["fedify", "webfinger", "lookup"]);
|
|
|
18
22
|
|
|
19
23
|
const DEFAULT_MAX_REDIRECTION = 5;
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The terminal classification of an outgoing {@link lookupWebFinger} call,
|
|
27
|
+
* recorded as the `webfinger.lookup.result` attribute on the
|
|
28
|
+
* `webfinger.lookup` counter and `webfinger.lookup.duration` histogram.
|
|
29
|
+
*
|
|
30
|
+
* - `found`: the lookup returned a {@link ResourceDescriptor}.
|
|
31
|
+
* - `not_found`: the remote responded with HTTP `404 Not Found` or
|
|
32
|
+
* `410 Gone`. Recorded together with `http.response.status_code`.
|
|
33
|
+
* - `invalid`: the remote responded with content Fedify could not parse
|
|
34
|
+
* into a {@link ResourceDescriptor} (JSON parse failure), the
|
|
35
|
+
* redirect chain exceeded `maxRedirection`, the remote redirected to
|
|
36
|
+
* a different protocol, or the queried `acct:` resource itself was
|
|
37
|
+
* malformed.
|
|
38
|
+
* - `network_error`: no HTTP response was received (the URL was
|
|
39
|
+
* rejected as a private address, `fetch()` threw, or an `AbortError`
|
|
40
|
+
* cancelled the request).
|
|
41
|
+
* - `error`: the remote responded with a non-2xx HTTP response that is
|
|
42
|
+
* neither `404` nor `410`, or any other unexpected failure bubbled up
|
|
43
|
+
* from the lookup.
|
|
44
|
+
* @since 2.3.0
|
|
45
|
+
*/
|
|
46
|
+
export type WebFingerLookupResult =
|
|
47
|
+
| "found"
|
|
48
|
+
| "not_found"
|
|
49
|
+
| "invalid"
|
|
50
|
+
| "network_error"
|
|
51
|
+
| "error";
|
|
52
|
+
|
|
53
|
+
interface WebFingerInstruments {
|
|
54
|
+
lookup: Counter;
|
|
55
|
+
lookupDuration: Histogram;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const WEBFINGER_HISTOGRAM_BUCKETS: ReadonlyArray<number> = [
|
|
59
|
+
5,
|
|
60
|
+
10,
|
|
61
|
+
25,
|
|
62
|
+
50,
|
|
63
|
+
75,
|
|
64
|
+
100,
|
|
65
|
+
250,
|
|
66
|
+
500,
|
|
67
|
+
750,
|
|
68
|
+
1000,
|
|
69
|
+
2500,
|
|
70
|
+
5000,
|
|
71
|
+
7500,
|
|
72
|
+
10000,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const webFingerInstruments = new WeakMap<MeterProvider, WebFingerInstruments>();
|
|
76
|
+
|
|
77
|
+
function getWebFingerInstruments(
|
|
78
|
+
meterProvider: MeterProvider,
|
|
79
|
+
): WebFingerInstruments {
|
|
80
|
+
let instruments = webFingerInstruments.get(meterProvider);
|
|
81
|
+
if (instruments == null) {
|
|
82
|
+
const meter = meterProvider.getMeter(metadata.name, metadata.version);
|
|
83
|
+
instruments = {
|
|
84
|
+
lookup: meter.createCounter("webfinger.lookup", {
|
|
85
|
+
description: "Outgoing WebFinger lookup attempts.",
|
|
86
|
+
unit: "{lookup}",
|
|
87
|
+
}),
|
|
88
|
+
lookupDuration: meter.createHistogram("webfinger.lookup.duration", {
|
|
89
|
+
description: "Duration of outgoing WebFinger lookups.",
|
|
90
|
+
unit: "ms",
|
|
91
|
+
advice: { explicitBucketBoundaries: [...WEBFINGER_HISTOGRAM_BUCKETS] },
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
webFingerInstruments.set(meterProvider, instruments);
|
|
95
|
+
}
|
|
96
|
+
return instruments;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getResourceScheme(resource: URL | string): string {
|
|
100
|
+
if (typeof resource === "string") {
|
|
101
|
+
const colon = resource.indexOf(":");
|
|
102
|
+
return colon > 0 ? resource.substring(0, colon).toLowerCase() : "";
|
|
103
|
+
}
|
|
104
|
+
return resource.protocol.replace(/:$/, "").toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The scheme attribute is recorded on the `webfinger.lookup` metric. Even
|
|
108
|
+
// though most call sites pass scheme-controlled resources (Fedify code and
|
|
109
|
+
// library users), `lookupObject()` accepts user-supplied identifiers that
|
|
110
|
+
// flow into here, so the metric attribute is bucketed to the schemes
|
|
111
|
+
// WebFinger / fediverse clients legitimately use (RFC 7565 +
|
|
112
|
+
// ActivityPub). Anything else is bucketed as `other`, keeping metric
|
|
113
|
+
// cardinality bounded even when a remote returns redirects whose target
|
|
114
|
+
// scheme is unusual.
|
|
115
|
+
const WEBFINGER_LOOKUP_SCHEME_WHITELIST: ReadonlySet<string> = new Set([
|
|
116
|
+
"acct",
|
|
117
|
+
"http",
|
|
118
|
+
"https",
|
|
119
|
+
"mailto",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
function getMetricResourceScheme(scheme: string): string {
|
|
123
|
+
return WEBFINGER_LOOKUP_SCHEME_WHITELIST.has(scheme) ? scheme : "other";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface WebFingerLookupOutcome {
|
|
127
|
+
resource: ResourceDescriptor | null;
|
|
128
|
+
result: WebFingerLookupResult;
|
|
129
|
+
statusCode?: number;
|
|
130
|
+
remoteHost?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
21
133
|
/**
|
|
22
134
|
* Options for {@link lookupWebFinger}.
|
|
23
135
|
* @since 1.3.0
|
|
@@ -54,6 +166,16 @@ export interface LookupWebFingerOptions {
|
|
|
54
166
|
*/
|
|
55
167
|
tracerProvider?: TracerProvider;
|
|
56
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The OpenTelemetry meter provider used to record the `webfinger.lookup`
|
|
171
|
+
* counter and `webfinger.lookup.duration` histogram. If omitted, no
|
|
172
|
+
* metric measurements are emitted (the helper is opt-in to avoid
|
|
173
|
+
* touching the global meter provider for callers that do not use
|
|
174
|
+
* OpenTelemetry).
|
|
175
|
+
* @since 2.3.0
|
|
176
|
+
*/
|
|
177
|
+
meterProvider?: MeterProvider;
|
|
178
|
+
|
|
57
179
|
/**
|
|
58
180
|
* AbortSignal for cancelling the request.
|
|
59
181
|
* @since 1.8.0
|
|
@@ -77,24 +199,35 @@ export async function lookupWebFinger(
|
|
|
77
199
|
metadata.name,
|
|
78
200
|
metadata.version,
|
|
79
201
|
);
|
|
202
|
+
const scheme = getResourceScheme(resource);
|
|
80
203
|
return await tracer.startActiveSpan(
|
|
81
204
|
"webfinger.lookup",
|
|
82
205
|
{
|
|
83
206
|
kind: SpanKind.CLIENT,
|
|
84
207
|
attributes: {
|
|
85
208
|
"webfinger.resource": resource.toString(),
|
|
86
|
-
"webfinger.resource.scheme":
|
|
87
|
-
? resource.replace(/:.*$/, "")
|
|
88
|
-
: resource.protocol.replace(/:$/, ""),
|
|
209
|
+
"webfinger.resource.scheme": scheme,
|
|
89
210
|
},
|
|
90
211
|
},
|
|
91
212
|
async (span) => {
|
|
213
|
+
const meterProvider = options.meterProvider;
|
|
214
|
+
const start = meterProvider == null ? 0 : performance.now();
|
|
215
|
+
// Initialise the outcome with the `error` shape that the `finally`
|
|
216
|
+
// block records when `lookupWebFingerInternal()` itself rejects;
|
|
217
|
+
// the `try` body reassigns this to the actual outcome before any
|
|
218
|
+
// other statement runs, so the `catch` does not need to reassign.
|
|
219
|
+
let outcome: WebFingerLookupOutcome = {
|
|
220
|
+
resource: null,
|
|
221
|
+
result: "error",
|
|
222
|
+
};
|
|
92
223
|
try {
|
|
93
|
-
|
|
224
|
+
outcome = await lookupWebFingerInternal(resource, options);
|
|
94
225
|
span.setStatus({
|
|
95
|
-
code:
|
|
226
|
+
code: outcome.resource === null
|
|
227
|
+
? SpanStatusCode.ERROR
|
|
228
|
+
: SpanStatusCode.OK,
|
|
96
229
|
});
|
|
97
|
-
return
|
|
230
|
+
return outcome.resource;
|
|
98
231
|
} catch (error) {
|
|
99
232
|
span.setStatus({
|
|
100
233
|
code: SpanStatusCode.ERROR,
|
|
@@ -102,24 +235,73 @@ export async function lookupWebFinger(
|
|
|
102
235
|
});
|
|
103
236
|
throw error;
|
|
104
237
|
} finally {
|
|
238
|
+
if (meterProvider != null) {
|
|
239
|
+
const durationMs = Math.max(0, performance.now() - start);
|
|
240
|
+
recordWebFingerLookup(meterProvider, durationMs, scheme, outcome);
|
|
241
|
+
}
|
|
105
242
|
span.end();
|
|
106
243
|
}
|
|
107
244
|
},
|
|
108
245
|
);
|
|
109
246
|
}
|
|
110
247
|
|
|
248
|
+
function recordWebFingerLookup(
|
|
249
|
+
meterProvider: MeterProvider,
|
|
250
|
+
durationMs: number,
|
|
251
|
+
scheme: string,
|
|
252
|
+
outcome: WebFingerLookupOutcome,
|
|
253
|
+
): void {
|
|
254
|
+
const attributes: Attributes = {
|
|
255
|
+
"webfinger.lookup.result": outcome.result,
|
|
256
|
+
"webfinger.resource.scheme": getMetricResourceScheme(scheme),
|
|
257
|
+
};
|
|
258
|
+
if (outcome.remoteHost != null) {
|
|
259
|
+
attributes["activitypub.remote.host"] = outcome.remoteHost;
|
|
260
|
+
}
|
|
261
|
+
if (outcome.statusCode != null) {
|
|
262
|
+
attributes["http.response.status_code"] = outcome.statusCode;
|
|
263
|
+
}
|
|
264
|
+
const instruments = getWebFingerInstruments(meterProvider);
|
|
265
|
+
instruments.lookup.add(1, attributes);
|
|
266
|
+
instruments.lookupDuration.record(durationMs, attributes);
|
|
267
|
+
}
|
|
268
|
+
|
|
111
269
|
async function lookupWebFingerInternal(
|
|
112
270
|
resource: URL | string,
|
|
113
271
|
options: LookupWebFingerOptions = {},
|
|
114
|
-
): Promise<
|
|
272
|
+
): Promise<WebFingerLookupOutcome> {
|
|
115
273
|
if (typeof resource === "string") resource = new URL(resource);
|
|
116
274
|
let protocol = "https:";
|
|
117
275
|
let server: string;
|
|
118
|
-
if (resource.protocol === "acct:") {
|
|
276
|
+
if (resource.protocol === "acct:" || resource.protocol === "mailto:") {
|
|
277
|
+
// `acct:` (RFC 7565) and `mailto:` (RFC 6068, used as a WebFinger
|
|
278
|
+
// resource per RFC 7033 §4.5) are opaque-path schemes: their
|
|
279
|
+
// `user@host` authority lives in `pathname`, not in `host`. The
|
|
280
|
+
// WebFinger host is extracted from the substring after the last
|
|
281
|
+
// `@`, and the lookup always goes to https on that host.
|
|
119
282
|
const atPos = resource.pathname.lastIndexOf("@");
|
|
120
|
-
if (atPos < 0) return null;
|
|
283
|
+
if (atPos < 0) return { resource: null, result: "invalid" };
|
|
121
284
|
server = resource.pathname.substring(atPos + 1);
|
|
122
|
-
|
|
285
|
+
// The authority part of both schemes must be a bare host: no
|
|
286
|
+
// path, query, or fragment characters embedded in it. The
|
|
287
|
+
// WHATWG URL parser routes anything after the first `?` or `#`
|
|
288
|
+
// into `search` / `hash`, so by the time we read `pathname` the
|
|
289
|
+
// only stray characters that can land in `server` are slashes.
|
|
290
|
+
// Reject those (along with an empty authority) for both schemes.
|
|
291
|
+
if (server === "" || /[/?#]/.test(server)) {
|
|
292
|
+
return { resource: null, result: "invalid" };
|
|
293
|
+
}
|
|
294
|
+
// `acct:` (RFC 7565 §3) is bare authority only: no `search` or
|
|
295
|
+
// `hash` allowed. `mailto:` (RFC 6068 §2) explicitly permits
|
|
296
|
+
// `?hfields=…` header fields and fragment identifiers, so we
|
|
297
|
+
// forward those to the remote WebFinger lookup unchanged and
|
|
298
|
+
// only enforce the stricter shape for `acct:`.
|
|
299
|
+
if (
|
|
300
|
+
resource.protocol === "acct:" &&
|
|
301
|
+
(resource.search !== "" || resource.hash !== "")
|
|
302
|
+
) {
|
|
303
|
+
return { resource: null, result: "invalid" };
|
|
304
|
+
}
|
|
123
305
|
} else {
|
|
124
306
|
protocol = resource.protocol;
|
|
125
307
|
server = resource.host;
|
|
@@ -128,6 +310,7 @@ async function lookupWebFingerInternal(
|
|
|
128
310
|
url.searchParams.set("resource", resource.href);
|
|
129
311
|
let redirected = 0;
|
|
130
312
|
while (true) {
|
|
313
|
+
const remoteHost = url.host;
|
|
131
314
|
logger.debug(
|
|
132
315
|
"Fetching WebFinger resource descriptor from {url}...",
|
|
133
316
|
{ url: url.href },
|
|
@@ -142,7 +325,7 @@ async function lookupWebFingerInternal(
|
|
|
142
325
|
"Invalid URL for WebFinger resource descriptor: {error}",
|
|
143
326
|
{ error: e },
|
|
144
327
|
);
|
|
145
|
-
return null;
|
|
328
|
+
return { resource: null, result: "network_error", remoteHost };
|
|
146
329
|
}
|
|
147
330
|
throw e;
|
|
148
331
|
}
|
|
@@ -163,7 +346,7 @@ async function lookupWebFingerInternal(
|
|
|
163
346
|
"Failed to fetch WebFinger resource descriptor: {error}",
|
|
164
347
|
{ url: url.href, error },
|
|
165
348
|
);
|
|
166
|
-
return null;
|
|
349
|
+
return { resource: null, result: "network_error", remoteHost };
|
|
167
350
|
}
|
|
168
351
|
if (
|
|
169
352
|
response.status >= 300 && response.status < 400 &&
|
|
@@ -171,18 +354,44 @@ async function lookupWebFingerInternal(
|
|
|
171
354
|
) {
|
|
172
355
|
redirected++;
|
|
173
356
|
const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
174
|
-
|
|
357
|
+
// `maxRedirection: N` is documented as "the maximum number of
|
|
358
|
+
// redirections to follow", so the Nth redirect must still be
|
|
359
|
+
// followed and the (N+1)th rejected. An earlier version used
|
|
360
|
+
// `>=` here, which drifted by one from the documented semantics
|
|
361
|
+
// and from the sibling code in @fedify/vocab-runtime's document
|
|
362
|
+
// loader.
|
|
363
|
+
if (redirected > maxRedirection) {
|
|
175
364
|
logger.error(
|
|
176
365
|
"Too many redirections ({redirections}) while fetching WebFinger " +
|
|
177
366
|
"resource descriptor.",
|
|
178
367
|
{ redirections: redirected },
|
|
179
368
|
);
|
|
180
|
-
return
|
|
369
|
+
return {
|
|
370
|
+
resource: null,
|
|
371
|
+
result: "invalid",
|
|
372
|
+
statusCode: response.status,
|
|
373
|
+
remoteHost,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
let redirectedUrl: URL;
|
|
377
|
+
try {
|
|
378
|
+
redirectedUrl = new URL(
|
|
379
|
+
response.headers.get("Location")!,
|
|
380
|
+
response.url == null || response.url === "" ? url : response.url,
|
|
381
|
+
);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
logger.error(
|
|
384
|
+
"Invalid Location header while following WebFinger redirect: " +
|
|
385
|
+
"{error}",
|
|
386
|
+
{ url: url.href, error: e },
|
|
387
|
+
);
|
|
388
|
+
return {
|
|
389
|
+
resource: null,
|
|
390
|
+
result: "invalid",
|
|
391
|
+
statusCode: response.status,
|
|
392
|
+
remoteHost,
|
|
393
|
+
};
|
|
181
394
|
}
|
|
182
|
-
const redirectedUrl = new URL(
|
|
183
|
-
response.headers.get("Location")!,
|
|
184
|
-
response.url == null || response.url === "" ? url : response.url,
|
|
185
|
-
);
|
|
186
395
|
if (redirectedUrl.protocol !== url.protocol) {
|
|
187
396
|
logger.error(
|
|
188
397
|
"Redirected to a different protocol ({protocol} to " +
|
|
@@ -193,7 +402,12 @@ async function lookupWebFingerInternal(
|
|
|
193
402
|
redirectedProtocol: redirectedUrl.protocol,
|
|
194
403
|
},
|
|
195
404
|
);
|
|
196
|
-
return
|
|
405
|
+
return {
|
|
406
|
+
resource: null,
|
|
407
|
+
result: "invalid",
|
|
408
|
+
statusCode: response.status,
|
|
409
|
+
remoteHost,
|
|
410
|
+
};
|
|
197
411
|
}
|
|
198
412
|
url = redirectedUrl;
|
|
199
413
|
continue;
|
|
@@ -207,17 +421,34 @@ async function lookupWebFingerInternal(
|
|
|
207
421
|
statusText: response.statusText,
|
|
208
422
|
},
|
|
209
423
|
);
|
|
210
|
-
|
|
424
|
+
const isNotFound = response.status === 404 || response.status === 410;
|
|
425
|
+
return {
|
|
426
|
+
resource: null,
|
|
427
|
+
result: isNotFound ? "not_found" : "error",
|
|
428
|
+
statusCode: response.status,
|
|
429
|
+
remoteHost,
|
|
430
|
+
};
|
|
211
431
|
}
|
|
212
432
|
try {
|
|
213
|
-
|
|
433
|
+
const parsed = await response.json() as ResourceDescriptor;
|
|
434
|
+
return {
|
|
435
|
+
resource: parsed,
|
|
436
|
+
result: "found",
|
|
437
|
+
statusCode: response.status,
|
|
438
|
+
remoteHost,
|
|
439
|
+
};
|
|
214
440
|
} catch (e) {
|
|
215
441
|
if (e instanceof SyntaxError) {
|
|
216
442
|
logger.debug(
|
|
217
443
|
"Failed to parse WebFinger resource descriptor as JSON: {error}",
|
|
218
444
|
{ error: e },
|
|
219
445
|
);
|
|
220
|
-
return
|
|
446
|
+
return {
|
|
447
|
+
resource: null,
|
|
448
|
+
result: "invalid",
|
|
449
|
+
statusCode: response.status,
|
|
450
|
+
remoteHost,
|
|
451
|
+
};
|
|
221
452
|
}
|
|
222
453
|
throw e;
|
|
223
454
|
}
|