@fedify/vocab-runtime 2.0.28-dev.1974 → 2.0.28-dev.1982

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.
Files changed (42) hide show
  1. package/deno.json +1 -1
  2. package/dist/mod.cjs +160 -95
  3. package/dist/mod.js +160 -95
  4. package/dist/tests/body-CGaJrKL7.mjs +77 -0
  5. package/dist/tests/body-CtbBbd67.cjs +95 -0
  6. package/dist/tests/body.test.cjs +90 -0
  7. package/dist/tests/body.test.d.cts +1 -0
  8. package/dist/tests/body.test.d.mts +1 -0
  9. package/dist/tests/body.test.mjs +90 -0
  10. package/dist/tests/docloader.test.cjs +105 -34
  11. package/dist/tests/docloader.test.mjs +105 -34
  12. package/dist/tests/internal/multicodec.test.cjs +2 -2
  13. package/dist/tests/internal/multicodec.test.mjs +2 -2
  14. package/dist/tests/key.test.cjs +3 -3
  15. package/dist/tests/key.test.mjs +3 -3
  16. package/dist/tests/langstr.test.cjs +1 -1
  17. package/dist/tests/langstr.test.mjs +1 -1
  18. package/dist/tests/link.test.cjs +2 -2
  19. package/dist/tests/link.test.mjs +2 -2
  20. package/dist/tests/multibase/multibase.test.cjs +2 -2
  21. package/dist/tests/multibase/multibase.test.mjs +2 -2
  22. package/dist/tests/{request-C4ZUiyFP.mjs → request-ByxplZyY.mjs} +1 -1
  23. package/dist/tests/{request-DekYA6RV.cjs → request-Ir4zMwN8.cjs} +1 -1
  24. package/dist/tests/request.test.cjs +2 -2
  25. package/dist/tests/request.test.mjs +2 -2
  26. package/dist/tests/temporal.test.cjs +1 -1
  27. package/dist/tests/temporal.test.mjs +1 -1
  28. package/dist/tests/url.test.cjs +2 -2
  29. package/dist/tests/url.test.mjs +2 -2
  30. package/package.json +1 -1
  31. package/src/body.test.ts +125 -0
  32. package/src/body.ts +95 -0
  33. package/src/docloader.test.ts +124 -1
  34. package/src/docloader.ts +41 -43
  35. /package/dist/tests/{link-B6ZWBZhf.cjs → link-FguCydMA.cjs} +0 -0
  36. /package/dist/tests/{link-B8JGXSS2.mjs → link-NUUWCdnK.mjs} +0 -0
  37. /package/dist/tests/{multibase-CgYqpk4Z.cjs → multibase-CnmModw0.cjs} +0 -0
  38. /package/dist/tests/{multibase-jcKrOpuU.mjs → multibase-DLBufb99.mjs} +0 -0
  39. /package/dist/tests/{multicodec-DeYop8xg.cjs → multicodec-CxGVGa91.cjs} +0 -0
  40. /package/dist/tests/{multicodec-aqbZnrNi.mjs → multicodec-CyFp54fI.mjs} +0 -0
  41. /package/dist/tests/{url-CqwkmczT.cjs → url-CWU5HT3Y.cjs} +0 -0
  42. /package/dist/tests/{url-BulORwtj.mjs → url-o0e-aXQY.mjs} +0 -0
@@ -1,5 +1,5 @@
1
- import { deepStrictEqual } from "node:assert";
2
1
  import { test } from "node:test";
2
+ import { deepStrictEqual } from "node:assert";
3
3
  import util from "node:util";
4
4
  //#region src/langstr.ts
5
5
  /**
@@ -1,7 +1,7 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- const require_link = require("./link-B6ZWBZhf.cjs");
3
- let node_assert = require("node:assert");
2
+ const require_link = require("./link-FguCydMA.cjs");
4
3
  let node_test = require("node:test");
4
+ let node_assert = require("node:assert");
5
5
  //#region src/link.test.ts
6
6
  (0, node_test.test)("String Good 1", () => {
7
7
  const instance = new require_link.HttpHeaderLink(`<https://example.com>; rel="preconnect"`);
@@ -1,6 +1,6 @@
1
- import { t as HttpHeaderLink } from "./link-B8JGXSS2.mjs";
2
- import { deepStrictEqual, throws } from "node:assert";
1
+ import { t as HttpHeaderLink } from "./link-NUUWCdnK.mjs";
3
2
  import { test } from "node:test";
3
+ import { deepStrictEqual, throws } from "node:assert";
4
4
  //#region src/link.test.ts
5
5
  test("String Good 1", () => {
6
6
  const instance = new HttpHeaderLink(`<https://example.com>; rel="preconnect"`);
@@ -1,7 +1,7 @@
1
1
  require("../chunk-Do9eywBl.cjs");
2
- const require_multibase = require("../multibase-CgYqpk4Z.cjs");
3
- let node_assert = require("node:assert");
2
+ const require_multibase = require("../multibase-CnmModw0.cjs");
4
3
  let node_test = require("node:test");
4
+ let node_assert = require("node:assert");
5
5
  //#region src/multibase/multibase.test.ts
6
6
  (0, node_test.test)("multibase.encode and decode", async (t) => {
7
7
  const testCases = [
@@ -1,6 +1,6 @@
1
- import { a as decodeText, i as names, n as encodeMultibase, o as encodeText, r as codes, t as decodeMultibase } from "../multibase-jcKrOpuU.mjs";
2
- import { deepStrictEqual } from "node:assert";
1
+ import { a as decodeText, i as names, n as encodeMultibase, o as encodeText, r as codes, t as decodeMultibase } from "../multibase-DLBufb99.mjs";
3
2
  import { test } from "node:test";
3
+ import { deepStrictEqual } from "node:assert";
4
4
  //#region src/multibase/multibase.test.ts
5
5
  test("multibase.encode and decode", async (t) => {
6
6
  const testCases = [
@@ -1,7 +1,7 @@
1
1
  import process from "node:process";
2
2
  //#region deno.json
3
3
  var name = "@fedify/vocab-runtime";
4
- var version = "2.0.28-dev.1974+d70e3148";
4
+ var version = "2.0.28-dev.1982+a57dec3f";
5
5
  //#endregion
6
6
  //#region src/request.ts
7
7
  /**
@@ -3,7 +3,7 @@ let node_process = require("node:process");
3
3
  node_process = require_chunk.__toESM(node_process);
4
4
  //#region deno.json
5
5
  var name = "@fedify/vocab-runtime";
6
- var version = "2.0.28-dev.1974+d70e3148";
6
+ var version = "2.0.28-dev.1982+a57dec3f";
7
7
  //#endregion
8
8
  //#region src/request.ts
9
9
  /**
@@ -1,9 +1,9 @@
1
1
  const require_chunk = require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-DekYA6RV.cjs");
3
- let node_assert = require("node:assert");
2
+ const require_request = require("./request-Ir4zMwN8.cjs");
4
3
  let node_test = require("node:test");
5
4
  let node_process = require("node:process");
6
5
  node_process = require_chunk.__toESM(node_process);
6
+ let node_assert = require("node:assert");
7
7
  //#region src/request.test.ts
8
8
  (0, node_test.test)("getUserAgent()", () => {
9
9
  if ("Deno" in globalThis) {
@@ -1,7 +1,7 @@
1
- import { o as version, r as getUserAgent } from "./request-C4ZUiyFP.mjs";
2
- import { deepStrictEqual } from "node:assert";
1
+ import { o as version, r as getUserAgent } from "./request-ByxplZyY.mjs";
3
2
  import { test } from "node:test";
4
3
  import process from "node:process";
4
+ import { deepStrictEqual } from "node:assert";
5
5
  //#region src/request.test.ts
6
6
  test("getUserAgent()", () => {
7
7
  if ("Deno" in globalThis) {
@@ -1,6 +1,6 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- let node_assert = require("node:assert");
3
2
  let node_test = require("node:test");
3
+ let node_assert = require("node:assert");
4
4
  let _js_temporal_polyfill = require("@js-temporal/polyfill");
5
5
  //#region src/temporal.ts
6
6
  /**
@@ -1,5 +1,5 @@
1
- import { strictEqual } from "node:assert";
2
1
  import { test } from "node:test";
2
+ import { strictEqual } from "node:assert";
3
3
  import { Temporal } from "@js-temporal/polyfill";
4
4
  //#region src/temporal.ts
5
5
  /**
@@ -1,7 +1,7 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- const require_url = require("./url-CqwkmczT.cjs");
3
- let node_assert = require("node:assert");
2
+ const require_url = require("./url-CWU5HT3Y.cjs");
4
3
  let node_test = require("node:test");
4
+ let node_assert = require("node:assert");
5
5
  //#region src/url.test.ts
6
6
  (0, node_test.test)("validatePublicUrl()", async () => {
7
7
  await (0, node_assert.rejects)(() => require_url.validatePublicUrl("ftp://localhost"), require_url.UrlError);
@@ -1,6 +1,6 @@
1
- import { a as validateLookupAddresses, i as isValidPublicIPv6Address, n as expandIPv6Address, o as validatePublicUrl, r as isValidPublicIPv4Address, t as UrlError } from "./url-BulORwtj.mjs";
2
- import { deepStrictEqual, ok, rejects, throws } from "node:assert";
1
+ import { a as validateLookupAddresses, i as isValidPublicIPv6Address, n as expandIPv6Address, o as validatePublicUrl, r as isValidPublicIPv4Address, t as UrlError } from "./url-o0e-aXQY.mjs";
3
2
  import { test } from "node:test";
3
+ import { deepStrictEqual, ok, rejects, throws } from "node:assert";
4
4
  //#region src/url.test.ts
5
5
  test("validatePublicUrl()", async () => {
6
6
  await rejects(() => validatePublicUrl("ftp://localhost"), UrlError);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.0.28-dev.1974+d70e3148",
3
+ "version": "2.0.28-dev.1982+a57dec3f",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,125 @@
1
+ import { deepStrictEqual, ok, rejects } from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { gunzipSync, gzipSync } from "node:zlib";
4
+ import { BodyTooLargeError, readBoundedText } from "./body.ts";
5
+
6
+ const url = "https://example.com/document";
7
+
8
+ test("readBoundedText handles empty bodies and flushes incomplete UTF-8", async () => {
9
+ deepStrictEqual(await readBoundedText(new Response(null), 1, url), "");
10
+ deepStrictEqual(await readBoundedText(new Response(""), 1, url), "");
11
+ deepStrictEqual(
12
+ await readBoundedText(new Response(new Uint8Array([0xe2, 0x82])), 2, url),
13
+ "\ufffd",
14
+ );
15
+ });
16
+
17
+ test("readBoundedText counts bytes and preserves split UTF-8", async () => {
18
+ const bytes = new TextEncoder().encode('"안녕"');
19
+ const stream = new ReadableStream<Uint8Array>({
20
+ start(controller) {
21
+ for (const byte of bytes) controller.enqueue(new Uint8Array([byte]));
22
+ controller.close();
23
+ },
24
+ });
25
+ deepStrictEqual(
26
+ await readBoundedText(new Response(stream), bytes.length, url),
27
+ '"안녕"',
28
+ );
29
+ await rejects(
30
+ readBoundedText(new Response(bytes), bytes.length - 1, url),
31
+ BodyTooLargeError,
32
+ );
33
+ });
34
+
35
+ test("readBoundedText rejects a declared oversized body without pulling", async () => {
36
+ let pulled = false;
37
+ let canceled = false;
38
+ const response = new Response(
39
+ new ReadableStream({
40
+ pull() {
41
+ pulled = true;
42
+ },
43
+ cancel() {
44
+ canceled = true;
45
+ },
46
+ }, { highWaterMark: 0 }),
47
+ { headers: { "Content-Length": "100" } },
48
+ );
49
+ await rejects(readBoundedText(response, 10, url), BodyTooLargeError);
50
+ deepStrictEqual(pulled, false);
51
+ deepStrictEqual(canceled, true);
52
+ deepStrictEqual(response.body?.locked, false);
53
+ });
54
+
55
+ test("readBoundedText cancels chunked bodies despite absent or false lengths", async () => {
56
+ for (const length of [undefined, "1", "invalid"]) {
57
+ let pulls = 0;
58
+ let canceled = false;
59
+ const stream = new ReadableStream<Uint8Array>({
60
+ pull(controller) {
61
+ pulls++;
62
+ controller.enqueue(new Uint8Array(4));
63
+ },
64
+ cancel() {
65
+ canceled = true;
66
+ },
67
+ }, { highWaterMark: 0 });
68
+ const response = new Response(stream, {
69
+ headers: length == null ? {} : { "Content-Length": length },
70
+ });
71
+ await rejects(readBoundedText(response, 8, url), BodyTooLargeError);
72
+ deepStrictEqual(pulls, 3);
73
+ deepStrictEqual(canceled, true);
74
+ deepStrictEqual(response.body?.locked, false);
75
+ }
76
+ });
77
+
78
+ test("readBoundedText limits decoded gzip bytes, not the compressed length", async () => {
79
+ const compressed = gzipSync('"' + "a".repeat(4096) + '"');
80
+ ok(compressed.length < 128);
81
+ const decoded = gunzipSync(compressed);
82
+ await rejects(
83
+ readBoundedText(
84
+ new Response(decoded, {
85
+ headers: {
86
+ "Content-Encoding": "gzip",
87
+ "Content-Length": String(compressed.length),
88
+ },
89
+ }),
90
+ 128,
91
+ url,
92
+ ),
93
+ BodyTooLargeError,
94
+ );
95
+ // The encoded representation can also be larger than a valid decoded body.
96
+ deepStrictEqual(
97
+ await readBoundedText(
98
+ new Response("{}", {
99
+ headers: { "Content-Encoding": "gzip", "Content-Length": "22" },
100
+ }),
101
+ 2,
102
+ url,
103
+ ),
104
+ "{}",
105
+ );
106
+ });
107
+
108
+ test("readBoundedText rejects invalid limits and releases failed streams", async () => {
109
+ for (const limit of [0, -1, NaN, Infinity, 1.5]) {
110
+ await rejects(readBoundedText(new Response("{}"), limit, url), RangeError);
111
+ }
112
+ const failure = new Error("broken stream");
113
+ const response = new Response(
114
+ new ReadableStream({
115
+ pull(controller) {
116
+ controller.error(failure);
117
+ },
118
+ }),
119
+ );
120
+ await rejects(
121
+ readBoundedText(response, 10, url),
122
+ (error) => error === failure,
123
+ );
124
+ deepStrictEqual(response.body?.locked, false);
125
+ });
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 { getLogger } from "@logtape/logtape";
5
+ import { FetchError } from "./request.ts";
6
+
7
+ /** Decoded JSON body limit: 16 MiB. @internal */
8
+ export const MAX_BODY_SIZE: number = 16 * 1024 * 1024;
9
+
10
+ /** A body exceeded the byte limit. @internal */
11
+ export class BodyTooLargeError extends FetchError {
12
+ /** Creates an error for a body exceeding the given limit. */
13
+ constructor(url: string | URL, maxBytes: number) {
14
+ super(url, `Body exceeds the limit of ${maxBytes} bytes`);
15
+ this.name = "BodyTooLargeError";
16
+ }
17
+ }
18
+
19
+ /** Validates a finite, positive byte limit. @internal */
20
+ function validateBodySizeLimit(maxBytes: number): void {
21
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
22
+ throw new RangeError(
23
+ "The body size limit must be a positive safe integer.",
24
+ );
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Reads UTF-8 text while limiting bytes received from the body stream.
30
+ * Fetch responses are already decompressed, so the stream count is authoritative.
31
+ * @param message The response or request to read.
32
+ * @param maxBytes The maximum number of decoded bytes to read.
33
+ * @param url The URL to include in errors and logs.
34
+ * @returns The decoded body text.
35
+ * @throws {BodyTooLargeError} If the body exceeds the limit.
36
+ * @internal
37
+ */
38
+ export async function readBoundedText(
39
+ message: Pick<Request, "body" | "headers">,
40
+ maxBytes: number,
41
+ url: string | URL,
42
+ ): Promise<string> {
43
+ validateBodySizeLimit(maxBytes);
44
+ const reader = message.body?.getReader();
45
+ const tooLarge = (): never => {
46
+ getLogger(["fedify", "runtime", "body"]).warn(
47
+ "Body from {url} exceeds the limit of {maxBytes} bytes.",
48
+ { url: url.toString(), maxBytes },
49
+ );
50
+ throw new BodyTooLargeError(url, maxBytes);
51
+ };
52
+ try {
53
+ // Content-Length describes the encoded body when compression is used.
54
+ // Only use it as an early rejection for an unencoded body.
55
+ const length = message.headers.get("Content-Length");
56
+ const encoding = message.headers.get("Content-Encoding");
57
+ if (
58
+ (encoding == null || encoding.toLowerCase() === "identity") &&
59
+ length != null && /^\d+$/.test(length) && Number(length) > maxBytes
60
+ ) tooLarge();
61
+ if (reader == null) return "";
62
+ let size = 0;
63
+ const bounded = new ReadableStream<BufferSource>({
64
+ async pull(controller) {
65
+ const { done, value } = await reader.read();
66
+ if (done) {
67
+ controller.close();
68
+ return;
69
+ }
70
+ size += value.byteLength;
71
+ if (size > maxBytes) tooLarge();
72
+ controller.enqueue(value);
73
+ },
74
+ }, { highWaterMark: 0 });
75
+ const textReader = bounded.pipeThrough(new TextDecoderStream()).getReader();
76
+ try {
77
+ const chunks: string[] = [];
78
+ while (true) {
79
+ const { done, value } = await textReader.read();
80
+ if (done) break;
81
+ chunks.push(value);
82
+ }
83
+ return chunks.join("");
84
+ } finally {
85
+ textReader.releaseLock();
86
+ }
87
+ } catch (error) {
88
+ // A cloned inbox request is a tee. Awaiting one branch's cancellation
89
+ // would deadlock until the other branch is canceled by the inbox handler.
90
+ if (reader != null) void reader.cancel(error).catch(() => {});
91
+ throw error;
92
+ } finally {
93
+ reader?.releaseLock();
94
+ }
95
+ }
@@ -1,9 +1,11 @@
1
1
  import fetchMock from "fetch-mock";
2
2
  import { deepStrictEqual, ok, rejects } from "node:assert";
3
3
  import { test } from "node:test";
4
+ import { createServer } from "node:http";
5
+ import { gzipSync } from "node:zlib";
4
6
  import preloadedContexts from "./contexts.ts";
5
7
  import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
6
- import { getDocumentLoader } from "./docloader.ts";
8
+ import { getDocumentLoader, getRemoteDocument } from "./docloader.ts";
7
9
  import { FetchError } from "./request.ts";
8
10
  import { UrlError } from "./url.ts";
9
11
 
@@ -499,3 +501,124 @@ test("getDocumentLoader()", async (t) => {
499
501
 
500
502
  fetchMock.hardReset();
501
503
  });
504
+
505
+ test("getDocumentLoader() bounds JSON, HTML, and alternate documents", async () => {
506
+ const url = "https://example.com/bounded";
507
+ fetchMock.mockGlobal();
508
+ let oversized = true;
509
+ try {
510
+ fetchMock.get(url, () =>
511
+ new Response('{"name":"hello"}', {
512
+ headers: {
513
+ "Content-Type": "application/activity+json",
514
+ ...(oversized
515
+ ? { "Content-Length": String(16 * 1024 * 1024 + 1) }
516
+ : {}),
517
+ },
518
+ }));
519
+ const loader = getDocumentLoader({
520
+ allowPrivateAddress: true,
521
+ });
522
+ await rejects(loader(url), FetchError);
523
+ oversized = false;
524
+ deepStrictEqual((await loader(url)).document, { name: "hello" });
525
+ oversized = true;
526
+ fetchMock.get(
527
+ `${url}/html`,
528
+ () =>
529
+ new Response(" ".repeat(1024 * 1024 + 1), {
530
+ headers: { "Content-Type": "text/html" },
531
+ }),
532
+ );
533
+ await rejects(
534
+ getDocumentLoader({ allowPrivateAddress: true })(`${url}/html`),
535
+ FetchError,
536
+ );
537
+ fetchMock.get(`${url}/alternate`, () =>
538
+ new Response("", {
539
+ headers: {
540
+ "Content-Type": "text/html",
541
+ Link: `<${url}>; rel="alternate"; type="application/activity+json"`,
542
+ },
543
+ }));
544
+ await rejects(loader(`${url}/alternate`), FetchError);
545
+ fetchMock.get(`${url}/redirect`, {
546
+ status: 302,
547
+ headers: { Location: url },
548
+ });
549
+ await rejects(loader(`${url}/redirect`), FetchError);
550
+ } finally {
551
+ fetchMock.hardReset();
552
+ }
553
+ });
554
+
555
+ test("getDocumentLoader() rejects oversized gzip responses from fetch", async () => {
556
+ const document = { name: "a".repeat(16 * 1024 * 1024) };
557
+ const compressed = gzipSync(JSON.stringify(document));
558
+ ok(compressed.byteLength < 64 * 1024);
559
+ const server = createServer((request, response) => {
560
+ const body = request.url === "/small"
561
+ ? gzipSync('{"name":"hello"}')
562
+ : compressed;
563
+ response.writeHead(200, {
564
+ "Content-Type": "application/activity+json",
565
+ "Content-Encoding": "gzip",
566
+ "Content-Length": body.byteLength,
567
+ });
568
+ response.end(body);
569
+ });
570
+ await new Promise<void>((resolve, reject) => {
571
+ server.once("error", reject);
572
+ server.listen(0, "127.0.0.1", resolve);
573
+ });
574
+ try {
575
+ const address = server.address();
576
+ ok(address != null && typeof address !== "string");
577
+ const url = `http://127.0.0.1:${address.port}/document`;
578
+ await rejects(
579
+ getDocumentLoader({
580
+ allowPrivateAddress: true,
581
+ })(url),
582
+ FetchError,
583
+ );
584
+ deepStrictEqual(
585
+ (await getDocumentLoader({
586
+ allowPrivateAddress: true,
587
+ })(new URL("/small", url).href)).document,
588
+ { name: "hello" },
589
+ );
590
+ } finally {
591
+ await new Promise<void>((resolve, reject) => {
592
+ server.close((error) => error == null ? resolve() : reject(error));
593
+ server.closeAllConnections();
594
+ });
595
+ }
596
+ });
597
+
598
+ test("getRemoteDocument() bounds JSON by default", async () => {
599
+ let canceled = false;
600
+ let pulls = 0;
601
+ const response = new Response(
602
+ new ReadableStream({
603
+ pull(controller) {
604
+ pulls++;
605
+ controller.enqueue(new Uint8Array(64 * 1024).fill(32));
606
+ },
607
+ cancel() {
608
+ canceled = true;
609
+ },
610
+ }, { highWaterMark: 0 }),
611
+ );
612
+ await rejects(
613
+ getRemoteDocument(
614
+ "https://example.com/document",
615
+ response,
616
+ () => {
617
+ throw new Error("Unexpected alternate document");
618
+ },
619
+ ),
620
+ FetchError,
621
+ );
622
+ deepStrictEqual(canceled, true);
623
+ deepStrictEqual(pulls, 257);
624
+ });
package/src/docloader.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
2
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
3
3
  import metadata from "../deno.json" with { type: "json" };
4
+ import { MAX_BODY_SIZE, readBoundedText } from "./body.ts";
4
5
  import preloadedContexts from "./contexts.ts";
5
6
  import { HttpHeaderLink } from "./link.ts";
6
7
  import {
@@ -194,55 +195,52 @@ export async function getRemoteDocument(
194
195
  ) {
195
196
  // Security: Limit HTML response size to mitigate ReDoS attacks
196
197
  const MAX_HTML_SIZE = 1024 * 1024; // 1MB
197
- const html = await response.text();
198
- if (html.length > MAX_HTML_SIZE) {
199
- logger.warn(
200
- "HTML response too large, skipping alternate link discovery: {url}",
201
- { url: documentUrl, size: html.length },
202
- );
203
- document = JSON.parse(html);
204
- } else {
205
- // Safe regex patterns without nested quantifiers to prevent ReDoS
206
- // (CVE-2025-68475)
207
- // Step 1: Extract <a ...> or <link ...> tags
208
- const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
209
- // Step 2: Parse attributes
210
- const attrPattern =
211
- /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
198
+ const html = await readBoundedText(
199
+ response,
200
+ MAX_HTML_SIZE,
201
+ documentUrl,
202
+ );
203
+ // Safe regex patterns without nested quantifiers to prevent ReDoS
204
+ // (CVE-2025-68475)
205
+ // Step 1: Extract <a ...> or <link ...> tags
206
+ const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
207
+ // Step 2: Parse attributes
208
+ const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
212
209
 
213
- let tagMatch: RegExpExecArray | null;
214
- while ((tagMatch = tagPattern.exec(html)) !== null) {
215
- const tagContent = tagMatch[2];
216
- let attrMatch: RegExpExecArray | null;
217
- const attribs: Record<string, string> = {};
210
+ let tagMatch: RegExpExecArray | null;
211
+ while ((tagMatch = tagPattern.exec(html)) !== null) {
212
+ const tagContent = tagMatch[2];
213
+ let attrMatch: RegExpExecArray | null;
214
+ const attribs: Record<string, string> = {};
218
215
 
219
- // Reset regex state for attribute parsing
220
- attrPattern.lastIndex = 0;
221
- while ((attrMatch = attrPattern.exec(tagContent)) !== null) {
222
- const key = attrMatch[1].toLowerCase();
223
- const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
224
- attribs[key] = value;
225
- }
216
+ // Reset regex state for attribute parsing
217
+ attrPattern.lastIndex = 0;
218
+ while ((attrMatch = attrPattern.exec(tagContent)) !== null) {
219
+ const key = attrMatch[1].toLowerCase();
220
+ const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
221
+ attribs[key] = value;
222
+ }
226
223
 
227
- if (
228
- attribs.rel === "alternate" && "type" in attribs && (
229
- attribs.type === "application/activity+json" ||
230
- attribs.type === "application/ld+json" ||
231
- attribs.type.startsWith("application/ld+json;")
232
- ) && "href" in attribs &&
233
- new URL(attribs.href, docUrl).href !== docUrl.href
234
- ) {
235
- logger.debug(
236
- "Found alternate document: {alternateUrl} from {url}",
237
- { alternateUrl: attribs.href, url: documentUrl },
238
- );
239
- return await fetch(new URL(attribs.href, docUrl).href);
240
- }
224
+ if (
225
+ attribs.rel === "alternate" && "type" in attribs && (
226
+ attribs.type === "application/activity+json" ||
227
+ attribs.type === "application/ld+json" ||
228
+ attribs.type.startsWith("application/ld+json;")
229
+ ) && "href" in attribs &&
230
+ new URL(attribs.href, docUrl).href !== docUrl.href
231
+ ) {
232
+ logger.debug(
233
+ "Found alternate document: {alternateUrl} from {url}",
234
+ { alternateUrl: attribs.href, url: documentUrl },
235
+ );
236
+ return await fetch(new URL(attribs.href, docUrl).href);
241
237
  }
242
- document = JSON.parse(html);
243
238
  }
239
+ document = JSON.parse(html);
244
240
  } else {
245
- document = await response.json();
241
+ document = JSON.parse(
242
+ await readBoundedText(response, MAX_BODY_SIZE, documentUrl),
243
+ );
246
244
  }
247
245
  logger.debug(
248
246
  "Fetched document: {status} {url} {headers}",