@fedify/webfinger 2.4.0-pr.936.41 → 2.4.0
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 +158 -12
- package/dist/lookup.test.js +157 -12
- package/dist/mod.cjs +88 -4
- package/dist/mod.d.cts +4 -4
- package/dist/mod.d.ts +4 -4
- package/dist/mod.js +89 -5
- package/package.json +3 -3
- package/src/body.ts +95 -0
- package/src/lookup.test.ts +103 -0
- package/src/lookup.ts +27 -9
package/deno.json
CHANGED
package/dist/lookup.test.cjs
CHANGED
|
@@ -22,10 +22,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
22
22
|
}) : target, mod));
|
|
23
23
|
//#endregion
|
|
24
24
|
let _fedify_fixture = require("@fedify/fixture");
|
|
25
|
-
let es_toolkit = require("es-toolkit");
|
|
26
|
-
let node_assert_strict = require("node:assert/strict");
|
|
27
25
|
let _fedify_vocab_runtime = require("@fedify/vocab-runtime");
|
|
28
26
|
let _logtape_logtape = require("@logtape/logtape");
|
|
27
|
+
let es_toolkit = require("es-toolkit");
|
|
28
|
+
let node_assert_strict = require("node:assert/strict");
|
|
29
|
+
let node_dns_promises = require("node:dns/promises");
|
|
30
|
+
node_dns_promises = __toESM(node_dns_promises, 1);
|
|
29
31
|
let _opentelemetry_api = require("@opentelemetry/api");
|
|
30
32
|
//#endregion
|
|
31
33
|
//#region ../../node_modules/.pnpm/regexparam@3.0.0/node_modules/regexparam/dist/index.mjs
|
|
@@ -132,27 +134,27 @@ function parse(input, loose) {
|
|
|
132
134
|
}
|
|
133
135
|
//#endregion
|
|
134
136
|
//#region ../../node_modules/.pnpm/fetch-mock@12.6.0/node_modules/fetch-mock/dist/esm/TypeDescriptor.js
|
|
135
|
-
const valueTypes = new Set([
|
|
137
|
+
const valueTypes = /* @__PURE__ */ new Set([
|
|
136
138
|
"boolean",
|
|
137
139
|
"number",
|
|
138
140
|
"null",
|
|
139
141
|
"string",
|
|
140
142
|
"undefined"
|
|
141
143
|
]);
|
|
142
|
-
const referenceTypes = new Set([
|
|
144
|
+
const referenceTypes = /* @__PURE__ */ new Set([
|
|
143
145
|
"array",
|
|
144
146
|
"function",
|
|
145
147
|
"object",
|
|
146
148
|
"symbol"
|
|
147
149
|
]);
|
|
148
|
-
const detectableTypes = new Set([
|
|
150
|
+
const detectableTypes = /* @__PURE__ */ new Set([
|
|
149
151
|
"boolean",
|
|
150
152
|
"function",
|
|
151
153
|
"number",
|
|
152
154
|
"string",
|
|
153
155
|
"symbol"
|
|
154
156
|
]);
|
|
155
|
-
const typeConstructors = new Set([
|
|
157
|
+
const typeConstructors = /* @__PURE__ */ new Set([
|
|
156
158
|
Boolean,
|
|
157
159
|
Number,
|
|
158
160
|
String
|
|
@@ -223,7 +225,7 @@ var TypeDescriptor = class TypeDescriptor {
|
|
|
223
225
|
};
|
|
224
226
|
//#endregion
|
|
225
227
|
//#region ../../node_modules/.pnpm/fetch-mock@12.6.0/node_modules/fetch-mock/dist/esm/IsSubsetOf.js
|
|
226
|
-
const allowedTypes = new Set([
|
|
228
|
+
const allowedTypes = /* @__PURE__ */ new Set([
|
|
227
229
|
"array",
|
|
228
230
|
"object",
|
|
229
231
|
"function",
|
|
@@ -715,7 +717,14 @@ var __classPrivateFieldGet = function(receiver, state, kind, f) {
|
|
|
715
717
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
716
718
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
717
719
|
};
|
|
718
|
-
var _Route_instances
|
|
720
|
+
var _Route_instances;
|
|
721
|
+
var _a;
|
|
722
|
+
var _Route_responseSubscriptions;
|
|
723
|
+
var _Route_validate;
|
|
724
|
+
var _Route_sanitize;
|
|
725
|
+
var _Route_generateMatcher;
|
|
726
|
+
var _Route_limit;
|
|
727
|
+
var _Route_delayResponse;
|
|
719
728
|
var RouteConfigWrapper = class {
|
|
720
729
|
constructor(config) {
|
|
721
730
|
Object.assign(this, config);
|
|
@@ -1228,7 +1237,81 @@ var esm_default = new class FetchMock {
|
|
|
1228
1237
|
//#endregion
|
|
1229
1238
|
//#region deno.json
|
|
1230
1239
|
var name = "@fedify/webfinger";
|
|
1231
|
-
var version = "2.4.0
|
|
1240
|
+
var version = "2.4.0";
|
|
1241
|
+
//#endregion
|
|
1242
|
+
//#region src/body.ts
|
|
1243
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
1244
|
+
const MAX_BODY_SIZE = 16 * 1024 * 1024;
|
|
1245
|
+
/** A body exceeded the byte limit. @internal */
|
|
1246
|
+
var BodyTooLargeError = class extends _fedify_vocab_runtime.FetchError {
|
|
1247
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
1248
|
+
constructor(url, maxBytes) {
|
|
1249
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
1250
|
+
this.name = "BodyTooLargeError";
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
1254
|
+
function validateBodySizeLimit(maxBytes) {
|
|
1255
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
1259
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
1260
|
+
* @param message The response or request to read.
|
|
1261
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
1262
|
+
* @param url The URL to include in errors and logs.
|
|
1263
|
+
* @returns The decoded body text.
|
|
1264
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
1265
|
+
* @internal
|
|
1266
|
+
*/
|
|
1267
|
+
async function readBoundedText(message, maxBytes, url) {
|
|
1268
|
+
validateBodySizeLimit(maxBytes);
|
|
1269
|
+
const reader = message.body?.getReader();
|
|
1270
|
+
const tooLarge = () => {
|
|
1271
|
+
(0, _logtape_logtape.getLogger)([
|
|
1272
|
+
"fedify",
|
|
1273
|
+
"runtime",
|
|
1274
|
+
"body"
|
|
1275
|
+
]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
|
|
1276
|
+
url: url.toString(),
|
|
1277
|
+
maxBytes
|
|
1278
|
+
});
|
|
1279
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
1280
|
+
};
|
|
1281
|
+
try {
|
|
1282
|
+
const length = message.headers.get("Content-Length");
|
|
1283
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
1284
|
+
if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
|
|
1285
|
+
if (reader == null) return "";
|
|
1286
|
+
let size = 0;
|
|
1287
|
+
const textReader = new ReadableStream({ async pull(controller) {
|
|
1288
|
+
const { done, value } = await reader.read();
|
|
1289
|
+
if (done) {
|
|
1290
|
+
controller.close();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
size += value.byteLength;
|
|
1294
|
+
if (size > maxBytes) tooLarge();
|
|
1295
|
+
controller.enqueue(value);
|
|
1296
|
+
} }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
|
|
1297
|
+
try {
|
|
1298
|
+
const chunks = [];
|
|
1299
|
+
while (true) {
|
|
1300
|
+
const { done, value } = await textReader.read();
|
|
1301
|
+
if (done) break;
|
|
1302
|
+
chunks.push(value);
|
|
1303
|
+
}
|
|
1304
|
+
return chunks.join("");
|
|
1305
|
+
} finally {
|
|
1306
|
+
textReader.releaseLock();
|
|
1307
|
+
}
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
if (reader != null) reader.cancel(error).catch(() => {});
|
|
1310
|
+
throw error;
|
|
1311
|
+
} finally {
|
|
1312
|
+
reader?.releaseLock();
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1232
1315
|
//#endregion
|
|
1233
1316
|
//#region src/lookup.ts
|
|
1234
1317
|
const logger = (0, _logtape_logtape.getLogger)([
|
|
@@ -1280,7 +1363,7 @@ function getResourceScheme(resource) {
|
|
|
1280
1363
|
}
|
|
1281
1364
|
return resource.protocol.replace(/:$/, "").toLowerCase();
|
|
1282
1365
|
}
|
|
1283
|
-
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = new Set([
|
|
1366
|
+
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
|
|
1284
1367
|
"acct",
|
|
1285
1368
|
"http",
|
|
1286
1369
|
"https",
|
|
@@ -1373,7 +1456,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1373
1456
|
await (0, _fedify_vocab_runtime.validatePublicUrl)(url.href);
|
|
1374
1457
|
} catch (e) {
|
|
1375
1458
|
if (e instanceof _fedify_vocab_runtime.UrlError) {
|
|
1376
|
-
logger.
|
|
1459
|
+
if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
1460
|
+
url: url.href,
|
|
1461
|
+
error: e
|
|
1462
|
+
});
|
|
1463
|
+
else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
|
|
1377
1464
|
return {
|
|
1378
1465
|
resource: null,
|
|
1379
1466
|
result: "network_error",
|
|
@@ -1459,12 +1546,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1459
1546
|
}
|
|
1460
1547
|
try {
|
|
1461
1548
|
return {
|
|
1462
|
-
resource: await response
|
|
1549
|
+
resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
|
|
1463
1550
|
result: "found",
|
|
1464
1551
|
statusCode: response.status,
|
|
1465
1552
|
remoteHost
|
|
1466
1553
|
};
|
|
1467
1554
|
} catch (e) {
|
|
1555
|
+
if (e instanceof BodyTooLargeError) return {
|
|
1556
|
+
resource: null,
|
|
1557
|
+
result: "invalid",
|
|
1558
|
+
statusCode: response.status,
|
|
1559
|
+
remoteHost
|
|
1560
|
+
};
|
|
1468
1561
|
if (e instanceof SyntaxError) {
|
|
1469
1562
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
1470
1563
|
return {
|
|
@@ -1894,4 +1987,57 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1894
1987
|
esm_default.hardReset();
|
|
1895
1988
|
}
|
|
1896
1989
|
});
|
|
1990
|
+
(0, _fedify_fixture.test)("lookupWebFinger() bounds resource descriptors", async () => {
|
|
1991
|
+
esm_default.mockGlobal();
|
|
1992
|
+
let oversized = true;
|
|
1993
|
+
try {
|
|
1994
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
|
|
1995
|
+
"Content-Type": "application/jrd+json",
|
|
1996
|
+
"Content-Length": "1"
|
|
1997
|
+
} }));
|
|
1998
|
+
(0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
|
|
1999
|
+
oversized = false;
|
|
2000
|
+
(0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
|
|
2001
|
+
} finally {
|
|
2002
|
+
esm_default.hardReset();
|
|
2003
|
+
}
|
|
2004
|
+
});
|
|
2005
|
+
(0, _fedify_fixture.test)("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
2006
|
+
if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
|
|
2007
|
+
for (const result of [
|
|
2008
|
+
"throws",
|
|
2009
|
+
"empty",
|
|
2010
|
+
"private"
|
|
2011
|
+
]) await t.step(result, async () => {
|
|
2012
|
+
const originalLookup = node_dns_promises.default.lookup;
|
|
2013
|
+
node_dns_promises.default.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
|
|
2014
|
+
address: "127.0.0.1",
|
|
2015
|
+
family: 4
|
|
2016
|
+
}]));
|
|
2017
|
+
const records = [];
|
|
2018
|
+
await (0, _logtape_logtape.configure)({
|
|
2019
|
+
sinks: { buffer: (record) => records.push(record) },
|
|
2020
|
+
loggers: [{
|
|
2021
|
+
category: "fedify",
|
|
2022
|
+
sinks: ["buffer"],
|
|
2023
|
+
lowestLevel: "debug"
|
|
2024
|
+
}, {
|
|
2025
|
+
category: ["logtape", "meta"],
|
|
2026
|
+
sinks: []
|
|
2027
|
+
}],
|
|
2028
|
+
reset: true
|
|
2029
|
+
});
|
|
2030
|
+
try {
|
|
2031
|
+
(0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@dns-failure.invalid"), null);
|
|
2032
|
+
const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
|
|
2033
|
+
(0, node_assert_strict.deepStrictEqual)(failures.map((r) => [r.level, r.rawMessage]), [result === "private" ? ["error", "Invalid URL for WebFinger resource descriptor: {error}"] : ["debug", "DNS lookup failed for {url}"]]);
|
|
2034
|
+
const { error } = failures[0].properties;
|
|
2035
|
+
(0, node_assert_strict.ok)(error instanceof _fedify_vocab_runtime.UrlError);
|
|
2036
|
+
(0, node_assert_strict.deepStrictEqual)(error.reason, result === "private" ? "disallowed" : "dns");
|
|
2037
|
+
} finally {
|
|
2038
|
+
await (0, _logtape_logtape.reset)();
|
|
2039
|
+
node_dns_promises.default.lookup = originalLookup;
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
1897
2043
|
//#endregion
|
package/dist/lookup.test.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createTestMeterProvider, test } from "@fedify/fixture";
|
|
2
|
+
import { FetchError, UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
|
|
3
|
+
import { configure, getLogger, reset } from "@logtape/logtape";
|
|
2
4
|
import { withTimeout } from "es-toolkit";
|
|
3
5
|
import { deepStrictEqual, ok } from "node:assert/strict";
|
|
4
|
-
import
|
|
5
|
-
import { getLogger } from "@logtape/logtape";
|
|
6
|
+
import dns from "node:dns/promises";
|
|
6
7
|
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
7
8
|
//#region \0rolldown/runtime.js
|
|
8
9
|
var __create = Object.create;
|
|
@@ -131,27 +132,27 @@ function parse(input, loose) {
|
|
|
131
132
|
}
|
|
132
133
|
//#endregion
|
|
133
134
|
//#region ../../node_modules/.pnpm/fetch-mock@12.6.0/node_modules/fetch-mock/dist/esm/TypeDescriptor.js
|
|
134
|
-
const valueTypes = new Set([
|
|
135
|
+
const valueTypes = /* @__PURE__ */ new Set([
|
|
135
136
|
"boolean",
|
|
136
137
|
"number",
|
|
137
138
|
"null",
|
|
138
139
|
"string",
|
|
139
140
|
"undefined"
|
|
140
141
|
]);
|
|
141
|
-
const referenceTypes = new Set([
|
|
142
|
+
const referenceTypes = /* @__PURE__ */ new Set([
|
|
142
143
|
"array",
|
|
143
144
|
"function",
|
|
144
145
|
"object",
|
|
145
146
|
"symbol"
|
|
146
147
|
]);
|
|
147
|
-
const detectableTypes = new Set([
|
|
148
|
+
const detectableTypes = /* @__PURE__ */ new Set([
|
|
148
149
|
"boolean",
|
|
149
150
|
"function",
|
|
150
151
|
"number",
|
|
151
152
|
"string",
|
|
152
153
|
"symbol"
|
|
153
154
|
]);
|
|
154
|
-
const typeConstructors = new Set([
|
|
155
|
+
const typeConstructors = /* @__PURE__ */ new Set([
|
|
155
156
|
Boolean,
|
|
156
157
|
Number,
|
|
157
158
|
String
|
|
@@ -222,7 +223,7 @@ var TypeDescriptor = class TypeDescriptor {
|
|
|
222
223
|
};
|
|
223
224
|
//#endregion
|
|
224
225
|
//#region ../../node_modules/.pnpm/fetch-mock@12.6.0/node_modules/fetch-mock/dist/esm/IsSubsetOf.js
|
|
225
|
-
const allowedTypes = new Set([
|
|
226
|
+
const allowedTypes = /* @__PURE__ */ new Set([
|
|
226
227
|
"array",
|
|
227
228
|
"object",
|
|
228
229
|
"function",
|
|
@@ -714,7 +715,14 @@ var __classPrivateFieldGet = function(receiver, state, kind, f) {
|
|
|
714
715
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
715
716
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
716
717
|
};
|
|
717
|
-
var _Route_instances
|
|
718
|
+
var _Route_instances;
|
|
719
|
+
var _a;
|
|
720
|
+
var _Route_responseSubscriptions;
|
|
721
|
+
var _Route_validate;
|
|
722
|
+
var _Route_sanitize;
|
|
723
|
+
var _Route_generateMatcher;
|
|
724
|
+
var _Route_limit;
|
|
725
|
+
var _Route_delayResponse;
|
|
718
726
|
var RouteConfigWrapper = class {
|
|
719
727
|
constructor(config) {
|
|
720
728
|
Object.assign(this, config);
|
|
@@ -1227,7 +1235,81 @@ var esm_default = new class FetchMock {
|
|
|
1227
1235
|
//#endregion
|
|
1228
1236
|
//#region deno.json
|
|
1229
1237
|
var name = "@fedify/webfinger";
|
|
1230
|
-
var version = "2.4.0
|
|
1238
|
+
var version = "2.4.0";
|
|
1239
|
+
//#endregion
|
|
1240
|
+
//#region src/body.ts
|
|
1241
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
1242
|
+
const MAX_BODY_SIZE = 16 * 1024 * 1024;
|
|
1243
|
+
/** A body exceeded the byte limit. @internal */
|
|
1244
|
+
var BodyTooLargeError = class extends FetchError {
|
|
1245
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
1246
|
+
constructor(url, maxBytes) {
|
|
1247
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
1248
|
+
this.name = "BodyTooLargeError";
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
1252
|
+
function validateBodySizeLimit(maxBytes) {
|
|
1253
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
1257
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
1258
|
+
* @param message The response or request to read.
|
|
1259
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
1260
|
+
* @param url The URL to include in errors and logs.
|
|
1261
|
+
* @returns The decoded body text.
|
|
1262
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
1263
|
+
* @internal
|
|
1264
|
+
*/
|
|
1265
|
+
async function readBoundedText(message, maxBytes, url) {
|
|
1266
|
+
validateBodySizeLimit(maxBytes);
|
|
1267
|
+
const reader = message.body?.getReader();
|
|
1268
|
+
const tooLarge = () => {
|
|
1269
|
+
getLogger([
|
|
1270
|
+
"fedify",
|
|
1271
|
+
"runtime",
|
|
1272
|
+
"body"
|
|
1273
|
+
]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
|
|
1274
|
+
url: url.toString(),
|
|
1275
|
+
maxBytes
|
|
1276
|
+
});
|
|
1277
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
1278
|
+
};
|
|
1279
|
+
try {
|
|
1280
|
+
const length = message.headers.get("Content-Length");
|
|
1281
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
1282
|
+
if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
|
|
1283
|
+
if (reader == null) return "";
|
|
1284
|
+
let size = 0;
|
|
1285
|
+
const textReader = new ReadableStream({ async pull(controller) {
|
|
1286
|
+
const { done, value } = await reader.read();
|
|
1287
|
+
if (done) {
|
|
1288
|
+
controller.close();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
size += value.byteLength;
|
|
1292
|
+
if (size > maxBytes) tooLarge();
|
|
1293
|
+
controller.enqueue(value);
|
|
1294
|
+
} }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
|
|
1295
|
+
try {
|
|
1296
|
+
const chunks = [];
|
|
1297
|
+
while (true) {
|
|
1298
|
+
const { done, value } = await textReader.read();
|
|
1299
|
+
if (done) break;
|
|
1300
|
+
chunks.push(value);
|
|
1301
|
+
}
|
|
1302
|
+
return chunks.join("");
|
|
1303
|
+
} finally {
|
|
1304
|
+
textReader.releaseLock();
|
|
1305
|
+
}
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
if (reader != null) reader.cancel(error).catch(() => {});
|
|
1308
|
+
throw error;
|
|
1309
|
+
} finally {
|
|
1310
|
+
reader?.releaseLock();
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1231
1313
|
//#endregion
|
|
1232
1314
|
//#region src/lookup.ts
|
|
1233
1315
|
const logger = getLogger([
|
|
@@ -1279,7 +1361,7 @@ function getResourceScheme(resource) {
|
|
|
1279
1361
|
}
|
|
1280
1362
|
return resource.protocol.replace(/:$/, "").toLowerCase();
|
|
1281
1363
|
}
|
|
1282
|
-
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = new Set([
|
|
1364
|
+
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
|
|
1283
1365
|
"acct",
|
|
1284
1366
|
"http",
|
|
1285
1367
|
"https",
|
|
@@ -1372,7 +1454,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1372
1454
|
await validatePublicUrl(url.href);
|
|
1373
1455
|
} catch (e) {
|
|
1374
1456
|
if (e instanceof UrlError) {
|
|
1375
|
-
logger.
|
|
1457
|
+
if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
1458
|
+
url: url.href,
|
|
1459
|
+
error: e
|
|
1460
|
+
});
|
|
1461
|
+
else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
|
|
1376
1462
|
return {
|
|
1377
1463
|
resource: null,
|
|
1378
1464
|
result: "network_error",
|
|
@@ -1458,12 +1544,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1458
1544
|
}
|
|
1459
1545
|
try {
|
|
1460
1546
|
return {
|
|
1461
|
-
resource: await response
|
|
1547
|
+
resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
|
|
1462
1548
|
result: "found",
|
|
1463
1549
|
statusCode: response.status,
|
|
1464
1550
|
remoteHost
|
|
1465
1551
|
};
|
|
1466
1552
|
} catch (e) {
|
|
1553
|
+
if (e instanceof BodyTooLargeError) return {
|
|
1554
|
+
resource: null,
|
|
1555
|
+
result: "invalid",
|
|
1556
|
+
statusCode: response.status,
|
|
1557
|
+
remoteHost
|
|
1558
|
+
};
|
|
1467
1559
|
if (e instanceof SyntaxError) {
|
|
1468
1560
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
1469
1561
|
return {
|
|
@@ -1893,5 +1985,58 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
|
|
|
1893
1985
|
esm_default.hardReset();
|
|
1894
1986
|
}
|
|
1895
1987
|
});
|
|
1988
|
+
test("lookupWebFinger() bounds resource descriptors", async () => {
|
|
1989
|
+
esm_default.mockGlobal();
|
|
1990
|
+
let oversized = true;
|
|
1991
|
+
try {
|
|
1992
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
|
|
1993
|
+
"Content-Type": "application/jrd+json",
|
|
1994
|
+
"Content-Length": "1"
|
|
1995
|
+
} }));
|
|
1996
|
+
deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
|
|
1997
|
+
oversized = false;
|
|
1998
|
+
deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
|
|
1999
|
+
} finally {
|
|
2000
|
+
esm_default.hardReset();
|
|
2001
|
+
}
|
|
2002
|
+
});
|
|
2003
|
+
test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
2004
|
+
if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
|
|
2005
|
+
for (const result of [
|
|
2006
|
+
"throws",
|
|
2007
|
+
"empty",
|
|
2008
|
+
"private"
|
|
2009
|
+
]) await t.step(result, async () => {
|
|
2010
|
+
const originalLookup = dns.lookup;
|
|
2011
|
+
dns.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
|
|
2012
|
+
address: "127.0.0.1",
|
|
2013
|
+
family: 4
|
|
2014
|
+
}]));
|
|
2015
|
+
const records = [];
|
|
2016
|
+
await configure({
|
|
2017
|
+
sinks: { buffer: (record) => records.push(record) },
|
|
2018
|
+
loggers: [{
|
|
2019
|
+
category: "fedify",
|
|
2020
|
+
sinks: ["buffer"],
|
|
2021
|
+
lowestLevel: "debug"
|
|
2022
|
+
}, {
|
|
2023
|
+
category: ["logtape", "meta"],
|
|
2024
|
+
sinks: []
|
|
2025
|
+
}],
|
|
2026
|
+
reset: true
|
|
2027
|
+
});
|
|
2028
|
+
try {
|
|
2029
|
+
deepStrictEqual(await lookupWebFinger("acct:alice@dns-failure.invalid"), null);
|
|
2030
|
+
const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
|
|
2031
|
+
deepStrictEqual(failures.map((r) => [r.level, r.rawMessage]), [result === "private" ? ["error", "Invalid URL for WebFinger resource descriptor: {error}"] : ["debug", "DNS lookup failed for {url}"]]);
|
|
2032
|
+
const { error } = failures[0].properties;
|
|
2033
|
+
ok(error instanceof UrlError);
|
|
2034
|
+
deepStrictEqual(error.reason, result === "private" ? "disallowed" : "dns");
|
|
2035
|
+
} finally {
|
|
2036
|
+
await reset();
|
|
2037
|
+
dns.lookup = originalLookup;
|
|
2038
|
+
}
|
|
2039
|
+
});
|
|
2040
|
+
});
|
|
1896
2041
|
//#endregion
|
|
1897
2042
|
export {};
|
package/dist/mod.cjs
CHANGED
|
@@ -4,7 +4,81 @@ let _logtape_logtape = require("@logtape/logtape");
|
|
|
4
4
|
let _opentelemetry_api = require("@opentelemetry/api");
|
|
5
5
|
//#region deno.json
|
|
6
6
|
var name = "@fedify/webfinger";
|
|
7
|
-
var version = "2.4.0
|
|
7
|
+
var version = "2.4.0";
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/body.ts
|
|
10
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
11
|
+
const MAX_BODY_SIZE = 16 * 1024 * 1024;
|
|
12
|
+
/** A body exceeded the byte limit. @internal */
|
|
13
|
+
var BodyTooLargeError = class extends _fedify_vocab_runtime.FetchError {
|
|
14
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
15
|
+
constructor(url, maxBytes) {
|
|
16
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
17
|
+
this.name = "BodyTooLargeError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
21
|
+
function validateBodySizeLimit(maxBytes) {
|
|
22
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
26
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
27
|
+
* @param message The response or request to read.
|
|
28
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
29
|
+
* @param url The URL to include in errors and logs.
|
|
30
|
+
* @returns The decoded body text.
|
|
31
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
async function readBoundedText(message, maxBytes, url) {
|
|
35
|
+
validateBodySizeLimit(maxBytes);
|
|
36
|
+
const reader = message.body?.getReader();
|
|
37
|
+
const tooLarge = () => {
|
|
38
|
+
(0, _logtape_logtape.getLogger)([
|
|
39
|
+
"fedify",
|
|
40
|
+
"runtime",
|
|
41
|
+
"body"
|
|
42
|
+
]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
|
|
43
|
+
url: url.toString(),
|
|
44
|
+
maxBytes
|
|
45
|
+
});
|
|
46
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
const length = message.headers.get("Content-Length");
|
|
50
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
51
|
+
if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
|
|
52
|
+
if (reader == null) return "";
|
|
53
|
+
let size = 0;
|
|
54
|
+
const textReader = new ReadableStream({ async pull(controller) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) {
|
|
57
|
+
controller.close();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
size += value.byteLength;
|
|
61
|
+
if (size > maxBytes) tooLarge();
|
|
62
|
+
controller.enqueue(value);
|
|
63
|
+
} }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
|
|
64
|
+
try {
|
|
65
|
+
const chunks = [];
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await textReader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
chunks.push(value);
|
|
70
|
+
}
|
|
71
|
+
return chunks.join("");
|
|
72
|
+
} finally {
|
|
73
|
+
textReader.releaseLock();
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (reader != null) reader.cancel(error).catch(() => {});
|
|
77
|
+
throw error;
|
|
78
|
+
} finally {
|
|
79
|
+
reader?.releaseLock();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
8
82
|
//#endregion
|
|
9
83
|
//#region src/lookup.ts
|
|
10
84
|
const logger = (0, _logtape_logtape.getLogger)([
|
|
@@ -56,7 +130,7 @@ function getResourceScheme(resource) {
|
|
|
56
130
|
}
|
|
57
131
|
return resource.protocol.replace(/:$/, "").toLowerCase();
|
|
58
132
|
}
|
|
59
|
-
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = new Set([
|
|
133
|
+
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
|
|
60
134
|
"acct",
|
|
61
135
|
"http",
|
|
62
136
|
"https",
|
|
@@ -149,7 +223,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
149
223
|
await (0, _fedify_vocab_runtime.validatePublicUrl)(url.href);
|
|
150
224
|
} catch (e) {
|
|
151
225
|
if (e instanceof _fedify_vocab_runtime.UrlError) {
|
|
152
|
-
logger.
|
|
226
|
+
if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
227
|
+
url: url.href,
|
|
228
|
+
error: e
|
|
229
|
+
});
|
|
230
|
+
else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
|
|
153
231
|
return {
|
|
154
232
|
resource: null,
|
|
155
233
|
result: "network_error",
|
|
@@ -235,12 +313,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
235
313
|
}
|
|
236
314
|
try {
|
|
237
315
|
return {
|
|
238
|
-
resource: await response
|
|
316
|
+
resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
|
|
239
317
|
result: "found",
|
|
240
318
|
statusCode: response.status,
|
|
241
319
|
remoteHost
|
|
242
320
|
};
|
|
243
321
|
} catch (e) {
|
|
322
|
+
if (e instanceof BodyTooLargeError) return {
|
|
323
|
+
resource: null,
|
|
324
|
+
result: "invalid",
|
|
325
|
+
statusCode: response.status,
|
|
326
|
+
remoteHost
|
|
327
|
+
};
|
|
244
328
|
if (e instanceof SyntaxError) {
|
|
245
329
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
246
330
|
return {
|
package/dist/mod.d.cts
CHANGED
|
@@ -71,10 +71,10 @@ interface Link {
|
|
|
71
71
|
* - `not_found`: the remote responded with HTTP `404 Not Found` or
|
|
72
72
|
* `410 Gone`. Recorded together with `http.response.status_code`.
|
|
73
73
|
* - `invalid`: the remote responded with content Fedify could not parse
|
|
74
|
-
* into a {@link ResourceDescriptor} (JSON parse failure
|
|
75
|
-
* redirect chain exceeded `maxRedirection`, the
|
|
76
|
-
* a different protocol, or the queried `acct:`
|
|
77
|
-
* malformed.
|
|
74
|
+
* into a {@link ResourceDescriptor} (JSON parse failure or a body over
|
|
75
|
+
* the size limit), the redirect chain exceeded `maxRedirection`, the
|
|
76
|
+
* remote redirected to a different protocol, or the queried `acct:`
|
|
77
|
+
* resource itself was malformed.
|
|
78
78
|
* - `network_error`: no HTTP response was received (the URL was
|
|
79
79
|
* rejected as a private address, `fetch()` threw, or an `AbortError`
|
|
80
80
|
* cancelled the request).
|
package/dist/mod.d.ts
CHANGED
|
@@ -71,10 +71,10 @@ interface Link {
|
|
|
71
71
|
* - `not_found`: the remote responded with HTTP `404 Not Found` or
|
|
72
72
|
* `410 Gone`. Recorded together with `http.response.status_code`.
|
|
73
73
|
* - `invalid`: the remote responded with content Fedify could not parse
|
|
74
|
-
* into a {@link ResourceDescriptor} (JSON parse failure
|
|
75
|
-
* redirect chain exceeded `maxRedirection`, the
|
|
76
|
-
* a different protocol, or the queried `acct:`
|
|
77
|
-
* malformed.
|
|
74
|
+
* into a {@link ResourceDescriptor} (JSON parse failure or a body over
|
|
75
|
+
* the size limit), the redirect chain exceeded `maxRedirection`, the
|
|
76
|
+
* remote redirected to a different protocol, or the queried `acct:`
|
|
77
|
+
* resource itself was malformed.
|
|
78
78
|
* - `network_error`: no HTTP response was received (the URL was
|
|
79
79
|
* rejected as a private address, `fetch()` threw, or an `AbortError`
|
|
80
80
|
* cancelled the request).
|
package/dist/mod.js
CHANGED
|
@@ -1,9 +1,83 @@
|
|
|
1
|
-
import { UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
|
|
1
|
+
import { FetchError, UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
|
|
2
2
|
import { getLogger } from "@logtape/logtape";
|
|
3
3
|
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
4
4
|
//#region deno.json
|
|
5
5
|
var name = "@fedify/webfinger";
|
|
6
|
-
var version = "2.4.0
|
|
6
|
+
var version = "2.4.0";
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/body.ts
|
|
9
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
10
|
+
const MAX_BODY_SIZE = 16 * 1024 * 1024;
|
|
11
|
+
/** A body exceeded the byte limit. @internal */
|
|
12
|
+
var BodyTooLargeError = class extends FetchError {
|
|
13
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
14
|
+
constructor(url, maxBytes) {
|
|
15
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
16
|
+
this.name = "BodyTooLargeError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
20
|
+
function validateBodySizeLimit(maxBytes) {
|
|
21
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
25
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
26
|
+
* @param message The response or request to read.
|
|
27
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
28
|
+
* @param url The URL to include in errors and logs.
|
|
29
|
+
* @returns The decoded body text.
|
|
30
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
async function readBoundedText(message, maxBytes, url) {
|
|
34
|
+
validateBodySizeLimit(maxBytes);
|
|
35
|
+
const reader = message.body?.getReader();
|
|
36
|
+
const tooLarge = () => {
|
|
37
|
+
getLogger([
|
|
38
|
+
"fedify",
|
|
39
|
+
"runtime",
|
|
40
|
+
"body"
|
|
41
|
+
]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
|
|
42
|
+
url: url.toString(),
|
|
43
|
+
maxBytes
|
|
44
|
+
});
|
|
45
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
const length = message.headers.get("Content-Length");
|
|
49
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
50
|
+
if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
|
|
51
|
+
if (reader == null) return "";
|
|
52
|
+
let size = 0;
|
|
53
|
+
const textReader = new ReadableStream({ async pull(controller) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done) {
|
|
56
|
+
controller.close();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
size += value.byteLength;
|
|
60
|
+
if (size > maxBytes) tooLarge();
|
|
61
|
+
controller.enqueue(value);
|
|
62
|
+
} }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
|
|
63
|
+
try {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
while (true) {
|
|
66
|
+
const { done, value } = await textReader.read();
|
|
67
|
+
if (done) break;
|
|
68
|
+
chunks.push(value);
|
|
69
|
+
}
|
|
70
|
+
return chunks.join("");
|
|
71
|
+
} finally {
|
|
72
|
+
textReader.releaseLock();
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (reader != null) reader.cancel(error).catch(() => {});
|
|
76
|
+
throw error;
|
|
77
|
+
} finally {
|
|
78
|
+
reader?.releaseLock();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
7
81
|
//#endregion
|
|
8
82
|
//#region src/lookup.ts
|
|
9
83
|
const logger = getLogger([
|
|
@@ -55,7 +129,7 @@ function getResourceScheme(resource) {
|
|
|
55
129
|
}
|
|
56
130
|
return resource.protocol.replace(/:$/, "").toLowerCase();
|
|
57
131
|
}
|
|
58
|
-
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = new Set([
|
|
132
|
+
const WEBFINGER_LOOKUP_SCHEME_WHITELIST = /* @__PURE__ */ new Set([
|
|
59
133
|
"acct",
|
|
60
134
|
"http",
|
|
61
135
|
"https",
|
|
@@ -148,7 +222,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
148
222
|
await validatePublicUrl(url.href);
|
|
149
223
|
} catch (e) {
|
|
150
224
|
if (e instanceof UrlError) {
|
|
151
|
-
logger.
|
|
225
|
+
if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
|
|
226
|
+
url: url.href,
|
|
227
|
+
error: e
|
|
228
|
+
});
|
|
229
|
+
else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
|
|
152
230
|
return {
|
|
153
231
|
resource: null,
|
|
154
232
|
result: "network_error",
|
|
@@ -234,12 +312,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
234
312
|
}
|
|
235
313
|
try {
|
|
236
314
|
return {
|
|
237
|
-
resource: await response
|
|
315
|
+
resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
|
|
238
316
|
result: "found",
|
|
239
317
|
statusCode: response.status,
|
|
240
318
|
remoteHost
|
|
241
319
|
};
|
|
242
320
|
} catch (e) {
|
|
321
|
+
if (e instanceof BodyTooLargeError) return {
|
|
322
|
+
resource: null,
|
|
323
|
+
result: "invalid",
|
|
324
|
+
statusCode: response.status,
|
|
325
|
+
remoteHost
|
|
326
|
+
};
|
|
243
327
|
if (e instanceof SyntaxError) {
|
|
244
328
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
245
329
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/webfinger",
|
|
3
|
-
"version": "2.4.0
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"homepage": "https://fedify.dev/",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@types/node": "^24.2
|
|
50
|
+
"@types/node": "^24.12.2",
|
|
51
51
|
"fetch-mock": "^12.5.4",
|
|
52
52
|
"tsdown": "^0.22.0",
|
|
53
53
|
"typescript": "^6.0.0",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@logtape/logtape": "^2.2.0",
|
|
58
58
|
"@opentelemetry/api": "^1.9.1",
|
|
59
59
|
"es-toolkit": "1.46.1",
|
|
60
|
-
"@fedify/vocab-runtime": "2.4.0
|
|
60
|
+
"@fedify/vocab-runtime": "2.4.0"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build:self": "tsdown",
|
package/src/body.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Kept private in each package to avoid adding public API in a patch release.
|
|
2
|
+
// TODO(Jiwon Kwon): Consolidate the duplicated body helpers in @fedify/vocab-runtime,
|
|
3
|
+
// @fedify/webfinger, and @fedify/fedify into a shared implementation.
|
|
4
|
+
import { FetchError } from "@fedify/vocab-runtime";
|
|
5
|
+
import { getLogger } from "@logtape/logtape";
|
|
6
|
+
|
|
7
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
8
|
+
export const MAX_BODY_SIZE: number = 16 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
/** A body exceeded the byte limit. @internal */
|
|
11
|
+
export class BodyTooLargeError extends FetchError {
|
|
12
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
13
|
+
constructor(url: string | URL, maxBytes: number) {
|
|
14
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
15
|
+
this.name = "BodyTooLargeError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
20
|
+
function validateBodySizeLimit(maxBytes: number): void {
|
|
21
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
22
|
+
throw new RangeError(
|
|
23
|
+
"The body size limit must be a positive safe integer.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
30
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
31
|
+
* @param message The response or request to read.
|
|
32
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
33
|
+
* @param url The URL to include in errors and logs.
|
|
34
|
+
* @returns The decoded body text.
|
|
35
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
export async function readBoundedText(
|
|
39
|
+
message: Pick<Request, "body" | "headers">,
|
|
40
|
+
maxBytes: number,
|
|
41
|
+
url: string | URL,
|
|
42
|
+
): Promise<string> {
|
|
43
|
+
validateBodySizeLimit(maxBytes);
|
|
44
|
+
const reader = message.body?.getReader();
|
|
45
|
+
const tooLarge = (): never => {
|
|
46
|
+
getLogger(["fedify", "runtime", "body"]).warn(
|
|
47
|
+
"Body from {url} exceeds the limit of {maxBytes} bytes.",
|
|
48
|
+
{ url: url.toString(), maxBytes },
|
|
49
|
+
);
|
|
50
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
// Content-Length describes the encoded body when compression is used.
|
|
54
|
+
// Only use it as an early rejection for an unencoded body.
|
|
55
|
+
const length = message.headers.get("Content-Length");
|
|
56
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
57
|
+
if (
|
|
58
|
+
(encoding == null || encoding.toLowerCase() === "identity") &&
|
|
59
|
+
length != null && /^\d+$/.test(length) && Number(length) > maxBytes
|
|
60
|
+
) tooLarge();
|
|
61
|
+
if (reader == null) return "";
|
|
62
|
+
let size = 0;
|
|
63
|
+
const bounded = new ReadableStream<BufferSource>({
|
|
64
|
+
async pull(controller) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done) {
|
|
67
|
+
controller.close();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
size += value.byteLength;
|
|
71
|
+
if (size > maxBytes) tooLarge();
|
|
72
|
+
controller.enqueue(value);
|
|
73
|
+
},
|
|
74
|
+
}, { highWaterMark: 0 });
|
|
75
|
+
const textReader = bounded.pipeThrough(new TextDecoderStream()).getReader();
|
|
76
|
+
try {
|
|
77
|
+
const chunks: string[] = [];
|
|
78
|
+
while (true) {
|
|
79
|
+
const { done, value } = await textReader.read();
|
|
80
|
+
if (done) break;
|
|
81
|
+
chunks.push(value);
|
|
82
|
+
}
|
|
83
|
+
return chunks.join("");
|
|
84
|
+
} finally {
|
|
85
|
+
textReader.releaseLock();
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// A cloned inbox request is a tee. Awaiting one branch's cancellation
|
|
89
|
+
// would deadlock until the other branch is canceled by the inbox handler.
|
|
90
|
+
if (reader != null) void reader.cancel(error).catch(() => {});
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
reader?.releaseLock();
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/lookup.test.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createTestMeterProvider, test } from "@fedify/fixture";
|
|
2
|
+
import { UrlError } from "@fedify/vocab-runtime";
|
|
3
|
+
import { configure, type LogRecord, reset } from "@logtape/logtape";
|
|
2
4
|
import { withTimeout } from "es-toolkit";
|
|
3
5
|
import fetchMock from "fetch-mock";
|
|
4
6
|
import { deepStrictEqual, ok } from "node:assert/strict";
|
|
7
|
+
import dns from "node:dns/promises";
|
|
5
8
|
import type { ResourceDescriptor } from "./jrd.ts";
|
|
6
9
|
import { lookupWebFinger } from "./lookup.ts";
|
|
7
10
|
|
|
@@ -886,3 +889,103 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
|
|
|
886
889
|
});
|
|
887
890
|
|
|
888
891
|
// cSpell: ignore johndoe
|
|
892
|
+
|
|
893
|
+
test("lookupWebFinger() bounds resource descriptors", async () => {
|
|
894
|
+
fetchMock.mockGlobal();
|
|
895
|
+
let oversized = true;
|
|
896
|
+
try {
|
|
897
|
+
fetchMock.get(
|
|
898
|
+
"begin:https://example.com/.well-known/webfinger?",
|
|
899
|
+
() =>
|
|
900
|
+
new Response(
|
|
901
|
+
'{"subject":"acct:alice@example.com"}' +
|
|
902
|
+
(oversized ? " ".repeat(16 * 1024 * 1024) : ""),
|
|
903
|
+
{
|
|
904
|
+
headers: {
|
|
905
|
+
"Content-Type": "application/jrd+json",
|
|
906
|
+
"Content-Length": "1",
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
),
|
|
910
|
+
);
|
|
911
|
+
deepStrictEqual(
|
|
912
|
+
await lookupWebFinger("acct:alice@example.com", {
|
|
913
|
+
allowPrivateAddress: true,
|
|
914
|
+
}),
|
|
915
|
+
null,
|
|
916
|
+
);
|
|
917
|
+
oversized = false;
|
|
918
|
+
deepStrictEqual(
|
|
919
|
+
await lookupWebFinger("acct:alice@example.com", {
|
|
920
|
+
allowPrivateAddress: true,
|
|
921
|
+
}),
|
|
922
|
+
{ subject: "acct:alice@example.com" },
|
|
923
|
+
);
|
|
924
|
+
} finally {
|
|
925
|
+
fetchMock.hardReset();
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("lookupWebFinger() logs DNS failures as such", async (t) => {
|
|
930
|
+
// The validator skips DNS when Deno has no network permission. Checked
|
|
931
|
+
// here rather than with top-level await, which the CommonJS build rejects.
|
|
932
|
+
if (
|
|
933
|
+
"Deno" in globalThis &&
|
|
934
|
+
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
935
|
+
) return;
|
|
936
|
+
for (const result of ["throws", "empty", "private"] as const) {
|
|
937
|
+
await t.step(result, async () => {
|
|
938
|
+
// Stubbing works only because vocab-runtime's url.ts uses the default
|
|
939
|
+
// node:dns/promises import; see the FIXME there.
|
|
940
|
+
const originalLookup = dns.lookup;
|
|
941
|
+
dns.lookup = (() =>
|
|
942
|
+
result === "throws"
|
|
943
|
+
? Promise.reject(new Error("Resolver unavailable"))
|
|
944
|
+
: Promise.resolve(
|
|
945
|
+
result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
|
|
946
|
+
)) as typeof dns.lookup;
|
|
947
|
+
const records: LogRecord[] = [];
|
|
948
|
+
await configure({
|
|
949
|
+
sinks: {
|
|
950
|
+
buffer: (record) =>
|
|
951
|
+
records.push(record),
|
|
952
|
+
},
|
|
953
|
+
loggers: [
|
|
954
|
+
{ category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
|
|
955
|
+
{ category: ["logtape", "meta"], sinks: [] },
|
|
956
|
+
],
|
|
957
|
+
reset: true,
|
|
958
|
+
});
|
|
959
|
+
try {
|
|
960
|
+
deepStrictEqual(
|
|
961
|
+
await lookupWebFinger("acct:alice@dns-failure.invalid"),
|
|
962
|
+
null,
|
|
963
|
+
);
|
|
964
|
+
const failures = records.filter((r) =>
|
|
965
|
+
r.rawMessage !==
|
|
966
|
+
"Fetching WebFinger resource descriptor from {url}..."
|
|
967
|
+
);
|
|
968
|
+
deepStrictEqual(
|
|
969
|
+
failures.map((r) => [r.level, r.rawMessage]),
|
|
970
|
+
[
|
|
971
|
+
result === "private"
|
|
972
|
+
? [
|
|
973
|
+
"error",
|
|
974
|
+
"Invalid URL for WebFinger resource descriptor: {error}",
|
|
975
|
+
]
|
|
976
|
+
: ["debug", "DNS lookup failed for {url}"],
|
|
977
|
+
],
|
|
978
|
+
);
|
|
979
|
+
const { error } = failures[0].properties;
|
|
980
|
+
ok(error instanceof UrlError);
|
|
981
|
+
deepStrictEqual(
|
|
982
|
+
error.reason,
|
|
983
|
+
result === "private" ? "disallowed" : "dns",
|
|
984
|
+
);
|
|
985
|
+
} finally {
|
|
986
|
+
await reset();
|
|
987
|
+
dns.lookup = originalLookup;
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
});
|
package/src/lookup.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type TracerProvider,
|
|
17
17
|
} from "@opentelemetry/api";
|
|
18
18
|
import metadata from "../deno.json" with { type: "json" };
|
|
19
|
+
import { BodyTooLargeError, MAX_BODY_SIZE, readBoundedText } from "./body.ts";
|
|
19
20
|
import type { ResourceDescriptor } from "./jrd.ts";
|
|
20
21
|
|
|
21
22
|
const logger = getLogger(["fedify", "webfinger", "lookup"]);
|
|
@@ -31,10 +32,10 @@ const DEFAULT_MAX_REDIRECTION = 5;
|
|
|
31
32
|
* - `not_found`: the remote responded with HTTP `404 Not Found` or
|
|
32
33
|
* `410 Gone`. Recorded together with `http.response.status_code`.
|
|
33
34
|
* - `invalid`: the remote responded with content Fedify could not parse
|
|
34
|
-
* into a {@link ResourceDescriptor} (JSON parse failure
|
|
35
|
-
* redirect chain exceeded `maxRedirection`, the
|
|
36
|
-
* a different protocol, or the queried `acct:`
|
|
37
|
-
* malformed.
|
|
35
|
+
* into a {@link ResourceDescriptor} (JSON parse failure or a body over
|
|
36
|
+
* the size limit), the redirect chain exceeded `maxRedirection`, the
|
|
37
|
+
* remote redirected to a different protocol, or the queried `acct:`
|
|
38
|
+
* resource itself was malformed.
|
|
38
39
|
* - `network_error`: no HTTP response was received (the URL was
|
|
39
40
|
* rejected as a private address, `fetch()` threw, or an `AbortError`
|
|
40
41
|
* cancelled the request).
|
|
@@ -321,10 +322,17 @@ async function lookupWebFingerInternal(
|
|
|
321
322
|
await validatePublicUrl(url.href);
|
|
322
323
|
} catch (e) {
|
|
323
324
|
if (e instanceof UrlError) {
|
|
324
|
-
|
|
325
|
-
"
|
|
326
|
-
|
|
327
|
-
|
|
325
|
+
if (e.reason === "dns") {
|
|
326
|
+
logger.debug("DNS lookup failed for {url}", {
|
|
327
|
+
url: url.href,
|
|
328
|
+
error: e,
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
logger.error(
|
|
332
|
+
"Invalid URL for WebFinger resource descriptor: {error}",
|
|
333
|
+
{ error: e },
|
|
334
|
+
);
|
|
335
|
+
}
|
|
328
336
|
return { resource: null, result: "network_error", remoteHost };
|
|
329
337
|
}
|
|
330
338
|
throw e;
|
|
@@ -430,7 +438,9 @@ async function lookupWebFingerInternal(
|
|
|
430
438
|
};
|
|
431
439
|
}
|
|
432
440
|
try {
|
|
433
|
-
const parsed =
|
|
441
|
+
const parsed = JSON.parse(
|
|
442
|
+
await readBoundedText(response, MAX_BODY_SIZE, url),
|
|
443
|
+
) as ResourceDescriptor;
|
|
434
444
|
return {
|
|
435
445
|
resource: parsed,
|
|
436
446
|
result: "found",
|
|
@@ -438,6 +448,14 @@ async function lookupWebFingerInternal(
|
|
|
438
448
|
remoteHost,
|
|
439
449
|
};
|
|
440
450
|
} catch (e) {
|
|
451
|
+
if (e instanceof BodyTooLargeError) {
|
|
452
|
+
return {
|
|
453
|
+
resource: null,
|
|
454
|
+
result: "invalid",
|
|
455
|
+
statusCode: response.status,
|
|
456
|
+
remoteHost,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
441
459
|
if (e instanceof SyntaxError) {
|
|
442
460
|
logger.debug(
|
|
443
461
|
"Failed to parse WebFinger resource descriptor as JSON: {error}",
|