@fedify/webfinger 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 +1 -1
- package/dist/lookup.test.cjs +440 -109
- package/dist/lookup.test.js +441 -109
- package/dist/mod.cjs +143 -84
- package/dist/mod.d.ts +1 -1
- package/dist/mod.js +144 -85
- package/package.json +3 -3
- package/src/lookup-impl.ts +234 -0
- package/src/lookup.test.ts +469 -36
- package/src/lookup.ts +2 -145
package/dist/lookup.test.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { test } from "@fedify/fixture";
|
|
2
|
-
import { FetchError, UrlError, getUserAgent
|
|
2
|
+
import { FetchError, UrlError, getUserAgent } from "@fedify/vocab-runtime";
|
|
3
|
+
import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
|
|
3
4
|
import { configure, getLogger, reset } from "@logtape/logtape";
|
|
4
5
|
import { withTimeout } from "es-toolkit";
|
|
5
6
|
import { deepStrictEqual, ok } from "node:assert/strict";
|
|
6
|
-
import dns from "node:dns/promises";
|
|
7
7
|
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
8
8
|
//#region \0rolldown/runtime.js
|
|
9
9
|
var __create = Object.create;
|
|
@@ -1228,7 +1228,7 @@ var esm_default = new class FetchMock {
|
|
|
1228
1228
|
//#endregion
|
|
1229
1229
|
//#region deno.json
|
|
1230
1230
|
var name = "@fedify/webfinger";
|
|
1231
|
-
var version = "2.2.
|
|
1231
|
+
var version = "2.2.20";
|
|
1232
1232
|
//#endregion
|
|
1233
1233
|
//#region src/body.ts
|
|
1234
1234
|
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
@@ -1304,7 +1304,7 @@ async function readBoundedText(message, maxBytes, url) {
|
|
|
1304
1304
|
}
|
|
1305
1305
|
}
|
|
1306
1306
|
//#endregion
|
|
1307
|
-
//#region src/lookup.ts
|
|
1307
|
+
//#region src/lookup-impl.ts
|
|
1308
1308
|
const logger = getLogger([
|
|
1309
1309
|
"fedify",
|
|
1310
1310
|
"webfinger",
|
|
@@ -1312,36 +1312,16 @@ const logger = getLogger([
|
|
|
1312
1312
|
]);
|
|
1313
1313
|
const DEFAULT_MAX_REDIRECTION = 5;
|
|
1314
1314
|
/**
|
|
1315
|
-
* Looks up a WebFinger resource.
|
|
1315
|
+
* Looks up a WebFinger resource, opening a fetch session per hop. This is
|
|
1316
|
+
* the implementation of {@link lookupWebFinger}, which is not exported from
|
|
1317
|
+
* the package entry point.
|
|
1316
1318
|
* @param resource The resource URL to look up.
|
|
1317
1319
|
* @param options Extra options for looking up the resource.
|
|
1320
|
+
* @param deps The dependencies.
|
|
1318
1321
|
* @returns The resource descriptor, or `null` if not found.
|
|
1319
|
-
* @
|
|
1322
|
+
* @internal
|
|
1320
1323
|
*/
|
|
1321
|
-
async function
|
|
1322
|
-
return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("webfinger.lookup", {
|
|
1323
|
-
kind: SpanKind.CLIENT,
|
|
1324
|
-
attributes: {
|
|
1325
|
-
"webfinger.resource": resource.toString(),
|
|
1326
|
-
"webfinger.resource.scheme": typeof resource === "string" ? resource.replace(/:.*$/, "") : resource.protocol.replace(/:$/, "")
|
|
1327
|
-
}
|
|
1328
|
-
}, async (span) => {
|
|
1329
|
-
try {
|
|
1330
|
-
const result = await lookupWebFingerInternal(resource, options);
|
|
1331
|
-
span.setStatus({ code: result === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
|
|
1332
|
-
return result;
|
|
1333
|
-
} catch (error) {
|
|
1334
|
-
span.setStatus({
|
|
1335
|
-
code: SpanStatusCode.ERROR,
|
|
1336
|
-
message: String(error)
|
|
1337
|
-
});
|
|
1338
|
-
throw error;
|
|
1339
|
-
} finally {
|
|
1340
|
-
span.end();
|
|
1341
|
-
}
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
async function lookupWebFingerInternal(resource, options = {}) {
|
|
1324
|
+
async function lookupWebFingerInternal(resource, options = {}, deps = {}) {
|
|
1345
1325
|
if (typeof resource === "string") resource = new URL(resource);
|
|
1346
1326
|
let protocol = "https:";
|
|
1347
1327
|
let server;
|
|
@@ -1356,84 +1336,175 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1356
1336
|
}
|
|
1357
1337
|
let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
|
|
1358
1338
|
url.searchParams.set("resource", resource.href);
|
|
1339
|
+
const openHop = deps.openHop ?? ((hopUrl) => options.allowPrivateAddress === true ? openDirectFetch(hopUrl) : openPublicFetch(hopUrl, { signal: options.signal }));
|
|
1359
1340
|
let redirected = 0;
|
|
1360
1341
|
while (true) {
|
|
1361
1342
|
logger.debug("Fetching WebFinger resource descriptor from {url}...", { url: url.href });
|
|
1362
|
-
|
|
1363
|
-
if (
|
|
1364
|
-
|
|
1365
|
-
} catch (e) {
|
|
1366
|
-
if (e instanceof UrlError) {
|
|
1367
|
-
if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
1368
|
-
url: url.href,
|
|
1369
|
-
error: e
|
|
1370
|
-
});
|
|
1371
|
-
else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
|
|
1372
|
-
return null;
|
|
1373
|
-
}
|
|
1374
|
-
throw e;
|
|
1375
|
-
}
|
|
1343
|
+
const session = await openSession(url, openHop, options.signal);
|
|
1344
|
+
if (session == null) return null;
|
|
1345
|
+
let hop;
|
|
1376
1346
|
try {
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1347
|
+
hop = await fetchHop(session, url, options);
|
|
1348
|
+
} finally {
|
|
1349
|
+
await session.close();
|
|
1350
|
+
}
|
|
1351
|
+
if ("result" in hop) return hop.result;
|
|
1352
|
+
redirected++;
|
|
1353
|
+
const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
1354
|
+
if (redirected >= maxRedirection) {
|
|
1355
|
+
logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
const redirectedUrl = new URL(hop.redirect, url);
|
|
1359
|
+
if (redirectedUrl.protocol !== url.protocol) {
|
|
1360
|
+
logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
|
|
1361
|
+
protocol: url.protocol,
|
|
1362
|
+
redirectedProtocol: redirectedUrl.protocol
|
|
1384
1363
|
});
|
|
1385
|
-
|
|
1386
|
-
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
url = redirectedUrl;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Opens a fetch session for a hop. A disallowed URL, a failed DNS lookup,
|
|
1371
|
+
* or an aborted lookup yields `null` instead of an error.
|
|
1372
|
+
*/
|
|
1373
|
+
async function openSession(url, openHop, signal) {
|
|
1374
|
+
try {
|
|
1375
|
+
return await openHop(url.href);
|
|
1376
|
+
} catch (e) {
|
|
1377
|
+
if (e instanceof UrlError) {
|
|
1378
|
+
if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
1387
1379
|
url: url.href,
|
|
1388
|
-
error
|
|
1380
|
+
error: e
|
|
1389
1381
|
});
|
|
1382
|
+
else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
|
|
1390
1383
|
return null;
|
|
1391
1384
|
}
|
|
1392
|
-
if (
|
|
1393
|
-
|
|
1394
|
-
const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
1395
|
-
if (redirected >= maxRedirection) {
|
|
1396
|
-
logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
|
|
1397
|
-
return null;
|
|
1398
|
-
}
|
|
1399
|
-
const redirectedUrl = new URL(response.headers.get("Location"), response.url == null || response.url === "" ? url : response.url);
|
|
1400
|
-
if (redirectedUrl.protocol !== url.protocol) {
|
|
1401
|
-
logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
|
|
1402
|
-
protocol: url.protocol,
|
|
1403
|
-
redirectedProtocol: redirectedUrl.protocol
|
|
1404
|
-
});
|
|
1405
|
-
return null;
|
|
1406
|
-
}
|
|
1407
|
-
url = redirectedUrl;
|
|
1408
|
-
continue;
|
|
1409
|
-
}
|
|
1410
|
-
if (!response.ok) {
|
|
1411
|
-
logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
|
|
1385
|
+
if (signal?.aborted) {
|
|
1386
|
+
logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
|
|
1412
1387
|
url: url.href,
|
|
1413
|
-
|
|
1414
|
-
statusText: response.statusText
|
|
1388
|
+
error: e
|
|
1415
1389
|
});
|
|
1416
1390
|
return null;
|
|
1417
1391
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1392
|
+
throw e;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Fetches a hop with the given session, and consumes the response body or
|
|
1397
|
+
* starts cancelling it before returning. The caller closes the session.
|
|
1398
|
+
*/
|
|
1399
|
+
async function fetchHop(session, url, options) {
|
|
1400
|
+
let response;
|
|
1401
|
+
try {
|
|
1402
|
+
response = await session.fetch(url, {
|
|
1403
|
+
headers: {
|
|
1404
|
+
Accept: "application/jrd+json",
|
|
1405
|
+
"User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent)
|
|
1406
|
+
},
|
|
1407
|
+
redirect: "manual",
|
|
1408
|
+
signal: options.signal
|
|
1409
|
+
});
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
|
|
1412
|
+
url: url.href,
|
|
1413
|
+
error
|
|
1414
|
+
});
|
|
1415
|
+
return { result: null };
|
|
1416
|
+
}
|
|
1417
|
+
if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
|
|
1418
|
+
cancelBody(response, url);
|
|
1419
|
+
return { redirect: response.headers.get("Location") };
|
|
1420
|
+
}
|
|
1421
|
+
if (!response.ok) {
|
|
1422
|
+
logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
|
|
1423
|
+
url: url.href,
|
|
1424
|
+
status: response.status,
|
|
1425
|
+
statusText: response.statusText
|
|
1426
|
+
});
|
|
1427
|
+
cancelBody(response, url);
|
|
1428
|
+
return { result: null };
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
return { result: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)) };
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
if (e instanceof BodyTooLargeError) return { result: null };
|
|
1434
|
+
if (e instanceof SyntaxError) {
|
|
1435
|
+
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
1436
|
+
return { result: null };
|
|
1427
1437
|
}
|
|
1438
|
+
throw e;
|
|
1428
1439
|
}
|
|
1429
1440
|
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Starts cancelling a response body that is not read, without waiting for it
|
|
1443
|
+
* to settle. The cancellation of a branch of a tee (e.g., a body cloned by
|
|
1444
|
+
* a fetch mock) settles only after the other branch is cancelled too, per
|
|
1445
|
+
* the Streams standard, so awaiting it could block forever. A failure is
|
|
1446
|
+
* only logged.
|
|
1447
|
+
*/
|
|
1448
|
+
function cancelBody(response, url) {
|
|
1449
|
+
response.body?.cancel().catch((error) => {
|
|
1450
|
+
logger.debug("Failed to cancel the response body from {url}: {error}", {
|
|
1451
|
+
url: url.href,
|
|
1452
|
+
error
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
//#endregion
|
|
1457
|
+
//#region src/lookup.ts
|
|
1458
|
+
/**
|
|
1459
|
+
* Looks up a WebFinger resource.
|
|
1460
|
+
* @param resource The resource URL to look up.
|
|
1461
|
+
* @param options Extra options for looking up the resource.
|
|
1462
|
+
* @returns The resource descriptor, or `null` if not found.
|
|
1463
|
+
* @since 0.2.0
|
|
1464
|
+
*/
|
|
1465
|
+
async function lookupWebFinger(resource, options = {}) {
|
|
1466
|
+
return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("webfinger.lookup", {
|
|
1467
|
+
kind: SpanKind.CLIENT,
|
|
1468
|
+
attributes: {
|
|
1469
|
+
"webfinger.resource": resource.toString(),
|
|
1470
|
+
"webfinger.resource.scheme": typeof resource === "string" ? resource.replace(/:.*$/, "") : resource.protocol.replace(/:$/, "")
|
|
1471
|
+
}
|
|
1472
|
+
}, async (span) => {
|
|
1473
|
+
try {
|
|
1474
|
+
const result = await lookupWebFingerInternal(resource, options);
|
|
1475
|
+
span.setStatus({ code: result === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
|
|
1476
|
+
return result;
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
span.setStatus({
|
|
1479
|
+
code: SpanStatusCode.ERROR,
|
|
1480
|
+
message: String(error)
|
|
1481
|
+
});
|
|
1482
|
+
throw error;
|
|
1483
|
+
} finally {
|
|
1484
|
+
span.end();
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1430
1488
|
//#endregion
|
|
1431
1489
|
//#region src/lookup.test.ts
|
|
1490
|
+
function lookupDirect(resource, options) {
|
|
1491
|
+
return lookupWebFingerInternal(resource, options, { openHop: openDirectFetch });
|
|
1492
|
+
}
|
|
1493
|
+
function lookupResolving(addresses) {
|
|
1494
|
+
return () => Promise.resolve(addresses);
|
|
1495
|
+
}
|
|
1496
|
+
function openExampleDirectly(url) {
|
|
1497
|
+
return new URL(url).hostname === "example.com" ? openDirectFetch(url) : openPublicFetch(url, { lookup: lookupResolving([{
|
|
1498
|
+
address: "93.184.215.14",
|
|
1499
|
+
family: 4
|
|
1500
|
+
}]) });
|
|
1501
|
+
}
|
|
1432
1502
|
test({
|
|
1433
1503
|
name: "lookupWebFinger()",
|
|
1434
1504
|
sanitizeOps: false,
|
|
1435
1505
|
sanitizeResources: false,
|
|
1436
1506
|
async fn(t) {
|
|
1507
|
+
const responseTimers = [];
|
|
1437
1508
|
await t.step("invalid resource", async () => {
|
|
1438
1509
|
deepStrictEqual(await lookupWebFinger("acct:johndoe"), null);
|
|
1439
1510
|
deepStrictEqual(await lookupWebFinger(new URL("acct:johndoe")), null);
|
|
@@ -1444,11 +1515,32 @@ test({
|
|
|
1444
1515
|
deepStrictEqual(await lookupWebFinger("acct:johndoe@fedify-test.internal"), null);
|
|
1445
1516
|
deepStrictEqual(await lookupWebFinger("https://fedify-test.internal/foo"), null);
|
|
1446
1517
|
});
|
|
1518
|
+
await t.step("fedify-test.internal resolving to a private address", async () => {
|
|
1519
|
+
esm_default.mockGlobal();
|
|
1520
|
+
esm_default.catch(500);
|
|
1521
|
+
try {
|
|
1522
|
+
const hosts = [];
|
|
1523
|
+
const openHop = (url) => openPublicFetch(url, { lookup: (hostname) => {
|
|
1524
|
+
hosts.push(hostname);
|
|
1525
|
+
return Promise.resolve([{
|
|
1526
|
+
address: "10.0.0.1",
|
|
1527
|
+
family: 4
|
|
1528
|
+
}]);
|
|
1529
|
+
} });
|
|
1530
|
+
deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@fedify-test.internal", {}, { openHop }), null);
|
|
1531
|
+
deepStrictEqual(await lookupWebFingerInternal("https://fedify-test.internal/foo", {}, { openHop }), null);
|
|
1532
|
+
deepStrictEqual(hosts, ["fedify-test.internal", "fedify-test.internal"]);
|
|
1533
|
+
deepStrictEqual(esm_default.callHistory.calls().length, 0);
|
|
1534
|
+
} finally {
|
|
1535
|
+
for (const timer of responseTimers) clearTimeout(timer);
|
|
1536
|
+
esm_default.hardReset();
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1447
1539
|
esm_default.spyGlobal();
|
|
1448
1540
|
esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
|
|
1449
1541
|
await t.step("not found", async () => {
|
|
1450
|
-
deepStrictEqual(await
|
|
1451
|
-
deepStrictEqual(await
|
|
1542
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
|
|
1543
|
+
deepStrictEqual(await lookupDirect("https://example.com/foo"), null);
|
|
1452
1544
|
});
|
|
1453
1545
|
const expected = {
|
|
1454
1546
|
subject: "acct:johndoe@example.com",
|
|
@@ -1457,7 +1549,7 @@ test({
|
|
|
1457
1549
|
esm_default.removeRoutes();
|
|
1458
1550
|
esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
|
|
1459
1551
|
await t.step("acct", async () => {
|
|
1460
|
-
deepStrictEqual(await
|
|
1552
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), expected);
|
|
1461
1553
|
});
|
|
1462
1554
|
const expected2 = {
|
|
1463
1555
|
subject: "https://example.com/foo",
|
|
@@ -1466,12 +1558,12 @@ test({
|
|
|
1466
1558
|
esm_default.removeRoutes();
|
|
1467
1559
|
esm_default.get("https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo", { body: expected2 });
|
|
1468
1560
|
await t.step("https", async () => {
|
|
1469
|
-
deepStrictEqual(await
|
|
1561
|
+
deepStrictEqual(await lookupDirect("https://example.com/foo"), expected2);
|
|
1470
1562
|
});
|
|
1471
1563
|
esm_default.removeRoutes();
|
|
1472
1564
|
esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
|
|
1473
1565
|
await t.step("invalid response", async () => {
|
|
1474
|
-
deepStrictEqual(await
|
|
1566
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
|
|
1475
1567
|
});
|
|
1476
1568
|
esm_default.removeRoutes();
|
|
1477
1569
|
esm_default.get("begin:https://localhost/.well-known/webfinger?", {
|
|
@@ -1500,7 +1592,7 @@ test({
|
|
|
1500
1592
|
});
|
|
1501
1593
|
esm_default.get("begin:https://example.com/.well-known/webfinger2", { body: expected });
|
|
1502
1594
|
await t.step("redirection", async () => {
|
|
1503
|
-
deepStrictEqual(await
|
|
1595
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), expected);
|
|
1504
1596
|
});
|
|
1505
1597
|
esm_default.removeRoutes();
|
|
1506
1598
|
esm_default.get("begin:https://example.com/.well-known/webfinger?", {
|
|
@@ -1508,7 +1600,7 @@ test({
|
|
|
1508
1600
|
headers: { Location: "/.well-known/webfinger" }
|
|
1509
1601
|
});
|
|
1510
1602
|
await t.step("infinite redirection", async () => {
|
|
1511
|
-
deepStrictEqual(await withTimeout(() =>
|
|
1603
|
+
deepStrictEqual(await withTimeout(() => lookupDirect("acct:johndoe@example.com"), 2e3), null);
|
|
1512
1604
|
});
|
|
1513
1605
|
esm_default.removeRoutes();
|
|
1514
1606
|
esm_default.get("begin:https://example.com/.well-known/webfinger?", {
|
|
@@ -1516,7 +1608,7 @@ test({
|
|
|
1516
1608
|
headers: { Location: "ftp://example.com/" }
|
|
1517
1609
|
});
|
|
1518
1610
|
await t.step("redirection to different protocol", async () => {
|
|
1519
|
-
deepStrictEqual(await
|
|
1611
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
|
|
1520
1612
|
});
|
|
1521
1613
|
esm_default.removeRoutes();
|
|
1522
1614
|
esm_default.get("begin:https://example.com/.well-known/webfinger?", {
|
|
@@ -1524,7 +1616,7 @@ test({
|
|
|
1524
1616
|
headers: { Location: "https://localhost/" }
|
|
1525
1617
|
});
|
|
1526
1618
|
await t.step("redirection to private address", async () => {
|
|
1527
|
-
deepStrictEqual(await
|
|
1619
|
+
deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: openExampleDirectly }), null);
|
|
1528
1620
|
});
|
|
1529
1621
|
esm_default.removeRoutes();
|
|
1530
1622
|
let redirectCount = 0;
|
|
@@ -1538,22 +1630,22 @@ test({
|
|
|
1538
1630
|
});
|
|
1539
1631
|
await t.step("custom maxRedirection", async () => {
|
|
1540
1632
|
redirectCount = 0;
|
|
1541
|
-
deepStrictEqual(await
|
|
1633
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { maxRedirection: 2 }), null);
|
|
1542
1634
|
redirectCount = 0;
|
|
1543
|
-
deepStrictEqual(await
|
|
1635
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
|
|
1544
1636
|
redirectCount = 0;
|
|
1545
|
-
deepStrictEqual(await
|
|
1637
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), expected);
|
|
1546
1638
|
});
|
|
1547
1639
|
esm_default.removeRoutes();
|
|
1548
1640
|
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
|
|
1549
1641
|
const timeoutId = setTimeout(() => {
|
|
1550
1642
|
resolve({ body: expected });
|
|
1551
1643
|
}, 1e3);
|
|
1552
|
-
|
|
1644
|
+
responseTimers.push(timeoutId);
|
|
1553
1645
|
}));
|
|
1554
1646
|
await t.step("request cancellation", async () => {
|
|
1555
1647
|
const controller = new AbortController();
|
|
1556
|
-
const promise =
|
|
1648
|
+
const promise = lookupDirect("acct:johndoe@example.com", { signal: controller.signal });
|
|
1557
1649
|
controller.abort();
|
|
1558
1650
|
deepStrictEqual(await promise, null);
|
|
1559
1651
|
});
|
|
@@ -1569,12 +1661,12 @@ test({
|
|
|
1569
1661
|
const timeoutId = setTimeout(() => {
|
|
1570
1662
|
resolve({ body: expected });
|
|
1571
1663
|
}, 1e3);
|
|
1572
|
-
|
|
1664
|
+
responseTimers.push(timeoutId);
|
|
1573
1665
|
});
|
|
1574
1666
|
});
|
|
1575
1667
|
await t.step("cancellation during redirection", async () => {
|
|
1576
1668
|
const controller = new AbortController();
|
|
1577
|
-
const promise =
|
|
1669
|
+
const promise = lookupDirect("acct:johndoe@example.com", { signal: controller.signal });
|
|
1578
1670
|
setTimeout(() => controller.abort(), 100);
|
|
1579
1671
|
deepStrictEqual(await promise, null);
|
|
1580
1672
|
});
|
|
@@ -1583,18 +1675,20 @@ test({
|
|
|
1583
1675
|
const timeoutId = setTimeout(() => {
|
|
1584
1676
|
resolve({ body: expected });
|
|
1585
1677
|
}, 500);
|
|
1586
|
-
|
|
1678
|
+
responseTimers.push(timeoutId);
|
|
1587
1679
|
}));
|
|
1588
1680
|
await t.step("cancellation with immediate abort", async () => {
|
|
1589
1681
|
const controller = new AbortController();
|
|
1590
1682
|
controller.abort();
|
|
1683
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { signal: controller.signal }), null);
|
|
1591
1684
|
deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal }), null);
|
|
1592
1685
|
});
|
|
1593
1686
|
esm_default.removeRoutes();
|
|
1594
1687
|
esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: expected });
|
|
1595
1688
|
await t.step("successful request with signal", async () => {
|
|
1596
|
-
deepStrictEqual(await
|
|
1689
|
+
deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
|
|
1597
1690
|
});
|
|
1691
|
+
for (const timer of responseTimers) clearTimeout(timer);
|
|
1598
1692
|
esm_default.hardReset();
|
|
1599
1693
|
}
|
|
1600
1694
|
});
|
|
@@ -1614,17 +1708,15 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
|
|
|
1614
1708
|
}
|
|
1615
1709
|
});
|
|
1616
1710
|
test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
1617
|
-
if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
|
|
1618
1711
|
for (const result of [
|
|
1619
1712
|
"throws",
|
|
1620
1713
|
"empty",
|
|
1621
1714
|
"private"
|
|
1622
1715
|
]) await t.step(result, async () => {
|
|
1623
|
-
const
|
|
1624
|
-
dns.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
|
|
1716
|
+
const lookup = () => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
|
|
1625
1717
|
address: "127.0.0.1",
|
|
1626
1718
|
family: 4
|
|
1627
|
-
}])
|
|
1719
|
+
}]);
|
|
1628
1720
|
const records = [];
|
|
1629
1721
|
await configure({
|
|
1630
1722
|
sinks: { buffer: (record) => records.push(record) },
|
|
@@ -1639,7 +1731,7 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
|
1639
1731
|
reset: true
|
|
1640
1732
|
});
|
|
1641
1733
|
try {
|
|
1642
|
-
deepStrictEqual(await
|
|
1734
|
+
deepStrictEqual(await lookupWebFingerInternal("acct:alice@dns-failure.invalid", {}, { openHop: (url) => openPublicFetch(url, { lookup }) }), null);
|
|
1643
1735
|
const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
|
|
1644
1736
|
deepStrictEqual(failures.map((r) => [r.level, r.rawMessage]), [result === "private" ? ["error", "Invalid URL for WebFinger resource descriptor: {error}"] : ["debug", "DNS lookup failed for {url}"]]);
|
|
1645
1737
|
const { error } = failures[0].properties;
|
|
@@ -1647,9 +1739,249 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
|
1647
1739
|
deepStrictEqual(error.reason, result === "private" ? "disallowed" : "dns");
|
|
1648
1740
|
} finally {
|
|
1649
1741
|
await reset();
|
|
1650
|
-
dns.lookup = originalLookup;
|
|
1651
1742
|
}
|
|
1652
1743
|
});
|
|
1653
1744
|
});
|
|
1745
|
+
function recordHops(respond) {
|
|
1746
|
+
const events = [];
|
|
1747
|
+
const unsettled = [];
|
|
1748
|
+
let opens = 0;
|
|
1749
|
+
let closes = 0;
|
|
1750
|
+
let responses = 0;
|
|
1751
|
+
return {
|
|
1752
|
+
events,
|
|
1753
|
+
async openHop(url) {
|
|
1754
|
+
opens++;
|
|
1755
|
+
events.push(`open ${url}`);
|
|
1756
|
+
const states = [];
|
|
1757
|
+
let closed = false;
|
|
1758
|
+
return {
|
|
1759
|
+
url: new URL(url),
|
|
1760
|
+
async fetch(input) {
|
|
1761
|
+
if (closed) throw new TypeError("The fetch session is closed.");
|
|
1762
|
+
const target = new URL(input instanceof Request ? input.url : input);
|
|
1763
|
+
const id = ++responses;
|
|
1764
|
+
events.push(`fetch ${target.href} #${id}`);
|
|
1765
|
+
const fake = respond(target);
|
|
1766
|
+
const state = {
|
|
1767
|
+
id,
|
|
1768
|
+
settled: false
|
|
1769
|
+
};
|
|
1770
|
+
states.push(state);
|
|
1771
|
+
let sent = false;
|
|
1772
|
+
const body = new ReadableStream({
|
|
1773
|
+
pull(controller) {
|
|
1774
|
+
if (!sent && fake.body != null && fake.body !== "") {
|
|
1775
|
+
sent = true;
|
|
1776
|
+
controller.enqueue(new TextEncoder().encode(fake.body));
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
state.settled = true;
|
|
1780
|
+
events.push(`consumed #${id}`);
|
|
1781
|
+
controller.close();
|
|
1782
|
+
},
|
|
1783
|
+
cancel() {
|
|
1784
|
+
state.settled = true;
|
|
1785
|
+
events.push(`cancel #${id}`);
|
|
1786
|
+
if (fake.cancelFails) throw new Error("Cancellation failed");
|
|
1787
|
+
if (fake.cancelNeverSettles) return new Promise(() => {});
|
|
1788
|
+
}
|
|
1789
|
+
}, { highWaterMark: 0 });
|
|
1790
|
+
const response = new Response(body, {
|
|
1791
|
+
status: fake.status ?? 200,
|
|
1792
|
+
headers: fake.headers
|
|
1793
|
+
});
|
|
1794
|
+
if (fake.url != null) Object.defineProperty(response, "url", { value: fake.url });
|
|
1795
|
+
return response;
|
|
1796
|
+
},
|
|
1797
|
+
async close() {
|
|
1798
|
+
if (closed) return;
|
|
1799
|
+
closed = true;
|
|
1800
|
+
closes++;
|
|
1801
|
+
for (const state of states) if (!state.settled) unsettled.push(`#${state.id}`);
|
|
1802
|
+
events.push(`close ${url}`);
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
},
|
|
1806
|
+
assertInvariants() {
|
|
1807
|
+
deepStrictEqual(closes, opens);
|
|
1808
|
+
deepStrictEqual(unsettled, []);
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
test("lookupWebFinger() closes each hop after cleaning up its body", async (t) => {
|
|
1813
|
+
const descriptor = {
|
|
1814
|
+
subject: "acct:johndoe@example.com",
|
|
1815
|
+
links: []
|
|
1816
|
+
};
|
|
1817
|
+
const first = "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com";
|
|
1818
|
+
const second = "https://example.com/.well-known/webfinger2";
|
|
1819
|
+
await t.step("redirect", async () => {
|
|
1820
|
+
const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
|
|
1821
|
+
status: 302,
|
|
1822
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
1823
|
+
body: "redirecting",
|
|
1824
|
+
url: url.href.replace("example.com", "93.184.215.14")
|
|
1825
|
+
} : { body: JSON.stringify(descriptor) });
|
|
1826
|
+
deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
|
|
1827
|
+
deepStrictEqual(recorder.events, [
|
|
1828
|
+
`open ${first}`,
|
|
1829
|
+
`fetch ${first} #1`,
|
|
1830
|
+
"cancel #1",
|
|
1831
|
+
`close ${first}`,
|
|
1832
|
+
`open ${second}`,
|
|
1833
|
+
`fetch ${second} #2`,
|
|
1834
|
+
"consumed #2",
|
|
1835
|
+
`close ${second}`
|
|
1836
|
+
]);
|
|
1837
|
+
recorder.assertInvariants();
|
|
1838
|
+
});
|
|
1839
|
+
await t.step("non-2xx response", async () => {
|
|
1840
|
+
const recorder = recordHops(() => ({
|
|
1841
|
+
status: 404,
|
|
1842
|
+
body: "not found"
|
|
1843
|
+
}));
|
|
1844
|
+
deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), null);
|
|
1845
|
+
deepStrictEqual(recorder.events, [
|
|
1846
|
+
`open ${first}`,
|
|
1847
|
+
`fetch ${first} #1`,
|
|
1848
|
+
"cancel #1",
|
|
1849
|
+
`close ${first}`
|
|
1850
|
+
]);
|
|
1851
|
+
recorder.assertInvariants();
|
|
1852
|
+
});
|
|
1853
|
+
await t.step("too many redirections", async () => {
|
|
1854
|
+
const recorder = recordHops(() => ({
|
|
1855
|
+
status: 302,
|
|
1856
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
1857
|
+
body: "redirecting"
|
|
1858
|
+
}));
|
|
1859
|
+
deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", { maxRedirection: 2 }, { openHop: recorder.openHop }), null);
|
|
1860
|
+
deepStrictEqual(recorder.events, [
|
|
1861
|
+
`open ${first}`,
|
|
1862
|
+
`fetch ${first} #1`,
|
|
1863
|
+
"cancel #1",
|
|
1864
|
+
`close ${first}`,
|
|
1865
|
+
`open ${second}`,
|
|
1866
|
+
`fetch ${second} #2`,
|
|
1867
|
+
"cancel #2",
|
|
1868
|
+
`close ${second}`
|
|
1869
|
+
]);
|
|
1870
|
+
recorder.assertInvariants();
|
|
1871
|
+
});
|
|
1872
|
+
await t.step("failed redirect body cancellation", async () => {
|
|
1873
|
+
const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
|
|
1874
|
+
status: 302,
|
|
1875
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
1876
|
+
body: "redirecting",
|
|
1877
|
+
cancelFails: true
|
|
1878
|
+
} : { body: JSON.stringify(descriptor) });
|
|
1879
|
+
const records = [];
|
|
1880
|
+
await configure({
|
|
1881
|
+
sinks: { buffer: (record) => records.push(record) },
|
|
1882
|
+
loggers: [{
|
|
1883
|
+
category: "fedify",
|
|
1884
|
+
sinks: ["buffer"],
|
|
1885
|
+
lowestLevel: "debug"
|
|
1886
|
+
}, {
|
|
1887
|
+
category: ["logtape", "meta"],
|
|
1888
|
+
sinks: []
|
|
1889
|
+
}],
|
|
1890
|
+
reset: true
|
|
1891
|
+
});
|
|
1892
|
+
try {
|
|
1893
|
+
deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
|
|
1894
|
+
const failures = records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}");
|
|
1895
|
+
deepStrictEqual(failures.map((r) => [r.level, r.properties.url]), [["debug", first]]);
|
|
1896
|
+
const { error } = failures[0].properties;
|
|
1897
|
+
ok(error instanceof Error);
|
|
1898
|
+
deepStrictEqual(error.message, "Cancellation failed");
|
|
1899
|
+
} finally {
|
|
1900
|
+
await reset();
|
|
1901
|
+
}
|
|
1902
|
+
deepStrictEqual(recorder.events, [
|
|
1903
|
+
`open ${first}`,
|
|
1904
|
+
`fetch ${first} #1`,
|
|
1905
|
+
"cancel #1",
|
|
1906
|
+
`close ${first}`,
|
|
1907
|
+
`open ${second}`,
|
|
1908
|
+
`fetch ${second} #2`,
|
|
1909
|
+
"consumed #2",
|
|
1910
|
+
`close ${second}`
|
|
1911
|
+
]);
|
|
1912
|
+
recorder.assertInvariants();
|
|
1913
|
+
});
|
|
1914
|
+
await t.step("never-settling body cancellation", async () => {
|
|
1915
|
+
const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
|
|
1916
|
+
status: 302,
|
|
1917
|
+
headers: { Location: "/.well-known/webfinger2" },
|
|
1918
|
+
body: "redirecting",
|
|
1919
|
+
cancelNeverSettles: true
|
|
1920
|
+
} : {
|
|
1921
|
+
status: 404,
|
|
1922
|
+
body: "not found",
|
|
1923
|
+
cancelNeverSettles: true
|
|
1924
|
+
});
|
|
1925
|
+
deepStrictEqual(await settleWithin(lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop })), null);
|
|
1926
|
+
deepStrictEqual(recorder.events, [
|
|
1927
|
+
`open ${first}`,
|
|
1928
|
+
`fetch ${first} #1`,
|
|
1929
|
+
"cancel #1",
|
|
1930
|
+
`close ${first}`,
|
|
1931
|
+
`open ${second}`,
|
|
1932
|
+
`fetch ${second} #2`,
|
|
1933
|
+
"cancel #2",
|
|
1934
|
+
`close ${second}`
|
|
1935
|
+
]);
|
|
1936
|
+
recorder.assertInvariants();
|
|
1937
|
+
});
|
|
1938
|
+
});
|
|
1939
|
+
/**
|
|
1940
|
+
* Rejects if the given promise does not settle within the timeout, so that
|
|
1941
|
+
* a hang fails the test instead of blocking it.
|
|
1942
|
+
*/
|
|
1943
|
+
async function settleWithin(promise, ms = 5e3) {
|
|
1944
|
+
let timer;
|
|
1945
|
+
try {
|
|
1946
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
1947
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Did not settle within ${ms} ms.`)), ms);
|
|
1948
|
+
})]);
|
|
1949
|
+
} finally {
|
|
1950
|
+
clearTimeout(timer);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function neverCancellableResponse(init) {
|
|
1954
|
+
return new Response(new ReadableStream({
|
|
1955
|
+
start(controller) {
|
|
1956
|
+
controller.enqueue(new TextEncoder().encode("never cancelled"));
|
|
1957
|
+
},
|
|
1958
|
+
cancel: () => new Promise(() => {})
|
|
1959
|
+
}), init);
|
|
1960
|
+
}
|
|
1961
|
+
test("lookupWebFinger() does not wait for cancelling response bodies", async (t) => {
|
|
1962
|
+
const descriptor = {
|
|
1963
|
+
subject: "acct:johndoe@example.com",
|
|
1964
|
+
links: []
|
|
1965
|
+
};
|
|
1966
|
+
esm_default.mockGlobal();
|
|
1967
|
+
try {
|
|
1968
|
+
await t.step("redirect", async () => {
|
|
1969
|
+
esm_default.removeRoutes();
|
|
1970
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({
|
|
1971
|
+
status: 302,
|
|
1972
|
+
headers: { Location: "/.well-known/webfinger2" }
|
|
1973
|
+
}));
|
|
1974
|
+
esm_default.get("https://example.com/.well-known/webfinger2", () => new Response(JSON.stringify(descriptor), { headers: { "Content-Type": "application/jrd+json" } }));
|
|
1975
|
+
deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), descriptor);
|
|
1976
|
+
});
|
|
1977
|
+
await t.step("non-2xx response", async () => {
|
|
1978
|
+
esm_default.removeRoutes();
|
|
1979
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({ status: 404 }));
|
|
1980
|
+
deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), null);
|
|
1981
|
+
});
|
|
1982
|
+
} finally {
|
|
1983
|
+
esm_default.hardReset();
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1654
1986
|
//#endregion
|
|
1655
1987
|
export {};
|