@fedify/webfinger 2.3.6 → 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 +1 -1
- package/dist/lookup.test.cjs +97 -2
- package/dist/lookup.test.js +98 -3
- package/dist/mod.cjs +82 -2
- package/dist/mod.d.cts +4 -4
- package/dist/mod.d.ts +4 -4
- package/dist/mod.js +83 -3
- package/package.json +2 -2
- package/src/body.ts +95 -0
- package/src/lookup.test.ts +36 -0
- package/src/lookup.ts +16 -5
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.3.
|
|
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
|
|
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
|
package/dist/lookup.test.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
+
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
|
|
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
|
|
75
|
-
* redirect chain exceeded `maxRedirection`, the
|
|
76
|
-
* a different protocol, or the queried `acct:`
|
|
77
|
-
* malformed.
|
|
74
|
+
* into a {@link ResourceDescriptor} (JSON parse failure or a body over
|
|
75
|
+
* the size limit), the redirect chain exceeded `maxRedirection`, the
|
|
76
|
+
* remote redirected to a different protocol, or the queried `acct:`
|
|
77
|
+
* resource itself was malformed.
|
|
78
78
|
* - `network_error`: no HTTP response was received (the URL was
|
|
79
79
|
* rejected as a private address, `fetch()` threw, or an `AbortError`
|
|
80
80
|
* cancelled the request).
|
package/dist/mod.d.ts
CHANGED
|
@@ -71,10 +71,10 @@ interface Link {
|
|
|
71
71
|
* - `not_found`: the remote responded with HTTP `404 Not Found` or
|
|
72
72
|
* `410 Gone`. Recorded together with `http.response.status_code`.
|
|
73
73
|
* - `invalid`: the remote responded with content Fedify could not parse
|
|
74
|
-
* into a {@link ResourceDescriptor} (JSON parse failure
|
|
75
|
-
* redirect chain exceeded `maxRedirection`, the
|
|
76
|
-
* a different protocol, or the queried `acct:`
|
|
77
|
-
* malformed.
|
|
74
|
+
* into a {@link ResourceDescriptor} (JSON parse failure or a body over
|
|
75
|
+
* the size limit), the redirect chain exceeded `maxRedirection`, the
|
|
76
|
+
* remote redirected to a different protocol, or the queried `acct:`
|
|
77
|
+
* resource itself was malformed.
|
|
78
78
|
* - `network_error`: no HTTP response was received (the URL was
|
|
79
79
|
* rejected as a private address, `fetch()` threw, or an `AbortError`
|
|
80
80
|
* cancelled the request).
|
package/dist/mod.js
CHANGED
|
@@ -1,9 +1,83 @@
|
|
|
1
|
-
import { UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
|
|
1
|
+
import { FetchError, UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
|
|
2
2
|
import { getLogger } from "@logtape/logtape";
|
|
3
3
|
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
4
4
|
//#region deno.json
|
|
5
5
|
var name = "@fedify/webfinger";
|
|
6
|
-
var version = "2.3.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/lookup.test.ts
CHANGED
|
@@ -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
|
|
35
|
-
* redirect chain exceeded `maxRedirection`, the
|
|
36
|
-
* a different protocol, or the queried `acct:`
|
|
37
|
-
* malformed.
|
|
35
|
+
* into a {@link ResourceDescriptor} (JSON parse failure or a body over
|
|
36
|
+
* the size limit), the redirect chain exceeded `maxRedirection`, the
|
|
37
|
+
* remote redirected to a different protocol, or the queried `acct:`
|
|
38
|
+
* resource itself was malformed.
|
|
38
39
|
* - `network_error`: no HTTP response was received (the URL was
|
|
39
40
|
* rejected as a private address, `fetch()` threw, or an `AbortError`
|
|
40
41
|
* cancelled the request).
|
|
@@ -430,7 +431,9 @@ async function lookupWebFingerInternal(
|
|
|
430
431
|
};
|
|
431
432
|
}
|
|
432
433
|
try {
|
|
433
|
-
const parsed =
|
|
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}",
|