@fedify/vocab-runtime 2.1.23 → 2.1.25

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 (56) hide show
  1. package/deno.json +1 -1
  2. package/dist/mod.cjs +220 -107
  3. package/dist/mod.d.cts +10 -0
  4. package/dist/mod.d.ts +10 -0
  5. package/dist/mod.js +220 -108
  6. package/dist/tests/body-D-gC5vdl.cjs +95 -0
  7. package/dist/tests/body-DBqFojnW.mjs +77 -0
  8. package/dist/tests/body.test.cjs +90 -0
  9. package/dist/tests/body.test.d.cts +1 -0
  10. package/dist/tests/body.test.d.mts +1 -0
  11. package/dist/tests/body.test.mjs +90 -0
  12. package/dist/tests/decimal.test.cjs +6 -6
  13. package/dist/tests/decimal.test.mjs +6 -6
  14. package/dist/tests/{docloader-D-CBVlrJ.mjs → docloader-Beoc8qTH.mjs} +66 -37
  15. package/dist/tests/{docloader-BcWmxXdo.cjs → docloader-fn4ICzFS.cjs} +71 -36
  16. package/dist/tests/docloader.test.cjs +321 -4
  17. package/dist/tests/docloader.test.mjs +320 -4
  18. package/dist/tests/internal/multicodec.test.cjs +2 -2
  19. package/dist/tests/internal/multicodec.test.mjs +2 -2
  20. package/dist/tests/{key-BeTHFQJK.mjs → key-BDIsC6hV.mjs} +2 -2
  21. package/dist/tests/{key-DTTIntwb.cjs → key-BarKGLQz.cjs} +2 -2
  22. package/dist/tests/key.test.cjs +3 -3
  23. package/dist/tests/key.test.mjs +3 -3
  24. package/dist/tests/langstr.test.cjs +2 -2
  25. package/dist/tests/langstr.test.mjs +2 -2
  26. package/dist/tests/link.test.cjs +2 -2
  27. package/dist/tests/link.test.mjs +2 -2
  28. package/dist/tests/multibase/multibase.test.cjs +2 -2
  29. package/dist/tests/multibase/multibase.test.mjs +2 -2
  30. package/dist/tests/{request-CdMLb0ba.cjs → request-DrO5xzev.cjs} +1 -1
  31. package/dist/tests/{request-DqsgdBUH.mjs → request-DxeGyJVh.mjs} +1 -1
  32. package/dist/tests/request.test.cjs +2 -2
  33. package/dist/tests/request.test.mjs +2 -2
  34. package/dist/tests/temporal.test.cjs +1 -1
  35. package/dist/tests/temporal.test.mjs +1 -1
  36. package/dist/tests/{url-o0e-aXQY.mjs → url-3fyJuqC0.mjs} +14 -4
  37. package/dist/tests/{url-CWU5HT3Y.cjs → url-vZTgkIZg.cjs} +15 -4
  38. package/dist/tests/url.test.cjs +37 -4
  39. package/dist/tests/url.test.mjs +37 -4
  40. package/package.json +1 -1
  41. package/src/body.test.ts +125 -0
  42. package/src/body.ts +95 -0
  43. package/src/contexts/fep-7aa9.json +24 -0
  44. package/src/contexts.ts +8 -0
  45. package/src/docloader.test.ts +431 -1
  46. package/src/docloader.ts +76 -49
  47. package/src/url.test.ts +29 -3
  48. package/src/url.ts +21 -5
  49. /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-BOQHfJDr.cjs} +0 -0
  50. /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-DDLc4833.mjs} +0 -0
  51. /package/dist/tests/{link-NUUWCdnK.mjs → link-CYszIegt.mjs} +0 -0
  52. /package/dist/tests/{link-FguCydMA.cjs → link-qDTmjPw_.cjs} +0 -0
  53. /package/dist/tests/{multibase-F7LtMMsK.cjs → multibase-8zhYPa7o.cjs} +0 -0
  54. /package/dist/tests/{multibase-BgU9XRf7.mjs → multibase-Ds75BvXs.mjs} +0 -0
  55. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-BBPZjRfg.mjs} +0 -0
  56. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-ByS9XLsw.cjs} +0 -0
package/src/contexts.ts CHANGED
@@ -8,6 +8,7 @@ import activitystreams from "./contexts/activitystreams.json" with {
8
8
  import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
9
9
  import didV1 from "./contexts/did-v1.json" with { type: "json" };
10
10
  import fep5711 from "./contexts/fep-5711.json" with { type: "json" };
11
+ import fep7aa9 from "./contexts/fep-7aa9.json" with { type: "json" };
11
12
  import fepEf61 from "./contexts/fep-ef61.json" with { type: "json" };
12
13
  import gotosocial from "./contexts/gotosocial.json" with { type: "json" };
13
14
  import identityV1 from "./contexts/identity-v1.json" with { type: "json" };
@@ -56,6 +57,13 @@ const preloadedContexts: Record<string, unknown> = {
56
57
  // https://github.com/fedify-dev/fedify/issues/982
57
58
  "https://w3id.org/fep/ef61": fepEf61,
58
59
 
60
+ // The FEP-7aa9 context is hosted on Codeberg Pages, whose outages can
61
+ // prevent actor documents from being parsed. Resolve it locally so that
62
+ // parsing does not depend on the remote context server's availability.
63
+ // See: https://w3id.org/fep/7aa9
64
+ // https://github.com/fedify-dev/fedify/issues/1078
65
+ "https://w3id.org/fep/7aa9": fep7aa9,
66
+
59
67
  // Lemmy's context document is served as application/json without the JSON-LD
60
68
  // context Link header. The default document loader treats that as a regular
61
69
  // JSON response instead of a JSON-LD context, so every Lemmy activity that
@@ -1,9 +1,13 @@
1
+ import { configure, type LogRecord, reset } from "@logtape/logtape";
1
2
  import fetchMock from "fetch-mock";
2
3
  import { deepStrictEqual, ok, rejects } from "node:assert";
4
+ import dns from "node:dns/promises";
3
5
  import { test } from "node:test";
6
+ import { createServer } from "node:http";
7
+ import { gzipSync } from "node:zlib";
4
8
  import preloadedContexts from "./contexts.ts";
5
9
  import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
6
- import { getDocumentLoader } from "./docloader.ts";
10
+ import { getDocumentLoader, getRemoteDocument } from "./docloader.ts";
7
11
  import { FetchError } from "./request.ts";
8
12
  import { UrlError } from "./url.ts";
9
13
 
@@ -334,6 +338,43 @@ test("getDocumentLoader()", async (t) => {
334
338
  });
335
339
  });
336
340
 
341
+ // A Codeberg Pages outage must not prevent loading the FEP-7aa9 context.
342
+ // See: https://github.com/fedify-dev/fedify/issues/1078
343
+ fetchMock.get("https://w3id.org/fep/7aa9", { status: 502 });
344
+ await t.test("preloaded FEP-7aa9 context", async () => {
345
+ const url = "https://w3id.org/fep/7aa9";
346
+ deepStrictEqual(await fetchDocumentLoader(url), {
347
+ contextUrl: null,
348
+ documentUrl: url,
349
+ document: {
350
+ "@context": {
351
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
352
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
353
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
354
+ "FeatureAuthorization":
355
+ "https://w3id.org/fep/7aa9#FeatureAuthorization",
356
+ "topic": {
357
+ "@id": "https://w3id.org/fep/7aa9#topic",
358
+ "@type": "@id",
359
+ },
360
+ "featuredObject": {
361
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
362
+ "@type": "@id",
363
+ },
364
+ "canFeature": {
365
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
366
+ "@type": "@id",
367
+ },
368
+ "featureAuthorization": {
369
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
370
+ "@type": "@id",
371
+ },
372
+ },
373
+ },
374
+ });
375
+ deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
376
+ });
377
+
337
378
  await t.test("deny non-HTTP/HTTPS", async () => {
338
379
  await rejects(
339
380
  () => fetchDocumentLoader("ftp://localhost"),
@@ -507,3 +548,392 @@ test("getDocumentLoader()", async (t) => {
507
548
 
508
549
  fetchMock.hardReset();
509
550
  });
551
+
552
+ test("getDocumentLoader() bounds JSON, HTML, and alternate documents", async () => {
553
+ const url = "https://example.com/bounded";
554
+ fetchMock.mockGlobal();
555
+ let oversized = true;
556
+ try {
557
+ fetchMock.get(url, () =>
558
+ new Response('{"name":"hello"}', {
559
+ headers: {
560
+ "Content-Type": "application/activity+json",
561
+ ...(oversized
562
+ ? { "Content-Length": String(16 * 1024 * 1024 + 1) }
563
+ : {}),
564
+ },
565
+ }));
566
+ const loader = getDocumentLoader({
567
+ allowPrivateAddress: true,
568
+ });
569
+ await rejects(loader(url), FetchError);
570
+ oversized = false;
571
+ deepStrictEqual((await loader(url)).document, { name: "hello" });
572
+ oversized = true;
573
+ fetchMock.get(
574
+ `${url}/html`,
575
+ () =>
576
+ new Response(" ".repeat(1024 * 1024 + 1), {
577
+ headers: { "Content-Type": "text/html" },
578
+ }),
579
+ );
580
+ await rejects(
581
+ getDocumentLoader({ allowPrivateAddress: true })(`${url}/html`),
582
+ FetchError,
583
+ );
584
+ fetchMock.get(`${url}/alternate`, () =>
585
+ new Response("", {
586
+ headers: {
587
+ "Content-Type": "text/html",
588
+ Link: `<${url}>; rel="alternate"; type="application/activity+json"`,
589
+ },
590
+ }));
591
+ await rejects(loader(`${url}/alternate`), FetchError);
592
+ fetchMock.get(`${url}/redirect`, {
593
+ status: 302,
594
+ headers: { Location: url },
595
+ });
596
+ await rejects(loader(`${url}/redirect`), FetchError);
597
+ } finally {
598
+ fetchMock.hardReset();
599
+ }
600
+ });
601
+
602
+ test("getDocumentLoader() rejects oversized gzip responses from fetch", async () => {
603
+ const document = { name: "a".repeat(16 * 1024 * 1024) };
604
+ const compressed = gzipSync(JSON.stringify(document));
605
+ ok(compressed.byteLength < 64 * 1024);
606
+ const server = createServer((request, response) => {
607
+ const body = request.url === "/small"
608
+ ? gzipSync('{"name":"hello"}')
609
+ : compressed;
610
+ response.writeHead(200, {
611
+ "Content-Type": "application/activity+json",
612
+ "Content-Encoding": "gzip",
613
+ "Content-Length": body.byteLength,
614
+ });
615
+ response.end(body);
616
+ });
617
+ await new Promise<void>((resolve, reject) => {
618
+ server.once("error", reject);
619
+ server.listen(0, "127.0.0.1", resolve);
620
+ });
621
+ try {
622
+ const address = server.address();
623
+ ok(address != null && typeof address !== "string");
624
+ const url = `http://127.0.0.1:${address.port}/document`;
625
+ await rejects(
626
+ getDocumentLoader({
627
+ allowPrivateAddress: true,
628
+ })(url),
629
+ FetchError,
630
+ );
631
+ deepStrictEqual(
632
+ (await getDocumentLoader({
633
+ allowPrivateAddress: true,
634
+ })(new URL("/small", url).href)).document,
635
+ { name: "hello" },
636
+ );
637
+ } finally {
638
+ await new Promise<void>((resolve, reject) => {
639
+ server.close((error) => error == null ? resolve() : reject(error));
640
+ server.closeAllConnections();
641
+ });
642
+ }
643
+ });
644
+
645
+ test("getRemoteDocument() bounds JSON by default", async () => {
646
+ let canceled = false;
647
+ let pulls = 0;
648
+ const response = new Response(
649
+ new ReadableStream({
650
+ pull(controller) {
651
+ pulls++;
652
+ controller.enqueue(new Uint8Array(64 * 1024).fill(32));
653
+ },
654
+ cancel() {
655
+ canceled = true;
656
+ },
657
+ }, { highWaterMark: 0 }),
658
+ );
659
+ await rejects(
660
+ getRemoteDocument(
661
+ "https://example.com/document",
662
+ response,
663
+ () => {
664
+ throw new Error("Unexpected alternate document");
665
+ },
666
+ ),
667
+ FetchError,
668
+ );
669
+ deepStrictEqual(canceled, true);
670
+ deepStrictEqual(pulls, 257);
671
+ });
672
+
673
+ test("getDocumentLoader() logs DNS failures as such", async (t) => {
674
+ // The validator skips DNS when Deno has no network permission. Checked
675
+ // here rather than with top-level await, which the CommonJS build rejects.
676
+ if (
677
+ "Deno" in globalThis &&
678
+ (await Deno.permissions.query({ name: "net" })).state !== "granted"
679
+ ) {
680
+ t.skip("requires the net permission");
681
+ return;
682
+ }
683
+ const loader = getDocumentLoader();
684
+ for (const result of ["throws", "empty", "private"] as const) {
685
+ await t.test(result, async () => {
686
+ // Stubbing works only because url.ts uses the default node:dns/promises
687
+ // import; see the FIXME there.
688
+ const originalLookup = dns.lookup;
689
+ dns.lookup = (() =>
690
+ result === "throws"
691
+ ? Promise.reject(new Error("Resolver unavailable"))
692
+ : Promise.resolve(
693
+ result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
694
+ )) as typeof dns.lookup;
695
+ const records: LogRecord[] = [];
696
+ await configure({
697
+ sinks: {
698
+ buffer: (record) =>
699
+ records.push(record),
700
+ },
701
+ loggers: [
702
+ { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
703
+ { category: ["logtape", "meta"], sinks: [] },
704
+ ],
705
+ reset: true,
706
+ });
707
+ try {
708
+ const url = "https://dns-failure.invalid/object";
709
+ let error: unknown;
710
+ await rejects(() => loader(url), (e) => {
711
+ error = e;
712
+ return true;
713
+ });
714
+ ok(error instanceof UrlError);
715
+ deepStrictEqual(
716
+ error.reason,
717
+ result === "private" ? "disallowed" : "dns",
718
+ );
719
+ deepStrictEqual(
720
+ records.map((r) => [r.level, r.rawMessage, r.properties.url]),
721
+ [
722
+ result === "private"
723
+ ? ["error", "Disallowed private URL: {url}", url]
724
+ : ["debug", "DNS lookup failed for {url}", url],
725
+ ],
726
+ );
727
+ ok(records[0].properties.error === error);
728
+ } finally {
729
+ await reset();
730
+ dns.lookup = originalLookup;
731
+ }
732
+ });
733
+ }
734
+ });
735
+
736
+ test("getDocumentLoader() bounds alternate document chains", async (t) => {
737
+ const base = "https://example.com/alternate-chain/";
738
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
739
+ for (const mode of ["header", "html", "mixed"] as const) {
740
+ for (const hops of [20, 21]) {
741
+ await t.test(`${mode}: ${hops} hops`, async () => {
742
+ fetchMock.mockGlobal();
743
+ let requests = 0;
744
+ fetchMock.get(`begin:${base}`, ({ url }) => {
745
+ requests++;
746
+ const index = Number(new URL(url).pathname.split("/").at(-1));
747
+ // Keep the unpatched regression bounded too.
748
+ if (index === hops || requests > 30) {
749
+ return Response.json({ done: true });
750
+ }
751
+ const next = `${base}${index + 1}`;
752
+ if (mode === "mixed" && index % 2 === 1) {
753
+ return Response.redirect(next, 302);
754
+ }
755
+ return alternate(next, mode === "html");
756
+ });
757
+ try {
758
+ if (hops === 20) {
759
+ deepStrictEqual((await loader(`${base}0`)).document, {
760
+ done: true,
761
+ });
762
+ } else {
763
+ await rejects(loader(`${base}0`), (error: unknown) => {
764
+ ok(error instanceof FetchError);
765
+ ok(error.message.includes("Too many redirections (21)"));
766
+ return true;
767
+ });
768
+ }
769
+ deepStrictEqual(requests, 21);
770
+ } finally {
771
+ fetchMock.hardReset();
772
+ }
773
+ });
774
+ }
775
+ }
776
+
777
+ for (
778
+ const mode of [
779
+ "header",
780
+ "html",
781
+ "alternate-redirect",
782
+ "redirect-alternate",
783
+ "redirect-intermediate",
784
+ ]
785
+ ) {
786
+ await t.test(`${mode}: cycle`, async () => {
787
+ fetchMock.mockGlobal();
788
+ let requests = 0;
789
+ fetchMock.get(`begin:${base}`, ({ url }) => {
790
+ requests++;
791
+ if (requests > 30) return Response.json({ stopped: true });
792
+ const index = Number(new URL(url).pathname.split("/").at(-1));
793
+ const next = `${base}${
794
+ mode === "redirect-intermediate"
795
+ ? (index === 0 ? 1 : index === 1 ? 2 : 1)
796
+ : 1 - index
797
+ }`;
798
+ if (
799
+ (mode === "alternate-redirect" && index === 1) ||
800
+ ((mode === "redirect-alternate" ||
801
+ mode === "redirect-intermediate") && index === 0)
802
+ ) {
803
+ return Response.redirect(next, 302);
804
+ }
805
+ return alternate(next, mode === "html");
806
+ });
807
+ try {
808
+ await rejects(loader(`${base}0`), (error: unknown) => {
809
+ ok(error instanceof FetchError);
810
+ ok(error.message.includes("Redirect loop detected:"));
811
+ return true;
812
+ });
813
+ deepStrictEqual(requests, mode === "redirect-intermediate" ? 3 : 2);
814
+ } finally {
815
+ fetchMock.hardReset();
816
+ }
817
+ });
818
+ }
819
+
820
+ await t.test("alternate followed by 20 redirects shares the limit", async () => {
821
+ fetchMock.mockGlobal();
822
+ let requests = 0;
823
+ fetchMock.get(`begin:${base}`, ({ url }) => {
824
+ requests++;
825
+ const index = Number(new URL(url).pathname.split("/").at(-1));
826
+ if (index === 21) return Response.json({ done: true });
827
+ return index === 0
828
+ ? alternate(`${base}1`, false)
829
+ : Response.redirect(`${base}${index + 1}`, 302);
830
+ });
831
+ try {
832
+ await rejects(loader(`${base}0`), (error: unknown) => {
833
+ ok(error instanceof FetchError);
834
+ ok(error.message.includes("Too many redirections (21)"));
835
+ return true;
836
+ });
837
+ deepStrictEqual(requests, 21);
838
+ } finally {
839
+ fetchMock.hardReset();
840
+ }
841
+ });
842
+
843
+ await t.test("relative alternate after redirect and isolated calls", async () => {
844
+ fetchMock.mockGlobal();
845
+ fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
846
+ fetchMock.get(`${base}html`, alternate("./document", true));
847
+ fetchMock.get(`${base}document`, Response.json({ done: true }));
848
+ try {
849
+ for (let i = 0; i < 2; i++) {
850
+ const results = await Promise.all([
851
+ loader(`${base}start`),
852
+ loader(`${base}start`),
853
+ ]);
854
+ for (const result of results) {
855
+ deepStrictEqual(result.document, { done: true });
856
+ deepStrictEqual(result.documentUrl, `${base}document`);
857
+ }
858
+ }
859
+ } finally {
860
+ fetchMock.hardReset();
861
+ }
862
+ });
863
+
864
+ function alternate(next: string, html: boolean): Response {
865
+ return html
866
+ ? new Response(
867
+ `<link rel="alternate" type="application/activity+json" href="${next}">`,
868
+ {
869
+ headers: { "Content-Type": "text/html" },
870
+ },
871
+ )
872
+ : new Response("not JSON", {
873
+ headers: {
874
+ "Content-Type": "text/plain",
875
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
876
+ },
877
+ });
878
+ }
879
+ });
880
+
881
+ test("getDocumentLoader() preserves cancellation across alternates", async (t) => {
882
+ const base = "https://example.com/alternate-abort/";
883
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
884
+ for (const html of [false, true]) {
885
+ await t.test(html ? "HTML" : "Link header", async () => {
886
+ fetchMock.mockGlobal();
887
+ const controller = new AbortController();
888
+ let requests = 0;
889
+ fetchMock.get(`begin:${base}`, ({ url }) => {
890
+ requests++;
891
+ const index = Number(new URL(url).pathname.split("/").at(-1));
892
+ if (index === 2) return Response.json({ done: true });
893
+ if (index === 1) controller.abort();
894
+ const next = `${base}${index + 1}`;
895
+ return new Response(
896
+ html
897
+ ? `<link rel="alternate" type="application/activity+json" href="${next}">`
898
+ : "not JSON",
899
+ {
900
+ headers: html ? { "Content-Type": "text/html" } : {
901
+ "Content-Type": "text/plain",
902
+ Link:
903
+ `<${next}>; rel="alternate"; type="application/activity+json"`,
904
+ },
905
+ },
906
+ );
907
+ });
908
+ try {
909
+ await rejects(loader(`${base}0`, { signal: controller.signal }), {
910
+ name: "AbortError",
911
+ });
912
+ deepStrictEqual(requests, 2);
913
+ } finally {
914
+ fetchMock.hardReset();
915
+ }
916
+ });
917
+ }
918
+ });
919
+
920
+ test("getDocumentLoader() rejects cancellation before fetching", async () => {
921
+ fetchMock.mockGlobal();
922
+ let requests = 0;
923
+ const url = "https://example.com/pre-aborted-alternate";
924
+ fetchMock.get(url, () => {
925
+ requests++;
926
+ return Response.json({ done: true });
927
+ });
928
+ try {
929
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
930
+ const controller = new AbortController();
931
+ controller.abort();
932
+ await rejects(loader(url, { signal: controller.signal }), {
933
+ name: "AbortError",
934
+ });
935
+ deepStrictEqual(requests, 0);
936
+ } finally {
937
+ fetchMock.hardReset();
938
+ }
939
+ });
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 {
@@ -195,55 +196,52 @@ export async function getRemoteDocument(
195
196
  ) {
196
197
  // Security: Limit HTML response size to mitigate ReDoS attacks
197
198
  const MAX_HTML_SIZE = 1024 * 1024; // 1MB
198
- const html = await response.text();
199
- if (html.length > MAX_HTML_SIZE) {
200
- logger.warn(
201
- "HTML response too large, skipping alternate link discovery: {url}",
202
- { url: documentUrl, size: html.length },
203
- );
204
- document = JSON.parse(html);
205
- } else {
206
- // Safe regex patterns without nested quantifiers to prevent ReDoS
207
- // (CVE-2025-68475)
208
- // Step 1: Extract <a ...> or <link ...> tags
209
- const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
210
- // Step 2: Parse attributes
211
- const attrPattern =
212
- /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
199
+ const html = await readBoundedText(
200
+ response,
201
+ MAX_HTML_SIZE,
202
+ documentUrl,
203
+ );
204
+ // Safe regex patterns without nested quantifiers to prevent ReDoS
205
+ // (CVE-2025-68475)
206
+ // Step 1: Extract <a ...> or <link ...> tags
207
+ const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
208
+ // Step 2: Parse attributes
209
+ const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
213
210
 
214
- let tagMatch: RegExpExecArray | null;
215
- while ((tagMatch = tagPattern.exec(html)) !== null) {
216
- const tagContent = tagMatch[2];
217
- let attrMatch: RegExpExecArray | null;
218
- const attribs: Record<string, string> = {};
211
+ let tagMatch: RegExpExecArray | null;
212
+ while ((tagMatch = tagPattern.exec(html)) !== null) {
213
+ const tagContent = tagMatch[2];
214
+ let attrMatch: RegExpExecArray | null;
215
+ const attribs: Record<string, string> = {};
219
216
 
220
- // Reset regex state for attribute parsing
221
- attrPattern.lastIndex = 0;
222
- while ((attrMatch = attrPattern.exec(tagContent)) !== null) {
223
- const key = attrMatch[1].toLowerCase();
224
- const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
225
- attribs[key] = value;
226
- }
217
+ // Reset regex state for attribute parsing
218
+ attrPattern.lastIndex = 0;
219
+ while ((attrMatch = attrPattern.exec(tagContent)) !== null) {
220
+ const key = attrMatch[1].toLowerCase();
221
+ const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
222
+ attribs[key] = value;
223
+ }
227
224
 
228
- if (
229
- attribs.rel === "alternate" && "type" in attribs && (
230
- attribs.type === "application/activity+json" ||
231
- attribs.type === "application/ld+json" ||
232
- attribs.type.startsWith("application/ld+json;")
233
- ) && "href" in attribs &&
234
- new URL(attribs.href, docUrl).href !== docUrl.href
235
- ) {
236
- logger.debug(
237
- "Found alternate document: {alternateUrl} from {url}",
238
- { alternateUrl: attribs.href, url: documentUrl },
239
- );
240
- return await fetch(new URL(attribs.href, docUrl).href);
241
- }
225
+ if (
226
+ attribs.rel === "alternate" && "type" in attribs && (
227
+ attribs.type === "application/activity+json" ||
228
+ attribs.type === "application/ld+json" ||
229
+ attribs.type.startsWith("application/ld+json;")
230
+ ) && "href" in attribs &&
231
+ new URL(attribs.href, docUrl).href !== docUrl.href
232
+ ) {
233
+ logger.debug(
234
+ "Found alternate document: {alternateUrl} from {url}",
235
+ { alternateUrl: attribs.href, url: documentUrl },
236
+ );
237
+ return await fetch(new URL(attribs.href, docUrl).href);
242
238
  }
243
- document = JSON.parse(html);
244
239
  }
240
+ document = JSON.parse(html);
245
241
  } else {
246
- document = await response.json();
242
+ document = JSON.parse(
243
+ await readBoundedText(response, MAX_BODY_SIZE, documentUrl),
244
+ );
247
245
  }
248
246
  logger.debug(
249
247
  "Fetched document: {status} {url} {headers}",
@@ -269,6 +267,8 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
269
267
 
270
268
  /**
271
269
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
270
+ * At most 20 HTTP redirects and alternate document links are followed in total
271
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
272
272
  *
273
273
  * The created loader preloads the below frequently used contexts by default
274
274
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -280,6 +280,7 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
280
280
  * - <https://www.w3.org/ns/cid/v1>
281
281
  * - <https://w3id.org/security/multikey/v1>
282
282
  * - <https://w3id.org/fep/ef61>
283
+ * - <https://w3id.org/fep/7aa9>
283
284
  * - <https://purl.archive.org/socialweb/webfinger>
284
285
  * - <http://schema.org/>
285
286
  * @param options Options for the document loader.
@@ -314,10 +315,17 @@ export function getDocumentLoader(
314
315
  await validatePublicUrl(currentUrl);
315
316
  } catch (error) {
316
317
  if (error instanceof UrlError) {
317
- logger.error("Disallowed private URL: {url}", {
318
- url: currentUrl,
319
- error,
320
- });
318
+ if (error.reason === "dns") {
319
+ logger.debug("DNS lookup failed for {url}", {
320
+ url: currentUrl,
321
+ error,
322
+ });
323
+ } else {
324
+ logger.error("Disallowed private URL: {url}", {
325
+ url: currentUrl,
326
+ error,
327
+ });
328
+ }
321
329
  }
322
330
  throw error;
323
331
  }
@@ -379,7 +387,26 @@ export function getDocumentLoader(
379
387
  return await load(redirectUrl, options, redirected + 1, visited);
380
388
  }
381
389
 
382
- const result = await getRemoteDocument(currentUrl, response, load);
390
+ const result = await getRemoteDocument(
391
+ currentUrl,
392
+ response,
393
+ async (alternateUrl) => {
394
+ options?.signal?.throwIfAborted();
395
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
396
+ throw new FetchError(
397
+ currentUrl,
398
+ `Too many redirections (${redirected + 1})`,
399
+ );
400
+ }
401
+ if (visited.has(alternateUrl)) {
402
+ throw new FetchError(
403
+ currentUrl,
404
+ `Redirect loop detected: ${alternateUrl}`,
405
+ );
406
+ }
407
+ return await load(alternateUrl, options, redirected + 1, visited);
408
+ },
409
+ );
383
410
  span.setAttribute("docloader.document_url", result.documentUrl);
384
411
  if (result.contextUrl != null) {
385
412
  span.setAttribute("docloader.context_url", result.contextUrl);
@@ -398,5 +425,5 @@ export function getDocumentLoader(
398
425
  },
399
426
  );
400
427
  }
401
- return load;
428
+ return (url, options) => load(url, options);
402
429
  }