@fedify/webfinger 2.1.23 → 2.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deno.json +1 -1
- package/dist/lookup.test.cjs +92 -2
- package/dist/lookup.test.js +93 -3
- package/dist/mod.cjs +77 -2
- package/dist/mod.js +78 -3
- package/package.json +2 -2
- package/src/body.ts +95 -0
- package/src/lookup.test.ts +36 -0
- package/src/lookup.ts +9 -1
package/deno.json
CHANGED
package/dist/lookup.test.cjs
CHANGED
|
@@ -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.1.
|
|
1231
|
+
var version = "2.1.24";
|
|
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)([
|
|
@@ -1338,8 +1412,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1338
1412
|
return null;
|
|
1339
1413
|
}
|
|
1340
1414
|
try {
|
|
1341
|
-
return await response
|
|
1415
|
+
return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
|
|
1342
1416
|
} catch (e) {
|
|
1417
|
+
if (e instanceof BodyTooLargeError) return null;
|
|
1343
1418
|
if (e instanceof SyntaxError) {
|
|
1344
1419
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
1345
1420
|
return null;
|
|
@@ -1519,4 +1594,19 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1519
1594
|
esm_default.hardReset();
|
|
1520
1595
|
}
|
|
1521
1596
|
});
|
|
1597
|
+
(0, _fedify_fixture.test)("lookupWebFinger() bounds resource descriptors", async () => {
|
|
1598
|
+
esm_default.mockGlobal();
|
|
1599
|
+
let oversized = true;
|
|
1600
|
+
try {
|
|
1601
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
|
|
1602
|
+
"Content-Type": "application/jrd+json",
|
|
1603
|
+
"Content-Length": "1"
|
|
1604
|
+
} }));
|
|
1605
|
+
(0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
|
|
1606
|
+
oversized = false;
|
|
1607
|
+
(0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
|
|
1608
|
+
} finally {
|
|
1609
|
+
esm_default.hardReset();
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1522
1612
|
//#endregion
|
package/dist/lookup.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from "@fedify/fixture";
|
|
2
2
|
import { withTimeout } from "es-toolkit";
|
|
3
3
|
import { deepStrictEqual } 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.1.
|
|
1230
|
+
var version = "2.1.24";
|
|
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([
|
|
@@ -1337,8 +1411,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1337
1411
|
return null;
|
|
1338
1412
|
}
|
|
1339
1413
|
try {
|
|
1340
|
-
return await response
|
|
1414
|
+
return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
|
|
1341
1415
|
} catch (e) {
|
|
1416
|
+
if (e instanceof BodyTooLargeError) return null;
|
|
1342
1417
|
if (e instanceof SyntaxError) {
|
|
1343
1418
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
1344
1419
|
return null;
|
|
@@ -1518,5 +1593,20 @@ test({
|
|
|
1518
1593
|
esm_default.hardReset();
|
|
1519
1594
|
}
|
|
1520
1595
|
});
|
|
1596
|
+
test("lookupWebFinger() bounds resource descriptors", async () => {
|
|
1597
|
+
esm_default.mockGlobal();
|
|
1598
|
+
let oversized = true;
|
|
1599
|
+
try {
|
|
1600
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
|
|
1601
|
+
"Content-Type": "application/jrd+json",
|
|
1602
|
+
"Content-Length": "1"
|
|
1603
|
+
} }));
|
|
1604
|
+
deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
|
|
1605
|
+
oversized = false;
|
|
1606
|
+
deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
|
|
1607
|
+
} finally {
|
|
1608
|
+
esm_default.hardReset();
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1521
1611
|
//#endregion
|
|
1522
1612
|
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.
|
|
7
|
+
var version = "2.1.24";
|
|
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)([
|
|
@@ -114,8 +188,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
114
188
|
return null;
|
|
115
189
|
}
|
|
116
190
|
try {
|
|
117
|
-
return await response
|
|
191
|
+
return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
|
|
118
192
|
} catch (e) {
|
|
193
|
+
if (e instanceof BodyTooLargeError) return null;
|
|
119
194
|
if (e instanceof SyntaxError) {
|
|
120
195
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
121
196
|
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.
|
|
6
|
+
var version = "2.1.24";
|
|
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([
|
|
@@ -113,8 +187,9 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
113
187
|
return null;
|
|
114
188
|
}
|
|
115
189
|
try {
|
|
116
|
-
return await response
|
|
190
|
+
return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
|
|
117
191
|
} catch (e) {
|
|
192
|
+
if (e instanceof BodyTooLargeError) return null;
|
|
118
193
|
if (e instanceof SyntaxError) {
|
|
119
194
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
120
195
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/webfinger",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.24",
|
|
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.
|
|
63
|
+
"@fedify/vocab-runtime": "2.1.24"
|
|
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
|
+
}
|
package/src/lookup.test.ts
CHANGED
|
@@ -329,3 +329,39 @@ test({
|
|
|
329
329
|
});
|
|
330
330
|
|
|
331
331
|
// cSpell: ignore johndoe
|
|
332
|
+
|
|
333
|
+
test("lookupWebFinger() bounds resource descriptors", async () => {
|
|
334
|
+
fetchMock.mockGlobal();
|
|
335
|
+
let oversized = true;
|
|
336
|
+
try {
|
|
337
|
+
fetchMock.get(
|
|
338
|
+
"begin:https://example.com/.well-known/webfinger?",
|
|
339
|
+
() =>
|
|
340
|
+
new Response(
|
|
341
|
+
'{"subject":"acct:alice@example.com"}' +
|
|
342
|
+
(oversized ? " ".repeat(16 * 1024 * 1024) : ""),
|
|
343
|
+
{
|
|
344
|
+
headers: {
|
|
345
|
+
"Content-Type": "application/jrd+json",
|
|
346
|
+
"Content-Length": "1",
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
deepStrictEqual(
|
|
352
|
+
await lookupWebFinger("acct:alice@example.com", {
|
|
353
|
+
allowPrivateAddress: true,
|
|
354
|
+
}),
|
|
355
|
+
null,
|
|
356
|
+
);
|
|
357
|
+
oversized = false;
|
|
358
|
+
deepStrictEqual(
|
|
359
|
+
await lookupWebFinger("acct:alice@example.com", {
|
|
360
|
+
allowPrivateAddress: true,
|
|
361
|
+
}),
|
|
362
|
+
{ subject: "acct:alice@example.com" },
|
|
363
|
+
);
|
|
364
|
+
} finally {
|
|
365
|
+
fetchMock.hardReset();
|
|
366
|
+
}
|
|
367
|
+
});
|
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"]);
|
|
@@ -210,8 +211,15 @@ async function lookupWebFingerInternal(
|
|
|
210
211
|
return null;
|
|
211
212
|
}
|
|
212
213
|
try {
|
|
213
|
-
return
|
|
214
|
+
return JSON.parse(
|
|
215
|
+
await readBoundedText(
|
|
216
|
+
response,
|
|
217
|
+
MAX_BODY_SIZE,
|
|
218
|
+
url,
|
|
219
|
+
),
|
|
220
|
+
) as ResourceDescriptor;
|
|
214
221
|
} catch (e) {
|
|
222
|
+
if (e instanceof BodyTooLargeError) return null;
|
|
215
223
|
if (e instanceof SyntaxError) {
|
|
216
224
|
logger.debug(
|
|
217
225
|
"Failed to parse WebFinger resource descriptor as JSON: {error}",
|