@fedify/webfinger 2.4.0-dev.1964 → 2.4.0-dev.1995
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
|
@@ -1235,7 +1235,81 @@ var esm_default = new class FetchMock {
|
|
|
1235
1235
|
//#endregion
|
|
1236
1236
|
//#region deno.json
|
|
1237
1237
|
var name = "@fedify/webfinger";
|
|
1238
|
-
var version = "2.4.0-dev.
|
|
1238
|
+
var version = "2.4.0-dev.1995+618fd4e7";
|
|
1239
|
+
//#endregion
|
|
1240
|
+
//#region src/body.ts
|
|
1241
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
1242
|
+
const MAX_BODY_SIZE = 16 * 1024 * 1024;
|
|
1243
|
+
/** A body exceeded the byte limit. @internal */
|
|
1244
|
+
var BodyTooLargeError = class extends _fedify_vocab_runtime.FetchError {
|
|
1245
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
1246
|
+
constructor(url, maxBytes) {
|
|
1247
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
1248
|
+
this.name = "BodyTooLargeError";
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
1252
|
+
function validateBodySizeLimit(maxBytes) {
|
|
1253
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
1257
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
1258
|
+
* @param message The response or request to read.
|
|
1259
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
1260
|
+
* @param url The URL to include in errors and logs.
|
|
1261
|
+
* @returns The decoded body text.
|
|
1262
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
1263
|
+
* @internal
|
|
1264
|
+
*/
|
|
1265
|
+
async function readBoundedText(message, maxBytes, url) {
|
|
1266
|
+
validateBodySizeLimit(maxBytes);
|
|
1267
|
+
const reader = message.body?.getReader();
|
|
1268
|
+
const tooLarge = () => {
|
|
1269
|
+
(0, _logtape_logtape.getLogger)([
|
|
1270
|
+
"fedify",
|
|
1271
|
+
"runtime",
|
|
1272
|
+
"body"
|
|
1273
|
+
]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
|
|
1274
|
+
url: url.toString(),
|
|
1275
|
+
maxBytes
|
|
1276
|
+
});
|
|
1277
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
1278
|
+
};
|
|
1279
|
+
try {
|
|
1280
|
+
const length = message.headers.get("Content-Length");
|
|
1281
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
1282
|
+
if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
|
|
1283
|
+
if (reader == null) return "";
|
|
1284
|
+
let size = 0;
|
|
1285
|
+
const textReader = new ReadableStream({ async pull(controller) {
|
|
1286
|
+
const { done, value } = await reader.read();
|
|
1287
|
+
if (done) {
|
|
1288
|
+
controller.close();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
size += value.byteLength;
|
|
1292
|
+
if (size > maxBytes) tooLarge();
|
|
1293
|
+
controller.enqueue(value);
|
|
1294
|
+
} }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
|
|
1295
|
+
try {
|
|
1296
|
+
const chunks = [];
|
|
1297
|
+
while (true) {
|
|
1298
|
+
const { done, value } = await textReader.read();
|
|
1299
|
+
if (done) break;
|
|
1300
|
+
chunks.push(value);
|
|
1301
|
+
}
|
|
1302
|
+
return chunks.join("");
|
|
1303
|
+
} finally {
|
|
1304
|
+
textReader.releaseLock();
|
|
1305
|
+
}
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
if (reader != null) reader.cancel(error).catch(() => {});
|
|
1308
|
+
throw error;
|
|
1309
|
+
} finally {
|
|
1310
|
+
reader?.releaseLock();
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1239
1313
|
//#endregion
|
|
1240
1314
|
//#region src/lookup.ts
|
|
1241
1315
|
const logger = (0, _logtape_logtape.getLogger)([
|
|
@@ -1466,12 +1540,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1466
1540
|
}
|
|
1467
1541
|
try {
|
|
1468
1542
|
return {
|
|
1469
|
-
resource: await response
|
|
1543
|
+
resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
|
|
1470
1544
|
result: "found",
|
|
1471
1545
|
statusCode: response.status,
|
|
1472
1546
|
remoteHost
|
|
1473
1547
|
};
|
|
1474
1548
|
} catch (e) {
|
|
1549
|
+
if (e instanceof BodyTooLargeError) return {
|
|
1550
|
+
resource: null,
|
|
1551
|
+
result: "invalid",
|
|
1552
|
+
statusCode: response.status,
|
|
1553
|
+
remoteHost
|
|
1554
|
+
};
|
|
1475
1555
|
if (e instanceof SyntaxError) {
|
|
1476
1556
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
1477
1557
|
return {
|
|
@@ -1901,4 +1981,19 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1901
1981
|
esm_default.hardReset();
|
|
1902
1982
|
}
|
|
1903
1983
|
});
|
|
1984
|
+
(0, _fedify_fixture.test)("lookupWebFinger() bounds resource descriptors", async () => {
|
|
1985
|
+
esm_default.mockGlobal();
|
|
1986
|
+
let oversized = true;
|
|
1987
|
+
try {
|
|
1988
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
|
|
1989
|
+
"Content-Type": "application/jrd+json",
|
|
1990
|
+
"Content-Length": "1"
|
|
1991
|
+
} }));
|
|
1992
|
+
(0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
|
|
1993
|
+
oversized = false;
|
|
1994
|
+
(0, node_assert_strict.deepStrictEqual)(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
|
|
1995
|
+
} finally {
|
|
1996
|
+
esm_default.hardReset();
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1904
1999
|
//#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
|
|
@@ -1234,7 +1234,81 @@ var esm_default = new class FetchMock {
|
|
|
1234
1234
|
//#endregion
|
|
1235
1235
|
//#region deno.json
|
|
1236
1236
|
var name = "@fedify/webfinger";
|
|
1237
|
-
var version = "2.4.0-dev.
|
|
1237
|
+
var version = "2.4.0-dev.1995+618fd4e7";
|
|
1238
|
+
//#endregion
|
|
1239
|
+
//#region src/body.ts
|
|
1240
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
1241
|
+
const MAX_BODY_SIZE = 16 * 1024 * 1024;
|
|
1242
|
+
/** A body exceeded the byte limit. @internal */
|
|
1243
|
+
var BodyTooLargeError = class extends FetchError {
|
|
1244
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
1245
|
+
constructor(url, maxBytes) {
|
|
1246
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
1247
|
+
this.name = "BodyTooLargeError";
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
1251
|
+
function validateBodySizeLimit(maxBytes) {
|
|
1252
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new RangeError("The body size limit must be a positive safe integer.");
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
1256
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
1257
|
+
* @param message The response or request to read.
|
|
1258
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
1259
|
+
* @param url The URL to include in errors and logs.
|
|
1260
|
+
* @returns The decoded body text.
|
|
1261
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
1262
|
+
* @internal
|
|
1263
|
+
*/
|
|
1264
|
+
async function readBoundedText(message, maxBytes, url) {
|
|
1265
|
+
validateBodySizeLimit(maxBytes);
|
|
1266
|
+
const reader = message.body?.getReader();
|
|
1267
|
+
const tooLarge = () => {
|
|
1268
|
+
getLogger([
|
|
1269
|
+
"fedify",
|
|
1270
|
+
"runtime",
|
|
1271
|
+
"body"
|
|
1272
|
+
]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
|
|
1273
|
+
url: url.toString(),
|
|
1274
|
+
maxBytes
|
|
1275
|
+
});
|
|
1276
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
1277
|
+
};
|
|
1278
|
+
try {
|
|
1279
|
+
const length = message.headers.get("Content-Length");
|
|
1280
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
1281
|
+
if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
|
|
1282
|
+
if (reader == null) return "";
|
|
1283
|
+
let size = 0;
|
|
1284
|
+
const textReader = new ReadableStream({ async pull(controller) {
|
|
1285
|
+
const { done, value } = await reader.read();
|
|
1286
|
+
if (done) {
|
|
1287
|
+
controller.close();
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
size += value.byteLength;
|
|
1291
|
+
if (size > maxBytes) tooLarge();
|
|
1292
|
+
controller.enqueue(value);
|
|
1293
|
+
} }, { highWaterMark: 0 }).pipeThrough(new TextDecoderStream()).getReader();
|
|
1294
|
+
try {
|
|
1295
|
+
const chunks = [];
|
|
1296
|
+
while (true) {
|
|
1297
|
+
const { done, value } = await textReader.read();
|
|
1298
|
+
if (done) break;
|
|
1299
|
+
chunks.push(value);
|
|
1300
|
+
}
|
|
1301
|
+
return chunks.join("");
|
|
1302
|
+
} finally {
|
|
1303
|
+
textReader.releaseLock();
|
|
1304
|
+
}
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
if (reader != null) reader.cancel(error).catch(() => {});
|
|
1307
|
+
throw error;
|
|
1308
|
+
} finally {
|
|
1309
|
+
reader?.releaseLock();
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1238
1312
|
//#endregion
|
|
1239
1313
|
//#region src/lookup.ts
|
|
1240
1314
|
const logger = getLogger([
|
|
@@ -1465,12 +1539,18 @@ async function lookupWebFingerInternal(resource, options = {}) {
|
|
|
1465
1539
|
}
|
|
1466
1540
|
try {
|
|
1467
1541
|
return {
|
|
1468
|
-
resource: await response
|
|
1542
|
+
resource: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)),
|
|
1469
1543
|
result: "found",
|
|
1470
1544
|
statusCode: response.status,
|
|
1471
1545
|
remoteHost
|
|
1472
1546
|
};
|
|
1473
1547
|
} catch (e) {
|
|
1548
|
+
if (e instanceof BodyTooLargeError) return {
|
|
1549
|
+
resource: null,
|
|
1550
|
+
result: "invalid",
|
|
1551
|
+
statusCode: response.status,
|
|
1552
|
+
remoteHost
|
|
1553
|
+
};
|
|
1474
1554
|
if (e instanceof SyntaxError) {
|
|
1475
1555
|
logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
|
|
1476
1556
|
return {
|
|
@@ -1900,5 +1980,20 @@ test("lookupWebFinger() records webfinger.lookup counter and duration", {
|
|
|
1900
1980
|
esm_default.hardReset();
|
|
1901
1981
|
}
|
|
1902
1982
|
});
|
|
1983
|
+
test("lookupWebFinger() bounds resource descriptors", async () => {
|
|
1984
|
+
esm_default.mockGlobal();
|
|
1985
|
+
let oversized = true;
|
|
1986
|
+
try {
|
|
1987
|
+
esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Response("{\"subject\":\"acct:alice@example.com\"}" + (oversized ? " ".repeat(16 * 1024 * 1024) : ""), { headers: {
|
|
1988
|
+
"Content-Type": "application/jrd+json",
|
|
1989
|
+
"Content-Length": "1"
|
|
1990
|
+
} }));
|
|
1991
|
+
deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), null);
|
|
1992
|
+
oversized = false;
|
|
1993
|
+
deepStrictEqual(await lookupWebFinger("acct:alice@example.com", { allowPrivateAddress: true }), { subject: "acct:alice@example.com" });
|
|
1994
|
+
} finally {
|
|
1995
|
+
esm_default.hardReset();
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1903
1998
|
//#endregion
|
|
1904
1999
|
export {};
|
package/dist/mod.cjs
CHANGED
|
@@ -4,7 +4,81 @@ let _logtape_logtape = require("@logtape/logtape");
|
|
|
4
4
|
let _opentelemetry_api = require("@opentelemetry/api");
|
|
5
5
|
//#region deno.json
|
|
6
6
|
var name = "@fedify/webfinger";
|
|
7
|
-
var version = "2.4.0-dev.
|
|
7
|
+
var version = "2.4.0-dev.1995+618fd4e7";
|
|
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.4.0-dev.
|
|
6
|
+
var version = "2.4.0-dev.1995+618fd4e7";
|
|
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.4.0-dev.
|
|
3
|
+
"version": "2.4.0-dev.1995+618fd4e7",
|
|
4
4
|
"homepage": "https://fedify.dev/",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@logtape/logtape": "^2.2.0",
|
|
58
58
|
"@opentelemetry/api": "^1.9.1",
|
|
59
59
|
"es-toolkit": "1.46.1",
|
|
60
|
-
"@fedify/vocab-runtime": "2.4.0-dev.
|
|
60
|
+
"@fedify/vocab-runtime": "2.4.0-dev.1995+618fd4e7"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build:self": "tsdown",
|
package/src/body.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Kept private in each package to avoid adding public API in a patch release.
|
|
2
|
+
// TODO(Jiwon Kwon): Consolidate the duplicated body helpers in @fedify/vocab-runtime,
|
|
3
|
+
// @fedify/webfinger, and @fedify/fedify into a shared implementation.
|
|
4
|
+
import { FetchError } from "@fedify/vocab-runtime";
|
|
5
|
+
import { getLogger } from "@logtape/logtape";
|
|
6
|
+
|
|
7
|
+
/** Decoded JSON body limit: 16 MiB. @internal */
|
|
8
|
+
export const MAX_BODY_SIZE: number = 16 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
/** A body exceeded the byte limit. @internal */
|
|
11
|
+
export class BodyTooLargeError extends FetchError {
|
|
12
|
+
/** Creates an error for a body exceeding the given limit. */
|
|
13
|
+
constructor(url: string | URL, maxBytes: number) {
|
|
14
|
+
super(url, `Body exceeds the limit of ${maxBytes} bytes`);
|
|
15
|
+
this.name = "BodyTooLargeError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validates a finite, positive byte limit. @internal */
|
|
20
|
+
function validateBodySizeLimit(maxBytes: number): void {
|
|
21
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
22
|
+
throw new RangeError(
|
|
23
|
+
"The body size limit must be a positive safe integer.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads UTF-8 text while limiting bytes received from the body stream.
|
|
30
|
+
* Fetch responses are already decompressed, so the stream count is authoritative.
|
|
31
|
+
* @param message The response or request to read.
|
|
32
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
33
|
+
* @param url The URL to include in errors and logs.
|
|
34
|
+
* @returns The decoded body text.
|
|
35
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
export async function readBoundedText(
|
|
39
|
+
message: Pick<Request, "body" | "headers">,
|
|
40
|
+
maxBytes: number,
|
|
41
|
+
url: string | URL,
|
|
42
|
+
): Promise<string> {
|
|
43
|
+
validateBodySizeLimit(maxBytes);
|
|
44
|
+
const reader = message.body?.getReader();
|
|
45
|
+
const tooLarge = (): never => {
|
|
46
|
+
getLogger(["fedify", "runtime", "body"]).warn(
|
|
47
|
+
"Body from {url} exceeds the limit of {maxBytes} bytes.",
|
|
48
|
+
{ url: url.toString(), maxBytes },
|
|
49
|
+
);
|
|
50
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
// Content-Length describes the encoded body when compression is used.
|
|
54
|
+
// Only use it as an early rejection for an unencoded body.
|
|
55
|
+
const length = message.headers.get("Content-Length");
|
|
56
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
57
|
+
if (
|
|
58
|
+
(encoding == null || encoding.toLowerCase() === "identity") &&
|
|
59
|
+
length != null && /^\d+$/.test(length) && Number(length) > maxBytes
|
|
60
|
+
) tooLarge();
|
|
61
|
+
if (reader == null) return "";
|
|
62
|
+
let size = 0;
|
|
63
|
+
const bounded = new ReadableStream<BufferSource>({
|
|
64
|
+
async pull(controller) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done) {
|
|
67
|
+
controller.close();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
size += value.byteLength;
|
|
71
|
+
if (size > maxBytes) tooLarge();
|
|
72
|
+
controller.enqueue(value);
|
|
73
|
+
},
|
|
74
|
+
}, { highWaterMark: 0 });
|
|
75
|
+
const textReader = bounded.pipeThrough(new TextDecoderStream()).getReader();
|
|
76
|
+
try {
|
|
77
|
+
const chunks: string[] = [];
|
|
78
|
+
while (true) {
|
|
79
|
+
const { done, value } = await textReader.read();
|
|
80
|
+
if (done) break;
|
|
81
|
+
chunks.push(value);
|
|
82
|
+
}
|
|
83
|
+
return chunks.join("");
|
|
84
|
+
} finally {
|
|
85
|
+
textReader.releaseLock();
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// A cloned inbox request is a tee. Awaiting one branch's cancellation
|
|
89
|
+
// would deadlock until the other branch is canceled by the inbox handler.
|
|
90
|
+
if (reader != null) void reader.cancel(error).catch(() => {});
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
reader?.releaseLock();
|
|
94
|
+
}
|
|
95
|
+
}
|
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}",
|