@fedify/webfinger 2.1.23 → 2.1.25

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.1.23",
3
+ "version": "2.1.25",
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);
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
@@ -1228,7 +1230,81 @@ var esm_default = new class FetchMock {
1228
1230
  //#endregion
1229
1231
  //#region deno.json
1230
1232
  var name = "@fedify/webfinger";
1231
- var version = "2.1.23";
1233
+ var version = "2.1.25";
1234
+ //#endregion
1235
+ //#region src/body.ts
1236
+ /** Decoded JSON body limit: 16 MiB. @internal */
1237
+ const MAX_BODY_SIZE = 16 * 1024 * 1024;
1238
+ /** A body exceeded the byte limit. @internal */
1239
+ var BodyTooLargeError = class extends _fedify_vocab_runtime.FetchError {
1240
+ /** Creates an error for a body exceeding the given limit. */
1241
+ constructor(url, maxBytes) {
1242
+ super(url, `Body exceeds the limit of ${maxBytes} bytes`);
1243
+ this.name = "BodyTooLargeError";
1244
+ }
1245
+ };
1246
+ /** Validates a finite, positive byte limit. @internal */
1247
+ function validateBodySizeLimit(maxBytes) {
1248
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
1249
+ }
1250
+ /**
1251
+ * Reads UTF-8 text while limiting bytes received from the body stream.
1252
+ * Fetch responses are already decompressed, so the stream count is authoritative.
1253
+ * @param message The response or request to read.
1254
+ * @param maxBytes The maximum number of decoded bytes to read.
1255
+ * @param url The URL to include in errors and logs.
1256
+ * @returns The decoded body text.
1257
+ * @throws {BodyTooLargeError} If the body exceeds the limit.
1258
+ * @internal
1259
+ */
1260
+ async function readBoundedText(message, maxBytes, url) {
1261
+ validateBodySizeLimit(maxBytes);
1262
+ const reader = message.body?.getReader();
1263
+ const tooLarge = () => {
1264
+ (0, _logtape_logtape.getLogger)([
1265
+ "fedify",
1266
+ "runtime",
1267
+ "body"
1268
+ ]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
1269
+ url: url.toString(),
1270
+ maxBytes
1271
+ });
1272
+ throw new BodyTooLargeError(url, maxBytes);
1273
+ };
1274
+ try {
1275
+ const length = message.headers.get("Content-Length");
1276
+ const encoding = message.headers.get("Content-Encoding");
1277
+ if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
1278
+ if (reader == null) return "";
1279
+ let size = 0;
1280
+ const textReader = new ReadableStream({ async pull(controller) {
1281
+ const { done, value } = await reader.read();
1282
+ if (done) {
1283
+ controller.close();
1284
+ return;
1285
+ }
1286
+ size += value.byteLength;
1287
+ if (size > maxBytes) tooLarge();
1288
+ controller.enqueue(value);
1289
+ } }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
1290
+ try {
1291
+ const chunks = [];
1292
+ while (true) {
1293
+ const { done, value } = await textReader.read();
1294
+ if (done) break;
1295
+ chunks.push(value);
1296
+ }
1297
+ return chunks.join("");
1298
+ } finally {
1299
+ textReader.releaseLock();
1300
+ }
1301
+ } catch (error) {
1302
+ if (reader != null) reader.cancel(error).catch(() => {});
1303
+ throw error;
1304
+ } finally {
1305
+ reader?.releaseLock();
1306
+ }
1307
+ }
1232
1308
  //#endregion
1233
1309
  //#region src/lookup.ts
1234
1310
  const logger = (0, _logtape_logtape.getLogger)([
@@ -1290,7 +1366,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
1290
1366
  await (0, _fedify_vocab_runtime.validatePublicUrl)(url.href);
1291
1367
  } catch (e) {
1292
1368
  if (e instanceof _fedify_vocab_runtime.UrlError) {
1293
- logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1369
+ if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
1370
+ url: url.href,
1371
+ error: e
1372
+ });
1373
+ else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1294
1374
  return null;
1295
1375
  }
1296
1376
  throw e;
@@ -1338,8 +1418,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
1338
1418
  return null;
1339
1419
  }
1340
1420
  try {
1341
- return await response.json();
1421
+ return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
1342
1422
  } catch (e) {
1423
+ if (e instanceof BodyTooLargeError) return null;
1343
1424
  if (e instanceof SyntaxError) {
1344
1425
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1345
1426
  return null;
@@ -1519,4 +1600,57 @@ async function lookupWebFingerInternal(resource, options = {}) {
1519
1600
  esm_default.hardReset();
1520
1601
  }
1521
1602
  });
1603
+ (0, _fedify_fixture.test)("lookupWebFinger() bounds resource descriptors", async () => {
1604
+ esm_default.mockGlobal();
1605
+ let oversized = true;
1606
+ try {
1607
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
1608
+ "Content-Type": "application/jrd+json",
1609
+ "Content-Length": "1"
1610
+ } }));
1611
+ (0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
1612
+ oversized = false;
1613
+ (0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
1614
+ } finally {
1615
+ esm_default.hardReset();
1616
+ }
1617
+ });
1618
+ (0, _fedify_fixture.test)("lookupWebFinger() logs DNS failures as such", async (t) => {
1619
+ if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
1620
+ for (const result of [
1621
+ "throws",
1622
+ "empty",
1623
+ "private"
1624
+ ]) await t.step(result, async () => {
1625
+ const originalLookup = node_dns_promises.default.lookup;
1626
+ node_dns_promises.default.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
1627
+ address: "127.0.0.1",
1628
+ family: 4
1629
+ }]));
1630
+ const records = [];
1631
+ await (0, _logtape_logtape.configure)({
1632
+ sinks: { buffer: (record) => records.push(record) },
1633
+ loggers: [{
1634
+ category: "fedify",
1635
+ sinks: ["buffer"],
1636
+ lowestLevel: "debug"
1637
+ }, {
1638
+ category: ["logtape", "meta"],
1639
+ sinks: []
1640
+ }],
1641
+ reset: true
1642
+ });
1643
+ try {
1644
+ (0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@dns-failure.invalid"), null);
1645
+ const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
1646
+ (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}"]]);
1647
+ const { error } = failures[0].properties;
1648
+ (0, node_assert_strict.ok)(error instanceof _fedify_vocab_runtime.UrlError);
1649
+ (0, node_assert_strict.deepStrictEqual)(error.reason, result === "private" ? "disallowed" : "dns");
1650
+ } finally {
1651
+ await (0, _logtape_logtape.reset)();
1652
+ node_dns_promises.default.lookup = originalLookup;
1653
+ }
1654
+ });
1655
+ });
1522
1656
  //#endregion
@@ -1,8 +1,9 @@
1
1
  import { 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
- import { deepStrictEqual } from "node:assert/strict";
4
- import { UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
5
- import { getLogger } from "@logtape/logtape";
5
+ import { deepStrictEqual, ok } from "node:assert/strict";
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;
@@ -1227,7 +1228,81 @@ var esm_default = new class FetchMock {
1227
1228
  //#endregion
1228
1229
  //#region deno.json
1229
1230
  var name = "@fedify/webfinger";
1230
- var version = "2.1.23";
1231
+ var version = "2.1.25";
1232
+ //#endregion
1233
+ //#region src/body.ts
1234
+ /** Decoded JSON body limit: 16 MiB. @internal */
1235
+ const MAX_BODY_SIZE = 16 * 1024 * 1024;
1236
+ /** A body exceeded the byte limit. @internal */
1237
+ var BodyTooLargeError = class extends FetchError {
1238
+ /** Creates an error for a body exceeding the given limit. */
1239
+ constructor(url, maxBytes) {
1240
+ super(url, `Body exceeds the limit of ${maxBytes} bytes`);
1241
+ this.name = "BodyTooLargeError";
1242
+ }
1243
+ };
1244
+ /** Validates a finite, positive byte limit. @internal */
1245
+ function validateBodySizeLimit(maxBytes) {
1246
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
1247
+ }
1248
+ /**
1249
+ * Reads UTF-8 text while limiting bytes received from the body stream.
1250
+ * Fetch responses are already decompressed, so the stream count is authoritative.
1251
+ * @param message The response or request to read.
1252
+ * @param maxBytes The maximum number of decoded bytes to read.
1253
+ * @param url The URL to include in errors and logs.
1254
+ * @returns The decoded body text.
1255
+ * @throws {BodyTooLargeError} If the body exceeds the limit.
1256
+ * @internal
1257
+ */
1258
+ async function readBoundedText(message, maxBytes, url) {
1259
+ validateBodySizeLimit(maxBytes);
1260
+ const reader = message.body?.getReader();
1261
+ const tooLarge = () => {
1262
+ getLogger([
1263
+ "fedify",
1264
+ "runtime",
1265
+ "body"
1266
+ ]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
1267
+ url: url.toString(),
1268
+ maxBytes
1269
+ });
1270
+ throw new BodyTooLargeError(url, maxBytes);
1271
+ };
1272
+ try {
1273
+ const length = message.headers.get("Content-Length");
1274
+ const encoding = message.headers.get("Content-Encoding");
1275
+ if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
1276
+ if (reader == null) return "";
1277
+ let size = 0;
1278
+ const textReader = new ReadableStream({ async pull(controller) {
1279
+ const { done, value } = await reader.read();
1280
+ if (done) {
1281
+ controller.close();
1282
+ return;
1283
+ }
1284
+ size += value.byteLength;
1285
+ if (size > maxBytes) tooLarge();
1286
+ controller.enqueue(value);
1287
+ } }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
1288
+ try {
1289
+ const chunks = [];
1290
+ while (true) {
1291
+ const { done, value } = await textReader.read();
1292
+ if (done) break;
1293
+ chunks.push(value);
1294
+ }
1295
+ return chunks.join("");
1296
+ } finally {
1297
+ textReader.releaseLock();
1298
+ }
1299
+ } catch (error) {
1300
+ if (reader != null) reader.cancel(error).catch(() => {});
1301
+ throw error;
1302
+ } finally {
1303
+ reader?.releaseLock();
1304
+ }
1305
+ }
1231
1306
  //#endregion
1232
1307
  //#region src/lookup.ts
1233
1308
  const logger = getLogger([
@@ -1289,7 +1364,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
1289
1364
  await validatePublicUrl(url.href);
1290
1365
  } catch (e) {
1291
1366
  if (e instanceof UrlError) {
1292
- logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1367
+ if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
1368
+ url: url.href,
1369
+ error: e
1370
+ });
1371
+ else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1293
1372
  return null;
1294
1373
  }
1295
1374
  throw e;
@@ -1337,8 +1416,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
1337
1416
  return null;
1338
1417
  }
1339
1418
  try {
1340
- return await response.json();
1419
+ return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
1341
1420
  } catch (e) {
1421
+ if (e instanceof BodyTooLargeError) return null;
1342
1422
  if (e instanceof SyntaxError) {
1343
1423
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1344
1424
  return null;
@@ -1518,5 +1598,58 @@ test({
1518
1598
  esm_default.hardReset();
1519
1599
  }
1520
1600
  });
1601
+ test("lookupWebFinger() bounds resource descriptors", async () => {
1602
+ esm_default.mockGlobal();
1603
+ let oversized = true;
1604
+ try {
1605
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
1606
+ "Content-Type": "application/jrd+json",
1607
+ "Content-Length": "1"
1608
+ } }));
1609
+ deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
1610
+ oversized = false;
1611
+ deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
1612
+ } finally {
1613
+ esm_default.hardReset();
1614
+ }
1615
+ });
1616
+ test("lookupWebFinger() logs DNS failures as such", async (t) => {
1617
+ if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
1618
+ for (const result of [
1619
+ "throws",
1620
+ "empty",
1621
+ "private"
1622
+ ]) await t.step(result, async () => {
1623
+ const originalLookup = dns.lookup;
1624
+ dns.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
1625
+ address: "127.0.0.1",
1626
+ family: 4
1627
+ }]));
1628
+ const records = [];
1629
+ await configure({
1630
+ sinks: { buffer: (record) => records.push(record) },
1631
+ loggers: [{
1632
+ category: "fedify",
1633
+ sinks: ["buffer"],
1634
+ lowestLevel: "debug"
1635
+ }, {
1636
+ category: ["logtape", "meta"],
1637
+ sinks: []
1638
+ }],
1639
+ reset: true
1640
+ });
1641
+ try {
1642
+ deepStrictEqual(await lookupWebFinger("acct:alice@dns-failure.invalid"), null);
1643
+ const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
1644
+ deepStrictEqual(failures.map((r) => [r.level, r.rawMessage]), [result === "private" ? ["error", "Invalid URL for WebFinger resource descriptor: {error}"] : ["debug", "DNS lookup failed for {url}"]]);
1645
+ const { error } = failures[0].properties;
1646
+ ok(error instanceof UrlError);
1647
+ deepStrictEqual(error.reason, result === "private" ? "disallowed" : "dns");
1648
+ } finally {
1649
+ await reset();
1650
+ dns.lookup = originalLookup;
1651
+ }
1652
+ });
1653
+ });
1521
1654
  //#endregion
1522
1655
  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.1.23";
7
+ var version = "2.1.25";
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)([
@@ -66,7 +140,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
66
140
  await (0, _fedify_vocab_runtime.validatePublicUrl)(url.href);
67
141
  } catch (e) {
68
142
  if (e instanceof _fedify_vocab_runtime.UrlError) {
69
- logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
143
+ if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
144
+ url: url.href,
145
+ error: e
146
+ });
147
+ else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
70
148
  return null;
71
149
  }
72
150
  throw e;
@@ -114,8 +192,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
114
192
  return null;
115
193
  }
116
194
  try {
117
- return await response.json();
195
+ return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
118
196
  } catch (e) {
197
+ if (e instanceof BodyTooLargeError) return null;
119
198
  if (e instanceof SyntaxError) {
120
199
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
121
200
  return null;
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.1.23";
6
+ var version = "2.1.25";
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([
@@ -65,7 +139,11 @@ async function lookupWebFingerInternal(resource, options = {}) {
65
139
  await validatePublicUrl(url.href);
66
140
  } catch (e) {
67
141
  if (e instanceof UrlError) {
68
- logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
142
+ if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
143
+ url: url.href,
144
+ error: e
145
+ });
146
+ else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
69
147
  return null;
70
148
  }
71
149
  throw e;
@@ -113,8 +191,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
113
191
  return null;
114
192
  }
115
193
  try {
116
- return await response.json();
194
+ return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
117
195
  } catch (e) {
196
+ if (e instanceof BodyTooLargeError) return null;
118
197
  if (e instanceof SyntaxError) {
119
198
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
120
199
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/webfinger",
3
- "version": "2.1.23",
3
+ "version": "2.1.25",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,7 +60,7 @@
60
60
  "@logtape/logtape": "^2.0.5",
61
61
  "@opentelemetry/api": "^1.9.0",
62
62
  "es-toolkit": "1.43.0",
63
- "@fedify/vocab-runtime": "2.1.23"
63
+ "@fedify/vocab-runtime": "2.1.25"
64
64
  },
65
65
  "scripts": {
66
66
  "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 { 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
- import { deepStrictEqual } from "node:assert/strict";
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
 
@@ -329,3 +332,103 @@ test({
329
332
  });
330
333
 
331
334
  // cSpell: ignore johndoe
335
+
336
+ test("lookupWebFinger() bounds resource descriptors", async () => {
337
+ fetchMock.mockGlobal();
338
+ let oversized = true;
339
+ try {
340
+ fetchMock.get(
341
+ "begin:https://example.com/.well-known/webfinger?",
342
+ () =>
343
+ new Response(
344
+ '{"subject":"acct:alice@example.com"}' +
345
+ (oversized ? " ".repeat(16 * 1024 * 1024) : ""),
346
+ {
347
+ headers: {
348
+ "Content-Type": "application/jrd+json",
349
+ "Content-Length": "1",
350
+ },
351
+ },
352
+ ),
353
+ );
354
+ deepStrictEqual(
355
+ await lookupWebFinger("acct:alice@example.com", {
356
+ allowPrivateAddress: true,
357
+ }),
358
+ null,
359
+ );
360
+ oversized = false;
361
+ deepStrictEqual(
362
+ await lookupWebFinger("acct:alice@example.com", {
363
+ allowPrivateAddress: true,
364
+ }),
365
+ { subject: "acct:alice@example.com" },
366
+ );
367
+ } finally {
368
+ fetchMock.hardReset();
369
+ }
370
+ });
371
+
372
+ test("lookupWebFinger() logs DNS failures as such", async (t) => {
373
+ // The validator skips DNS when Deno has no network permission. Checked
374
+ // here rather than with top-level await, which the CommonJS build rejects.
375
+ if (
376
+ "Deno" in globalThis &&
377
+ (await Deno.permissions.query({ name: "net" })).state !== "granted"
378
+ ) return;
379
+ for (const result of ["throws", "empty", "private"] as const) {
380
+ await t.step(result, async () => {
381
+ // Stubbing works only because vocab-runtime's url.ts uses the default
382
+ // node:dns/promises import; see the FIXME there.
383
+ const originalLookup = dns.lookup;
384
+ dns.lookup = (() =>
385
+ result === "throws"
386
+ ? Promise.reject(new Error("Resolver unavailable"))
387
+ : Promise.resolve(
388
+ result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
389
+ )) as typeof dns.lookup;
390
+ const records: LogRecord[] = [];
391
+ await configure({
392
+ sinks: {
393
+ buffer: (record) =>
394
+ records.push(record),
395
+ },
396
+ loggers: [
397
+ { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
398
+ { category: ["logtape", "meta"], sinks: [] },
399
+ ],
400
+ reset: true,
401
+ });
402
+ try {
403
+ deepStrictEqual(
404
+ await lookupWebFinger("acct:alice@dns-failure.invalid"),
405
+ null,
406
+ );
407
+ const failures = records.filter((r) =>
408
+ r.rawMessage !==
409
+ "Fetching WebFinger resource descriptor from {url}..."
410
+ );
411
+ deepStrictEqual(
412
+ failures.map((r) => [r.level, r.rawMessage]),
413
+ [
414
+ result === "private"
415
+ ? [
416
+ "error",
417
+ "Invalid URL for WebFinger resource descriptor: {error}",
418
+ ]
419
+ : ["debug", "DNS lookup failed for {url}"],
420
+ ],
421
+ );
422
+ const { error } = failures[0].properties;
423
+ ok(error instanceof UrlError);
424
+ deepStrictEqual(
425
+ error.reason,
426
+ result === "private" ? "disallowed" : "dns",
427
+ );
428
+ } finally {
429
+ await reset();
430
+ dns.lookup = originalLookup;
431
+ }
432
+ });
433
+ }
434
+ });
package/src/lookup.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type TracerProvider,
13
13
  } from "@opentelemetry/api";
14
14
  import metadata from "../deno.json" with { type: "json" };
15
+ import { BodyTooLargeError, MAX_BODY_SIZE, readBoundedText } from "./body.ts";
15
16
  import type { ResourceDescriptor } from "./jrd.ts";
16
17
 
17
18
  const logger = getLogger(["fedify", "webfinger", "lookup"]);
@@ -138,10 +139,20 @@ async function lookupWebFingerInternal(
138
139
  await validatePublicUrl(url.href);
139
140
  } catch (e) {
140
141
  if (e instanceof UrlError) {
141
- logger.error(
142
- "Invalid URL for WebFinger resource descriptor: {error}",
143
- { error: e },
144
- );
142
+ if (e.reason === "dns") {
143
+ logger.debug("DNS lookup failed for {url}", {
144
+ url: url.href,
145
+ error: e,
146
+ });
147
+ } else {
148
+ logger.error(
149
+ "Invalid URL for WebFinger resource descriptor: {error}",
150
+ { error: e },
151
+ );
152
+ }
153
+ // TODO: When merging forward into 2.3-maintenance and later, where
154
+ // this returns { resource: null, result: "network_error", remoteHost }
155
+ // instead of null, keep that return value for both reasons.
145
156
  return null;
146
157
  }
147
158
  throw e;
@@ -210,8 +221,15 @@ async function lookupWebFingerInternal(
210
221
  return null;
211
222
  }
212
223
  try {
213
- return await response.json() as ResourceDescriptor;
224
+ return JSON.parse(
225
+ await readBoundedText(
226
+ response,
227
+ MAX_BODY_SIZE,
228
+ url,
229
+ ),
230
+ ) as ResourceDescriptor;
214
231
  } catch (e) {
232
+ if (e instanceof BodyTooLargeError) return null;
215
233
  if (e instanceof SyntaxError) {
216
234
  logger.debug(
217
235
  "Failed to parse WebFinger resource descriptor as JSON: {error}",