@fedify/webfinger 2.4.0-pr.936.41 → 2.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/webfinger",
3
- "version": "2.4.0-pr.936.41+59c7432c",
3
+ "version": "2.4.1",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts"
@@ -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, _a, _Route_responseSubscriptions, _Route_validate, _Route_sanitize, _Route_generateMatcher, _Route_limit, _Route_delayResponse;
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-pr.936.41+59c7432c";
1240
+ var version = "2.4.1";
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.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
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.json(),
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
@@ -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 { UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
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, _a, _Route_responseSubscriptions, _Route_validate, _Route_sanitize, _Route_generateMatcher, _Route_limit, _Route_delayResponse;
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-pr.936.41+59c7432c";
1238
+ var version = "2.4.1";
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.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
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.json(),
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-pr.936.41+59c7432c";
7
+ var version = "2.4.1";
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.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
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.json(),
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), the
75
- * redirect chain exceeded `maxRedirection`, the remote redirected to
76
- * a different protocol, or the queried `acct:` resource itself was
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), the
75
- * redirect chain exceeded `maxRedirection`, the remote redirected to
76
- * a different protocol, or the queried `acct:` resource itself was
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-pr.936.41+59c7432c";
6
+ var version = "2.4.1";
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.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
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.json(),
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-pr.936.41+59c7432c",
3
+ "version": "2.4.1",
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.1",
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-pr.936.41+59c7432c"
60
+ "@fedify/vocab-runtime": "2.4.1"
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
+ }
@@ -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), the
35
- * redirect chain exceeded `maxRedirection`, the remote redirected to
36
- * a different protocol, or the queried `acct:` resource itself was
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
- logger.error(
325
- "Invalid URL for WebFinger resource descriptor: {error}",
326
- { error: e },
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 = await response.json() as ResourceDescriptor;
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}",