@fedify/vocab-runtime 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.
Files changed (50) hide show
  1. package/deno.json +1 -1
  2. package/dist/mod.cjs +166 -101
  3. package/dist/mod.js +166 -101
  4. package/dist/tests/body-B82RILk5.mjs +77 -0
  5. package/dist/tests/body-CKFs5kO9.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/decimal.test.cjs +6 -6
  11. package/dist/tests/decimal.test.mjs +6 -6
  12. package/dist/tests/{docloader-CWKdF29U.mjs → docloader-BetS1A0I.mjs} +26 -34
  13. package/dist/tests/{docloader-DprR2N-C.cjs → docloader-DfLdNMgM.cjs} +31 -33
  14. package/dist/tests/docloader.test.cjs +83 -4
  15. package/dist/tests/docloader.test.mjs +83 -4
  16. package/dist/tests/internal/multicodec.test.cjs +2 -2
  17. package/dist/tests/internal/multicodec.test.mjs +2 -2
  18. package/dist/tests/{key-pMmqUKuo.cjs → key-CWDxavIR.cjs} +2 -2
  19. package/dist/tests/{key-CrrK9mYh.mjs → key-Qj7u1qmH.mjs} +2 -2
  20. package/dist/tests/key.test.cjs +3 -3
  21. package/dist/tests/key.test.mjs +3 -3
  22. package/dist/tests/langstr.test.cjs +2 -2
  23. package/dist/tests/langstr.test.mjs +2 -2
  24. package/dist/tests/link.test.cjs +2 -2
  25. package/dist/tests/link.test.mjs +2 -2
  26. package/dist/tests/multibase/multibase.test.cjs +2 -2
  27. package/dist/tests/multibase/multibase.test.mjs +2 -2
  28. package/dist/tests/{request-D4Rca6WD.mjs → request-Borhn2tu.mjs} +1 -1
  29. package/dist/tests/{request-CfJdvXUH.cjs → request-FmFI0z5J.cjs} +1 -1
  30. package/dist/tests/request.test.cjs +2 -2
  31. package/dist/tests/request.test.mjs +2 -2
  32. package/dist/tests/temporal.test.cjs +1 -1
  33. package/dist/tests/temporal.test.mjs +1 -1
  34. package/dist/tests/url.test.cjs +2 -2
  35. package/dist/tests/url.test.mjs +2 -2
  36. package/package.json +1 -1
  37. package/src/body.test.ts +125 -0
  38. package/src/body.ts +95 -0
  39. package/src/docloader.test.ts +124 -1
  40. package/src/docloader.ts +41 -43
  41. /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-BOQHfJDr.cjs} +0 -0
  42. /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-DDLc4833.mjs} +0 -0
  43. /package/dist/tests/{link-NUUWCdnK.mjs → link-CYszIegt.mjs} +0 -0
  44. /package/dist/tests/{link-FguCydMA.cjs → link-qDTmjPw_.cjs} +0 -0
  45. /package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-BL45eko3.cjs} +0 -0
  46. /package/dist/tests/{multibase-B4bvakyA.mjs → multibase-Da9VJCw8.mjs} +0 -0
  47. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-BBPZjRfg.mjs} +0 -0
  48. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-ByS9XLsw.cjs} +0 -0
  49. /package/dist/tests/{url-o0e-aXQY.mjs → url-BBfduh6o.mjs} +0 -0
  50. /package/dist/tests/{url-BF_Nb_Cu.cjs → url-BbEC9H_R.cjs} +0 -0
@@ -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
 
@@ -518,3 +520,124 @@ test("getDocumentLoader()", async (t) => {
518
520
 
519
521
  fetchMock.hardReset();
520
522
  });
523
+
524
+ test("getDocumentLoader() bounds JSON, HTML, and alternate documents", async () => {
525
+ const url = "https://example.com/bounded";
526
+ fetchMock.mockGlobal();
527
+ let oversized = true;
528
+ try {
529
+ fetchMock.get(url, () =>
530
+ new Response('{"name":"hello"}', {
531
+ headers: {
532
+ "Content-Type": "application/activity+json",
533
+ ...(oversized
534
+ ? { "Content-Length": String(16 * 1024 * 1024 + 1) }
535
+ : {}),
536
+ },
537
+ }));
538
+ const loader = getDocumentLoader({
539
+ allowPrivateAddress: true,
540
+ });
541
+ await rejects(loader(url), FetchError);
542
+ oversized = false;
543
+ deepStrictEqual((await loader(url)).document, { name: "hello" });
544
+ oversized = true;
545
+ fetchMock.get(
546
+ `${url}/html`,
547
+ () =>
548
+ new Response(" ".repeat(1024 * 1024 + 1), {
549
+ headers: { "Content-Type": "text/html" },
550
+ }),
551
+ );
552
+ await rejects(
553
+ getDocumentLoader({ allowPrivateAddress: true })(`${url}/html`),
554
+ FetchError,
555
+ );
556
+ fetchMock.get(`${url}/alternate`, () =>
557
+ new Response("", {
558
+ headers: {
559
+ "Content-Type": "text/html",
560
+ Link: `<${url}>; rel="alternate"; type="application/activity+json"`,
561
+ },
562
+ }));
563
+ await rejects(loader(`${url}/alternate`), FetchError);
564
+ fetchMock.get(`${url}/redirect`, {
565
+ status: 302,
566
+ headers: { Location: url },
567
+ });
568
+ await rejects(loader(`${url}/redirect`), FetchError);
569
+ } finally {
570
+ fetchMock.hardReset();
571
+ }
572
+ });
573
+
574
+ test("getDocumentLoader() rejects oversized gzip responses from fetch", async () => {
575
+ const document = { name: "a".repeat(16 * 1024 * 1024) };
576
+ const compressed = gzipSync(JSON.stringify(document));
577
+ ok(compressed.byteLength < 64 * 1024);
578
+ const server = createServer((request, response) => {
579
+ const body = request.url === "/small"
580
+ ? gzipSync('{"name":"hello"}')
581
+ : compressed;
582
+ response.writeHead(200, {
583
+ "Content-Type": "application/activity+json",
584
+ "Content-Encoding": "gzip",
585
+ "Content-Length": body.byteLength,
586
+ });
587
+ response.end(body);
588
+ });
589
+ await new Promise<void>((resolve, reject) => {
590
+ server.once("error", reject);
591
+ server.listen(0, "127.0.0.1", resolve);
592
+ });
593
+ try {
594
+ const address = server.address();
595
+ ok(address != null && typeof address !== "string");
596
+ const url = `http://127.0.0.1:${address.port}/document`;
597
+ await rejects(
598
+ getDocumentLoader({
599
+ allowPrivateAddress: true,
600
+ })(url),
601
+ FetchError,
602
+ );
603
+ deepStrictEqual(
604
+ (await getDocumentLoader({
605
+ allowPrivateAddress: true,
606
+ })(new URL("/small", url).href)).document,
607
+ { name: "hello" },
608
+ );
609
+ } finally {
610
+ await new Promise<void>((resolve, reject) => {
611
+ server.close((error) => error == null ? resolve() : reject(error));
612
+ server.closeAllConnections();
613
+ });
614
+ }
615
+ });
616
+
617
+ test("getRemoteDocument() bounds JSON by default", async () => {
618
+ let canceled = false;
619
+ let pulls = 0;
620
+ const response = new Response(
621
+ new ReadableStream({
622
+ pull(controller) {
623
+ pulls++;
624
+ controller.enqueue(new Uint8Array(64 * 1024).fill(32));
625
+ },
626
+ cancel() {
627
+ canceled = true;
628
+ },
629
+ }, { highWaterMark: 0 }),
630
+ );
631
+ await rejects(
632
+ getRemoteDocument(
633
+ "https://example.com/document",
634
+ response,
635
+ () => {
636
+ throw new Error("Unexpected alternate document");
637
+ },
638
+ ),
639
+ FetchError,
640
+ );
641
+ deepStrictEqual(canceled, true);
642
+ deepStrictEqual(pulls, 257);
643
+ });
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 {
@@ -202,55 +203,52 @@ export async function getRemoteDocument(
202
203
  ) {
203
204
  // Security: Limit HTML response size to mitigate ReDoS attacks
204
205
  const MAX_HTML_SIZE = 1024 * 1024; // 1MB
205
- const html = await response.text();
206
- if (html.length > MAX_HTML_SIZE) {
207
- logger.warn(
208
- "HTML response too large, skipping alternate link discovery: {url}",
209
- { url: documentUrl, size: html.length },
210
- );
211
- document = JSON.parse(html);
212
- } else {
213
- // Safe regex patterns without nested quantifiers to prevent ReDoS
214
- // (CVE-2025-68475)
215
- // Step 1: Extract <a ...> or <link ...> tags
216
- const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
217
- // Step 2: Parse attributes
218
- const attrPattern =
219
- /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
206
+ const html = await readBoundedText(
207
+ response,
208
+ MAX_HTML_SIZE,
209
+ documentUrl,
210
+ );
211
+ // Safe regex patterns without nested quantifiers to prevent ReDoS
212
+ // (CVE-2025-68475)
213
+ // Step 1: Extract <a ...> or <link ...> tags
214
+ const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
215
+ // Step 2: Parse attributes
216
+ const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
220
217
 
221
- let tagMatch: RegExpExecArray | null;
222
- while ((tagMatch = tagPattern.exec(html)) !== null) {
223
- const tagContent = tagMatch[2];
224
- let attrMatch: RegExpExecArray | null;
225
- const attribs: Record<string, string> = {};
218
+ let tagMatch: RegExpExecArray | null;
219
+ while ((tagMatch = tagPattern.exec(html)) !== null) {
220
+ const tagContent = tagMatch[2];
221
+ let attrMatch: RegExpExecArray | null;
222
+ const attribs: Record<string, string> = {};
226
223
 
227
- // Reset regex state for attribute parsing
228
- attrPattern.lastIndex = 0;
229
- while ((attrMatch = attrPattern.exec(tagContent)) !== null) {
230
- const key = attrMatch[1].toLowerCase();
231
- const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
232
- attribs[key] = value;
233
- }
224
+ // Reset regex state for attribute parsing
225
+ attrPattern.lastIndex = 0;
226
+ while ((attrMatch = attrPattern.exec(tagContent)) !== null) {
227
+ const key = attrMatch[1].toLowerCase();
228
+ const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
229
+ attribs[key] = value;
230
+ }
234
231
 
235
- if (
236
- attribs.rel === "alternate" && "type" in attribs && (
237
- attribs.type === "application/activity+json" ||
238
- attribs.type === "application/ld+json" ||
239
- attribs.type.startsWith("application/ld+json;")
240
- ) && "href" in attribs &&
241
- new URL(attribs.href, docUrl).href !== docUrl.href
242
- ) {
243
- logger.debug(
244
- "Found alternate document: {alternateUrl} from {url}",
245
- { alternateUrl: attribs.href, url: documentUrl },
246
- );
247
- return await fetch(new URL(attribs.href, docUrl).href);
248
- }
232
+ if (
233
+ attribs.rel === "alternate" && "type" in attribs && (
234
+ attribs.type === "application/activity+json" ||
235
+ attribs.type === "application/ld+json" ||
236
+ attribs.type.startsWith("application/ld+json;")
237
+ ) && "href" in attribs &&
238
+ new URL(attribs.href, docUrl).href !== docUrl.href
239
+ ) {
240
+ logger.debug(
241
+ "Found alternate document: {alternateUrl} from {url}",
242
+ { alternateUrl: attribs.href, url: documentUrl },
243
+ );
244
+ return await fetch(new URL(attribs.href, docUrl).href);
249
245
  }
250
- document = JSON.parse(html);
251
246
  }
247
+ document = JSON.parse(html);
252
248
  } else {
253
- document = await response.json();
249
+ document = JSON.parse(
250
+ await readBoundedText(response, MAX_BODY_SIZE, documentUrl),
251
+ );
254
252
  }
255
253
  logger.debug(
256
254
  "Fetched document: {status} {url} {headers}",