@fedify/webfinger 2.3.7 → 2.3.8

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.3.7",
3
+ "version": "2.3.8",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts"
@@ -1228,7 +1228,81 @@ var esm_default = new class FetchMock {
1228
1228
  //#endregion
1229
1229
  //#region deno.json
1230
1230
  var name = "@fedify/webfinger";
1231
- var version = "2.3.7";
1231
+ var version = "2.3.8";
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 _fedify_vocab_runtime.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
+ (0, _logtape_logtape.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
+ }
1232
1306
  //#endregion
1233
1307
  //#region src/lookup.ts
1234
1308
  const logger = (0, _logtape_logtape.getLogger)([
@@ -1459,12 +1533,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
1459
1533
  }
1460
1534
  try {
1461
1535
  return {
1462
- resource: await response.json(),
1536
+ resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
1463
1537
  result: "found",
1464
1538
  statusCode: response.status,
1465
1539
  remoteHost
1466
1540
  };
1467
1541
  } catch (e) {
1542
+ if (e instanceof BodyTooLargeError) return {
1543
+ resource: null,
1544
+ result: "invalid",
1545
+ statusCode: response.status,
1546
+ remoteHost
1547
+ };
1468
1548
  if (e instanceof SyntaxError) {
1469
1549
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1470
1550
  return {
@@ -1894,4 +1974,19 @@ async function lookupWebFingerInternal(resource, options = {}) {
1894
1974
  esm_default.hardReset();
1895
1975
  }
1896
1976
  });
1977
+ (0, _fedify_fixture.test)("lookupWebFinger() bounds resource descriptors", async () => {
1978
+ esm_default.mockGlobal();
1979
+ let oversized = true;
1980
+ try {
1981
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
1982
+ "Content-Type": "application/jrd+json",
1983
+ "Content-Length": "1"
1984
+ } }));
1985
+ (0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
1986
+ oversized = false;
1987
+ (0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
1988
+ } finally {
1989
+ esm_default.hardReset();
1990
+ }
1991
+ });
1897
1992
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import { createTestMeterProvider, test } from "@fedify/fixture";
2
2
  import { withTimeout } from "es-toolkit";
3
3
  import { deepStrictEqual, ok } from "node:assert/strict";
4
- import { UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
4
+ import { FetchError, UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
5
5
  import { getLogger } from "@logtape/logtape";
6
6
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
7
7
  //#region \0rolldown/runtime.js
@@ -1227,7 +1227,81 @@ var esm_default = new class FetchMock {
1227
1227
  //#endregion
1228
1228
  //#region deno.json
1229
1229
  var name = "@fedify/webfinger";
1230
- var version = "2.3.7";
1230
+ var version = "2.3.8";
1231
+ //#endregion
1232
+ //#region src/body.ts
1233
+ /** Decoded JSON body limit: 16 MiB. @internal */
1234
+ const MAX_BODY_SIZE = 16 * 1024 * 1024;
1235
+ /** A body exceeded the byte limit. @internal */
1236
+ var BodyTooLargeError = class extends FetchError {
1237
+ /** Creates an error for a body exceeding the given limit. */
1238
+ constructor(url, maxBytes) {
1239
+ super(url, `Body exceeds the limit of ${maxBytes} bytes`);
1240
+ this.name = "BodyTooLargeError";
1241
+ }
1242
+ };
1243
+ /** Validates a finite, positive byte limit. @internal */
1244
+ function validateBodySizeLimit(maxBytes) {
1245
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
1246
+ }
1247
+ /**
1248
+ * Reads UTF-8 text while limiting bytes received from the body stream.
1249
+ * Fetch responses are already decompressed, so the stream count is authoritative.
1250
+ * @param message The response or request to read.
1251
+ * @param maxBytes The maximum number of decoded bytes to read.
1252
+ * @param url The URL to include in errors and logs.
1253
+ * @returns The decoded body text.
1254
+ * @throws {BodyTooLargeError} If the body exceeds the limit.
1255
+ * @internal
1256
+ */
1257
+ async function readBoundedText(message, maxBytes, url) {
1258
+ validateBodySizeLimit(maxBytes);
1259
+ const reader = message.body?.getReader();
1260
+ const tooLarge = () => {
1261
+ getLogger([
1262
+ "fedify",
1263
+ "runtime",
1264
+ "body"
1265
+ ]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
1266
+ url: url.toString(),
1267
+ maxBytes
1268
+ });
1269
+ throw new BodyTooLargeError(url, maxBytes);
1270
+ };
1271
+ try {
1272
+ const length = message.headers.get("Content-Length");
1273
+ const encoding = message.headers.get("Content-Encoding");
1274
+ if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
1275
+ if (reader == null) return "";
1276
+ let size = 0;
1277
+ const textReader = new ReadableStream({ async pull(controller) {
1278
+ const { done, value } = await reader.read();
1279
+ if (done) {
1280
+ controller.close();
1281
+ return;
1282
+ }
1283
+ size += value.byteLength;
1284
+ if (size > maxBytes) tooLarge();
1285
+ controller.enqueue(value);
1286
+ } }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
1287
+ try {
1288
+ const chunks = [];
1289
+ while (true) {
1290
+ const { done, value } = await textReader.read();
1291
+ if (done) break;
1292
+ chunks.push(value);
1293
+ }
1294
+ return chunks.join("");
1295
+ } finally {
1296
+ textReader.releaseLock();
1297
+ }
1298
+ } catch (error) {
1299
+ if (reader != null) reader.cancel(error).catch(() => {});
1300
+ throw error;
1301
+ } finally {
1302
+ reader?.releaseLock();
1303
+ }
1304
+ }
1231
1305
  //#endregion
1232
1306
  //#region src/lookup.ts
1233
1307
  const logger = getLogger([
@@ -1458,12 +1532,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
1458
1532
  }
1459
1533
  try {
1460
1534
  return {
1461
- resource: await response.json(),
1535
+ resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
1462
1536
  result: "found",
1463
1537
  statusCode: response.status,
1464
1538
  remoteHost
1465
1539
  };
1466
1540
  } catch (e) {
1541
+ if (e instanceof BodyTooLargeError) return {
1542
+ resource: null,
1543
+ result: "invalid",
1544
+ statusCode: response.status,
1545
+ remoteHost
1546
+ };
1467
1547
  if (e instanceof SyntaxError) {
1468
1548
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1469
1549
  return {
@@ -1893,5 +1973,20 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
1893
1973
  esm_default.hardReset();
1894
1974
  }
1895
1975
  });
1976
+ test("lookupWebFinger() bounds resource descriptors", async () => {
1977
+ esm_default.mockGlobal();
1978
+ let oversized = true;
1979
+ try {
1980
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
1981
+ "Content-Type": "application/jrd+json",
1982
+ "Content-Length": "1"
1983
+ } }));
1984
+ deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
1985
+ oversized = false;
1986
+ deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
1987
+ } finally {
1988
+ esm_default.hardReset();
1989
+ }
1990
+ });
1896
1991
  //#endregion
1897
1992
  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.3.7";
7
+ var version = "2.3.8";
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)([
@@ -235,12 +309,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
235
309
  }
236
310
  try {
237
311
  return {
238
- resource: await response.json(),
312
+ resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
239
313
  result: "found",
240
314
  statusCode: response.status,
241
315
  remoteHost
242
316
  };
243
317
  } catch (e) {
318
+ if (e instanceof BodyTooLargeError) return {
319
+ resource: null,
320
+ result: "invalid",
321
+ statusCode: response.status,
322
+ remoteHost
323
+ };
244
324
  if (e instanceof SyntaxError) {
245
325
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
246
326
  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.3.7";
6
+ var version = "2.3.8";
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([
@@ -234,12 +308,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
234
308
  }
235
309
  try {
236
310
  return {
237
- resource: await response.json(),
311
+ resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
238
312
  result: "found",
239
313
  statusCode: response.status,
240
314
  remoteHost
241
315
  };
242
316
  } catch (e) {
317
+ if (e instanceof BodyTooLargeError) return {
318
+ resource: null,
319
+ result: "invalid",
320
+ statusCode: response.status,
321
+ remoteHost
322
+ };
243
323
  if (e instanceof SyntaxError) {
244
324
  logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
245
325
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/webfinger",
3
- "version": "2.3.7",
3
+ "version": "2.3.8",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,7 +60,7 @@
60
60
  "@logtape/logtape": "^2.2.0",
61
61
  "@opentelemetry/api": "^1.9.1",
62
62
  "es-toolkit": "1.46.1",
63
- "@fedify/vocab-runtime": "2.3.7"
63
+ "@fedify/vocab-runtime": "2.3.8"
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
+ }
@@ -886,3 +886,39 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
886
886
  });
887
887
 
888
888
  // cSpell: ignore johndoe
889
+
890
+ test("lookupWebFinger() bounds resource descriptors", async () => {
891
+ fetchMock.mockGlobal();
892
+ let oversized = true;
893
+ try {
894
+ fetchMock.get(
895
+ "begin:https://example.com/.well-known/webfinger?",
896
+ () =>
897
+ new Response(
898
+ '{"subject":"acct:alice@example.com"}' +
899
+ (oversized ? " ".repeat(16 * 1024 * 1024) : ""),
900
+ {
901
+ headers: {
902
+ "Content-Type": "application/jrd+json",
903
+ "Content-Length": "1",
904
+ },
905
+ },
906
+ ),
907
+ );
908
+ deepStrictEqual(
909
+ await lookupWebFinger("acct:alice@example.com", {
910
+ allowPrivateAddress: true,
911
+ }),
912
+ null,
913
+ );
914
+ oversized = false;
915
+ deepStrictEqual(
916
+ await lookupWebFinger("acct:alice@example.com", {
917
+ allowPrivateAddress: true,
918
+ }),
919
+ { subject: "acct:alice@example.com" },
920
+ );
921
+ } finally {
922
+ fetchMock.hardReset();
923
+ }
924
+ });
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).
@@ -430,7 +431,9 @@ async function lookupWebFingerInternal(
430
431
  };
431
432
  }
432
433
  try {
433
- const parsed = await response.json() as ResourceDescriptor;
434
+ const parsed = JSON.parse(
435
+ await readBoundedText(response, MAX_BODY_SIZE, url),
436
+ ) as ResourceDescriptor;
434
437
  return {
435
438
  resource: parsed,
436
439
  result: "found",
@@ -438,6 +441,14 @@ async function lookupWebFingerInternal(
438
441
  remoteHost,
439
442
  };
440
443
  } catch (e) {
444
+ if (e instanceof BodyTooLargeError) {
445
+ return {
446
+ resource: null,
447
+ result: "invalid",
448
+ statusCode: response.status,
449
+ remoteHost,
450
+ };
451
+ }
441
452
  if (e instanceof SyntaxError) {
442
453
  logger.debug(
443
454
  "Failed to parse WebFinger resource descriptor as JSON: {error}",