@fedify/vocab-runtime 2.0.7-pr.639.14 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.0.7-pr.639.14+40546420",
3
+ "version": "2.0.8",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts",
package/dist/mod.cjs CHANGED
@@ -4263,7 +4263,7 @@ var contexts_default = preloadedContexts;
4263
4263
  //#endregion
4264
4264
  //#region deno.json
4265
4265
  var name = "@fedify/vocab-runtime";
4266
- var version = "2.0.7-pr.639.14+40546420";
4266
+ var version = "2.0.8";
4267
4267
  var license = "MIT";
4268
4268
  var exports$1 = {
4269
4269
  ".": "./src/mod.ts",
@@ -4616,6 +4616,7 @@ const logger = (0, __logtape_logtape.getLogger)([
4616
4616
  "runtime",
4617
4617
  "docloader"
4618
4618
  ]);
4619
+ const DEFAULT_MAX_REDIRECTION = 20;
4619
4620
  /**
4620
4621
  * Gets a {@link RemoteDocument} from the given response.
4621
4622
  * @param url The URL of the document to load.
@@ -4734,31 +4735,33 @@ async function getRemoteDocument(url, response, fetch$1) {
4734
4735
  function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
4735
4736
  const tracerProvider = __opentelemetry_api.trace.getTracerProvider();
4736
4737
  const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
4737
- async function load(url, options) {
4738
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4738
4739
  options?.signal?.throwIfAborted();
4739
- if (!skipPreloadedContexts && url in contexts_default) {
4740
- logger.debug("Using preloaded context: {url}.", { url });
4740
+ const currentUrl = new URL(url).href;
4741
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4742
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4741
4743
  return {
4742
4744
  contextUrl: null,
4743
- document: contexts_default[url],
4744
- documentUrl: url
4745
+ document: contexts_default[currentUrl],
4746
+ documentUrl: currentUrl
4745
4747
  };
4746
4748
  }
4747
4749
  if (!allowPrivateAddress) try {
4748
- await validatePublicUrl(url);
4750
+ await validatePublicUrl(currentUrl);
4749
4751
  } catch (error) {
4750
4752
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4751
- url,
4753
+ url: currentUrl,
4752
4754
  error
4753
4755
  });
4754
4756
  throw error;
4755
4757
  }
4758
+ visited.add(currentUrl);
4756
4759
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4757
4760
  kind: __opentelemetry_api.SpanKind.CLIENT,
4758
- attributes: { "url.full": url }
4761
+ attributes: { "url.full": currentUrl }
4759
4762
  }, async (span) => {
4760
4763
  try {
4761
- const request = createActivityPubRequest(url, { userAgent });
4764
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4762
4765
  logRequest(logger, request);
4763
4766
  const response = await fetch(request, {
4764
4767
  redirect: "manual",
@@ -4766,11 +4769,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4766
4769
  });
4767
4770
  span.setAttribute("http.response.status_code", response.status);
4768
4771
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4769
- const redirectUrl = response.headers.get("Location");
4772
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
4773
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4774
+ redirections: redirected + 1,
4775
+ url: currentUrl
4776
+ });
4777
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4778
+ }
4779
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4770
4780
  span.setAttribute("http.redirect.url", redirectUrl);
4771
- return await load(redirectUrl, options);
4781
+ if (visited.has(redirectUrl)) {
4782
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4783
+ url: currentUrl,
4784
+ redirectUrl
4785
+ });
4786
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4787
+ }
4788
+ return await load(redirectUrl, options, redirected + 1, visited);
4772
4789
  }
4773
- const result = await getRemoteDocument(url, response, load);
4790
+ const result = await getRemoteDocument(currentUrl, response, load);
4774
4791
  span.setAttribute("docloader.document_url", result.documentUrl);
4775
4792
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4776
4793
  return result;
package/dist/mod.js CHANGED
@@ -4262,7 +4262,7 @@ var contexts_default = preloadedContexts;
4262
4262
  //#endregion
4263
4263
  //#region deno.json
4264
4264
  var name = "@fedify/vocab-runtime";
4265
- var version = "2.0.7-pr.639.14+40546420";
4265
+ var version = "2.0.8";
4266
4266
  var license = "MIT";
4267
4267
  var exports = {
4268
4268
  ".": "./src/mod.ts",
@@ -4615,6 +4615,7 @@ const logger = getLogger([
4615
4615
  "runtime",
4616
4616
  "docloader"
4617
4617
  ]);
4618
+ const DEFAULT_MAX_REDIRECTION = 20;
4618
4619
  /**
4619
4620
  * Gets a {@link RemoteDocument} from the given response.
4620
4621
  * @param url The URL of the document to load.
@@ -4733,31 +4734,33 @@ async function getRemoteDocument(url, response, fetch$1) {
4733
4734
  function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
4734
4735
  const tracerProvider = trace.getTracerProvider();
4735
4736
  const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
4736
- async function load(url, options) {
4737
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4737
4738
  options?.signal?.throwIfAborted();
4738
- if (!skipPreloadedContexts && url in contexts_default) {
4739
- logger.debug("Using preloaded context: {url}.", { url });
4739
+ const currentUrl = new URL(url).href;
4740
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4741
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4740
4742
  return {
4741
4743
  contextUrl: null,
4742
- document: contexts_default[url],
4743
- documentUrl: url
4744
+ document: contexts_default[currentUrl],
4745
+ documentUrl: currentUrl
4744
4746
  };
4745
4747
  }
4746
4748
  if (!allowPrivateAddress) try {
4747
- await validatePublicUrl(url);
4749
+ await validatePublicUrl(currentUrl);
4748
4750
  } catch (error) {
4749
4751
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4750
- url,
4752
+ url: currentUrl,
4751
4753
  error
4752
4754
  });
4753
4755
  throw error;
4754
4756
  }
4757
+ visited.add(currentUrl);
4755
4758
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4756
4759
  kind: SpanKind.CLIENT,
4757
- attributes: { "url.full": url }
4760
+ attributes: { "url.full": currentUrl }
4758
4761
  }, async (span) => {
4759
4762
  try {
4760
- const request = createActivityPubRequest(url, { userAgent });
4763
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4761
4764
  logRequest(logger, request);
4762
4765
  const response = await fetch(request, {
4763
4766
  redirect: "manual",
@@ -4765,11 +4768,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4765
4768
  });
4766
4769
  span.setAttribute("http.response.status_code", response.status);
4767
4770
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4768
- const redirectUrl = response.headers.get("Location");
4771
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
4772
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4773
+ redirections: redirected + 1,
4774
+ url: currentUrl
4775
+ });
4776
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4777
+ }
4778
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4769
4779
  span.setAttribute("http.redirect.url", redirectUrl);
4770
- return await load(redirectUrl, options);
4780
+ if (visited.has(redirectUrl)) {
4781
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4782
+ url: currentUrl,
4783
+ redirectUrl
4784
+ });
4785
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4786
+ }
4787
+ return await load(redirectUrl, options, redirected + 1, visited);
4771
4788
  }
4772
- const result = await getRemoteDocument(url, response, load);
4789
+ const result = await getRemoteDocument(currentUrl, response, load);
4773
4790
  span.setAttribute("docloader.document_url", result.documentUrl);
4774
4791
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4775
4792
  return result;
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- const require_request = require('./request-B3KZmXmp.cjs');
2
+ const require_request = require('./request-D2-F2dMS.cjs');
3
3
  const require_link = require('./link-CdFPEo9O.cjs');
4
4
  const require_url = require('./url-C5Vs9nYh.cjs');
5
5
  const node_assert = require_chunk.__toESM(require("node:assert"));
@@ -5497,6 +5497,7 @@ const logger = (0, __logtape_logtape.getLogger)([
5497
5497
  "runtime",
5498
5498
  "docloader"
5499
5499
  ]);
5500
+ const DEFAULT_MAX_REDIRECTION = 20;
5500
5501
  /**
5501
5502
  * Gets a {@link RemoteDocument} from the given response.
5502
5503
  * @param url The URL of the document to load.
@@ -5615,31 +5616,33 @@ async function getRemoteDocument(url, response, fetch$1) {
5615
5616
  function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
5616
5617
  const tracerProvider = __opentelemetry_api.trace.getTracerProvider();
5617
5618
  const tracer = tracerProvider.getTracer(require_request.deno_default.name, require_request.deno_default.version);
5618
- async function load(url, options) {
5619
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
5619
5620
  options?.signal?.throwIfAborted();
5620
- if (!skipPreloadedContexts && url in contexts_default) {
5621
- logger.debug("Using preloaded context: {url}.", { url });
5621
+ const currentUrl = new URL(url).href;
5622
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
5623
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
5622
5624
  return {
5623
5625
  contextUrl: null,
5624
- document: contexts_default[url],
5625
- documentUrl: url
5626
+ document: contexts_default[currentUrl],
5627
+ documentUrl: currentUrl
5626
5628
  };
5627
5629
  }
5628
5630
  if (!allowPrivateAddress) try {
5629
- await require_url.validatePublicUrl(url);
5631
+ await require_url.validatePublicUrl(currentUrl);
5630
5632
  } catch (error) {
5631
5633
  if (error instanceof require_url.UrlError) logger.error("Disallowed private URL: {url}", {
5632
- url,
5634
+ url: currentUrl,
5633
5635
  error
5634
5636
  });
5635
5637
  throw error;
5636
5638
  }
5639
+ visited.add(currentUrl);
5637
5640
  return await tracer.startActiveSpan("activitypub.fetch_document", {
5638
5641
  kind: __opentelemetry_api.SpanKind.CLIENT,
5639
- attributes: { "url.full": url }
5642
+ attributes: { "url.full": currentUrl }
5640
5643
  }, async (span) => {
5641
5644
  try {
5642
- const request = require_request.createActivityPubRequest(url, { userAgent });
5645
+ const request = require_request.createActivityPubRequest(currentUrl, { userAgent });
5643
5646
  require_request.logRequest(logger, request);
5644
5647
  const response = await fetch(request, {
5645
5648
  redirect: "manual",
@@ -5647,11 +5650,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5647
5650
  });
5648
5651
  span.setAttribute("http.response.status_code", response.status);
5649
5652
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
5650
- const redirectUrl = response.headers.get("Location");
5653
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
5654
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
5655
+ redirections: redirected + 1,
5656
+ url: currentUrl
5657
+ });
5658
+ throw new require_request.FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5659
+ }
5660
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
5651
5661
  span.setAttribute("http.redirect.url", redirectUrl);
5652
- return await load(redirectUrl, options);
5662
+ if (visited.has(redirectUrl)) {
5663
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
5664
+ url: currentUrl,
5665
+ redirectUrl
5666
+ });
5667
+ throw new require_request.FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
5668
+ }
5669
+ return await load(redirectUrl, options, redirected + 1, visited);
5653
5670
  }
5654
- const result = await getRemoteDocument(url, response, load);
5671
+ const result = await getRemoteDocument(currentUrl, response, load);
5655
5672
  span.setAttribute("docloader.document_url", result.documentUrl);
5656
5673
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
5657
5674
  return result;
@@ -5939,6 +5956,53 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5939
5956
  (0, node_assert.deepStrictEqual)(await fetchDocumentLoader2("https://example.com/localhost-redirect"), expected);
5940
5957
  (0, node_assert.deepStrictEqual)(await fetchDocumentLoader2("https://example.com/localhost-link"), expected);
5941
5958
  });
5959
+ let redirectAttempts = 0;
5960
+ esm_default.get("begin:https://example.com/too-many-redirects/", (cl) => {
5961
+ redirectAttempts++;
5962
+ const index = Number(cl.url.split("/").at(-1));
5963
+ return {
5964
+ status: 302,
5965
+ headers: { Location: `https://example.com/too-many-redirects/${index + 1}` }
5966
+ };
5967
+ });
5968
+ await t.test("too many redirects", async () => {
5969
+ redirectAttempts = 0;
5970
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/too-many-redirects/0"), require_request.FetchError, "Too many redirections");
5971
+ (0, node_assert.deepStrictEqual)(redirectAttempts, 21);
5972
+ });
5973
+ let loopAttempts = 0;
5974
+ esm_default.get("https://example.com/redirect-loop-a", () => {
5975
+ loopAttempts++;
5976
+ return {
5977
+ status: 302,
5978
+ headers: { Location: "https://example.com/redirect-loop-b" }
5979
+ };
5980
+ });
5981
+ esm_default.get("https://example.com/redirect-loop-b", () => {
5982
+ loopAttempts++;
5983
+ return {
5984
+ status: 302,
5985
+ headers: { Location: "https://example.com/redirect-loop-a" }
5986
+ };
5987
+ });
5988
+ await t.test("redirect loop", async () => {
5989
+ loopAttempts = 0;
5990
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redirect-loop-a"), require_request.FetchError, "Redirect loop detected");
5991
+ (0, node_assert.deepStrictEqual)(loopAttempts, 2);
5992
+ });
5993
+ let relativeLoopAttempts = 0;
5994
+ esm_default.get("https://example.com/redirect-loop-relative", () => {
5995
+ relativeLoopAttempts++;
5996
+ return {
5997
+ status: 302,
5998
+ headers: { Location: "/redirect-loop-relative" }
5999
+ };
6000
+ });
6001
+ await t.test("redirect loop with relative location", async () => {
6002
+ relativeLoopAttempts = 0;
6003
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redirect-loop-relative"), require_request.FetchError, "Redirect loop detected");
6004
+ (0, node_assert.deepStrictEqual)(relativeLoopAttempts, 1);
6005
+ });
5942
6006
  const maliciousPayload = "<a" + " a=\"b\"".repeat(30) + " ";
5943
6007
  esm_default.get("https://example.com/redos", {
5944
6008
  body: maliciousPayload,
@@ -1,4 +1,4 @@
1
- import { FetchError, createActivityPubRequest, deno_default, logRequest } from "./request-BW_eXGbS.js";
1
+ import { FetchError, createActivityPubRequest, deno_default, logRequest } from "./request-Cqx2eUpt.js";
2
2
  import { HttpHeaderLink } from "./link-Ck2yj4dH.js";
3
3
  import { UrlError, validatePublicUrl } from "./url-fW_DHbih.js";
4
4
  import "node:module";
@@ -5523,6 +5523,7 @@ const logger = getLogger([
5523
5523
  "runtime",
5524
5524
  "docloader"
5525
5525
  ]);
5526
+ const DEFAULT_MAX_REDIRECTION = 20;
5526
5527
  /**
5527
5528
  * Gets a {@link RemoteDocument} from the given response.
5528
5529
  * @param url The URL of the document to load.
@@ -5641,31 +5642,33 @@ async function getRemoteDocument(url, response, fetch$1) {
5641
5642
  function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
5642
5643
  const tracerProvider = trace.getTracerProvider();
5643
5644
  const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
5644
- async function load(url, options) {
5645
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
5645
5646
  options?.signal?.throwIfAborted();
5646
- if (!skipPreloadedContexts && url in contexts_default) {
5647
- logger.debug("Using preloaded context: {url}.", { url });
5647
+ const currentUrl = new URL(url).href;
5648
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
5649
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
5648
5650
  return {
5649
5651
  contextUrl: null,
5650
- document: contexts_default[url],
5651
- documentUrl: url
5652
+ document: contexts_default[currentUrl],
5653
+ documentUrl: currentUrl
5652
5654
  };
5653
5655
  }
5654
5656
  if (!allowPrivateAddress) try {
5655
- await validatePublicUrl(url);
5657
+ await validatePublicUrl(currentUrl);
5656
5658
  } catch (error) {
5657
5659
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
5658
- url,
5660
+ url: currentUrl,
5659
5661
  error
5660
5662
  });
5661
5663
  throw error;
5662
5664
  }
5665
+ visited.add(currentUrl);
5663
5666
  return await tracer.startActiveSpan("activitypub.fetch_document", {
5664
5667
  kind: SpanKind.CLIENT,
5665
- attributes: { "url.full": url }
5668
+ attributes: { "url.full": currentUrl }
5666
5669
  }, async (span) => {
5667
5670
  try {
5668
- const request = createActivityPubRequest(url, { userAgent });
5671
+ const request = createActivityPubRequest(currentUrl, { userAgent });
5669
5672
  logRequest(logger, request);
5670
5673
  const response = await fetch(request, {
5671
5674
  redirect: "manual",
@@ -5673,11 +5676,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5673
5676
  });
5674
5677
  span.setAttribute("http.response.status_code", response.status);
5675
5678
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
5676
- const redirectUrl = response.headers.get("Location");
5679
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
5680
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
5681
+ redirections: redirected + 1,
5682
+ url: currentUrl
5683
+ });
5684
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5685
+ }
5686
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
5677
5687
  span.setAttribute("http.redirect.url", redirectUrl);
5678
- return await load(redirectUrl, options);
5688
+ if (visited.has(redirectUrl)) {
5689
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
5690
+ url: currentUrl,
5691
+ redirectUrl
5692
+ });
5693
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
5694
+ }
5695
+ return await load(redirectUrl, options, redirected + 1, visited);
5679
5696
  }
5680
- const result = await getRemoteDocument(url, response, load);
5697
+ const result = await getRemoteDocument(currentUrl, response, load);
5681
5698
  span.setAttribute("docloader.document_url", result.documentUrl);
5682
5699
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
5683
5700
  return result;
@@ -5965,6 +5982,53 @@ test("getDocumentLoader()", async (t) => {
5965
5982
  deepStrictEqual(await fetchDocumentLoader2("https://example.com/localhost-redirect"), expected);
5966
5983
  deepStrictEqual(await fetchDocumentLoader2("https://example.com/localhost-link"), expected);
5967
5984
  });
5985
+ let redirectAttempts = 0;
5986
+ esm_default.get("begin:https://example.com/too-many-redirects/", (cl) => {
5987
+ redirectAttempts++;
5988
+ const index = Number(cl.url.split("/").at(-1));
5989
+ return {
5990
+ status: 302,
5991
+ headers: { Location: `https://example.com/too-many-redirects/${index + 1}` }
5992
+ };
5993
+ });
5994
+ await t.test("too many redirects", async () => {
5995
+ redirectAttempts = 0;
5996
+ await rejects(() => fetchDocumentLoader("https://example.com/too-many-redirects/0"), FetchError, "Too many redirections");
5997
+ deepStrictEqual(redirectAttempts, 21);
5998
+ });
5999
+ let loopAttempts = 0;
6000
+ esm_default.get("https://example.com/redirect-loop-a", () => {
6001
+ loopAttempts++;
6002
+ return {
6003
+ status: 302,
6004
+ headers: { Location: "https://example.com/redirect-loop-b" }
6005
+ };
6006
+ });
6007
+ esm_default.get("https://example.com/redirect-loop-b", () => {
6008
+ loopAttempts++;
6009
+ return {
6010
+ status: 302,
6011
+ headers: { Location: "https://example.com/redirect-loop-a" }
6012
+ };
6013
+ });
6014
+ await t.test("redirect loop", async () => {
6015
+ loopAttempts = 0;
6016
+ await rejects(() => fetchDocumentLoader("https://example.com/redirect-loop-a"), FetchError, "Redirect loop detected");
6017
+ deepStrictEqual(loopAttempts, 2);
6018
+ });
6019
+ let relativeLoopAttempts = 0;
6020
+ esm_default.get("https://example.com/redirect-loop-relative", () => {
6021
+ relativeLoopAttempts++;
6022
+ return {
6023
+ status: 302,
6024
+ headers: { Location: "/redirect-loop-relative" }
6025
+ };
6026
+ });
6027
+ await t.test("redirect loop with relative location", async () => {
6028
+ relativeLoopAttempts = 0;
6029
+ await rejects(() => fetchDocumentLoader("https://example.com/redirect-loop-relative"), FetchError, "Redirect loop detected");
6030
+ deepStrictEqual(relativeLoopAttempts, 1);
6031
+ });
5968
6032
  const maliciousPayload = "<a" + " a=\"b\"".repeat(30) + " ";
5969
6033
  esm_default.get("https://example.com/redos", {
5970
6034
  body: maliciousPayload,
@@ -2,7 +2,7 @@ import process from "node:process";
2
2
 
3
3
  //#region deno.json
4
4
  var name = "@fedify/vocab-runtime";
5
- var version = "2.0.7-pr.639.14+40546420";
5
+ var version = "2.0.8";
6
6
  var license = "MIT";
7
7
  var exports = {
8
8
  ".": "./src/mod.ts",
@@ -3,7 +3,7 @@ const node_process = require_chunk.__toESM(require("node:process"));
3
3
 
4
4
  //#region deno.json
5
5
  var name = "@fedify/vocab-runtime";
6
- var version = "2.0.7-pr.639.14+40546420";
6
+ var version = "2.0.8";
7
7
  var license = "MIT";
8
8
  var exports$1 = {
9
9
  ".": "./src/mod.ts",
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- const require_request = require('./request-B3KZmXmp.cjs');
2
+ const require_request = require('./request-D2-F2dMS.cjs');
3
3
  const node_assert = require_chunk.__toESM(require("node:assert"));
4
4
  const node_test = require_chunk.__toESM(require("node:test"));
5
5
  const node_process = require_chunk.__toESM(require("node:process"));
@@ -1,4 +1,4 @@
1
- import { deno_default, getUserAgent } from "./request-BW_eXGbS.js";
1
+ import { deno_default, getUserAgent } from "./request-Cqx2eUpt.js";
2
2
  import { deepStrictEqual } from "node:assert";
3
3
  import { test } from "node:test";
4
4
  import process from "node:process";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.0.7-pr.639.14+40546420",
3
+ "version": "2.0.8",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -361,6 +361,73 @@ test("getDocumentLoader()", async (t) => {
361
361
  );
362
362
  });
363
363
 
364
+ let redirectAttempts = 0;
365
+ fetchMock.get("begin:https://example.com/too-many-redirects/", (cl) => {
366
+ redirectAttempts++;
367
+ const index = Number(cl.url.split("/").at(-1));
368
+ return {
369
+ status: 302,
370
+ headers: {
371
+ Location: `https://example.com/too-many-redirects/${index + 1}`,
372
+ },
373
+ };
374
+ });
375
+
376
+ await t.test("too many redirects", async () => {
377
+ redirectAttempts = 0;
378
+ await rejects(
379
+ () => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
380
+ FetchError,
381
+ "Too many redirections",
382
+ );
383
+ deepStrictEqual(redirectAttempts, 21);
384
+ });
385
+
386
+ let loopAttempts = 0;
387
+ fetchMock.get("https://example.com/redirect-loop-a", () => {
388
+ loopAttempts++;
389
+ return {
390
+ status: 302,
391
+ headers: { Location: "https://example.com/redirect-loop-b" },
392
+ };
393
+ });
394
+ fetchMock.get("https://example.com/redirect-loop-b", () => {
395
+ loopAttempts++;
396
+ return {
397
+ status: 302,
398
+ headers: { Location: "https://example.com/redirect-loop-a" },
399
+ };
400
+ });
401
+
402
+ await t.test("redirect loop", async () => {
403
+ loopAttempts = 0;
404
+ await rejects(
405
+ () => fetchDocumentLoader("https://example.com/redirect-loop-a"),
406
+ FetchError,
407
+ "Redirect loop detected",
408
+ );
409
+ deepStrictEqual(loopAttempts, 2);
410
+ });
411
+
412
+ let relativeLoopAttempts = 0;
413
+ fetchMock.get("https://example.com/redirect-loop-relative", () => {
414
+ relativeLoopAttempts++;
415
+ return {
416
+ status: 302,
417
+ headers: { Location: "/redirect-loop-relative" },
418
+ };
419
+ });
420
+
421
+ await t.test("redirect loop with relative location", async () => {
422
+ relativeLoopAttempts = 0;
423
+ await rejects(
424
+ () => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
425
+ FetchError,
426
+ "Redirect loop detected",
427
+ );
428
+ deepStrictEqual(relativeLoopAttempts, 1);
429
+ });
430
+
364
431
  // Regression test for ReDoS vulnerability (CVE-2025-68475)
365
432
  // Malicious HTML payload: <a a="b" a="b" ... (unclosed tag)
366
433
  // With the vulnerable regex, this causes catastrophic backtracking
package/src/docloader.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { UrlError, validatePublicUrl } from "./url.ts";
13
13
 
14
14
  const logger = getLogger(["fedify", "runtime", "docloader"]);
15
+ const DEFAULT_MAX_REDIRECTION = 20;
15
16
 
16
17
  /**
17
18
  * A remote JSON-LD document and its context fetched by
@@ -292,38 +293,45 @@ export function getDocumentLoader(
292
293
  async function load(
293
294
  url: string,
294
295
  options?: DocumentLoaderOptions,
296
+ redirected = 0,
297
+ visited = new Set<string>(),
295
298
  ): Promise<RemoteDocument> {
296
299
  options?.signal?.throwIfAborted();
297
- if (!skipPreloadedContexts && url in preloadedContexts) {
298
- logger.debug("Using preloaded context: {url}.", { url });
300
+ const currentUrl = new URL(url).href;
301
+ if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
302
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
299
303
  return {
300
304
  contextUrl: null,
301
- document: preloadedContexts[url],
302
- documentUrl: url,
305
+ document: preloadedContexts[currentUrl],
306
+ documentUrl: currentUrl,
303
307
  };
304
308
  }
305
309
  if (!allowPrivateAddress) {
306
310
  try {
307
- await validatePublicUrl(url);
311
+ await validatePublicUrl(currentUrl);
308
312
  } catch (error) {
309
313
  if (error instanceof UrlError) {
310
- logger.error("Disallowed private URL: {url}", { url, error });
314
+ logger.error("Disallowed private URL: {url}", {
315
+ url: currentUrl,
316
+ error,
317
+ });
311
318
  }
312
319
  throw error;
313
320
  }
314
321
  }
322
+ visited.add(currentUrl);
315
323
 
316
324
  return await tracer.startActiveSpan(
317
325
  "activitypub.fetch_document",
318
326
  {
319
327
  kind: SpanKind.CLIENT,
320
328
  attributes: {
321
- "url.full": url,
329
+ "url.full": currentUrl,
322
330
  },
323
331
  },
324
332
  async (span) => {
325
333
  try {
326
- const request = createActivityPubRequest(url, { userAgent });
334
+ const request = createActivityPubRequest(currentUrl, { userAgent });
327
335
  logRequest(logger, request);
328
336
  const response = await fetch(request, {
329
337
  // Since Bun has a bug that ignores the `Request.redirect` option,
@@ -339,12 +347,36 @@ export function getDocumentLoader(
339
347
  response.status >= 300 && response.status < 400 &&
340
348
  response.headers.has("Location")
341
349
  ) {
342
- const redirectUrl = response.headers.get("Location")!;
350
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
351
+ logger.error(
352
+ "Too many redirections ({redirections}) while fetching document.",
353
+ { redirections: redirected + 1, url: currentUrl },
354
+ );
355
+ throw new FetchError(
356
+ currentUrl,
357
+ `Too many redirections (${redirected + 1})`,
358
+ );
359
+ }
360
+ const redirectUrl = new URL(
361
+ response.headers.get("Location")!,
362
+ response.url === "" ? currentUrl : response.url,
363
+ ).href;
343
364
  span.setAttribute("http.redirect.url", redirectUrl);
344
- return await load(redirectUrl, options);
365
+ if (visited.has(redirectUrl)) {
366
+ logger.error(
367
+ "Detected a redirect loop while fetching document: {url} -> " +
368
+ "{redirectUrl}",
369
+ { url: currentUrl, redirectUrl },
370
+ );
371
+ throw new FetchError(
372
+ currentUrl,
373
+ `Redirect loop detected: ${redirectUrl}`,
374
+ );
375
+ }
376
+ return await load(redirectUrl, options, redirected + 1, visited);
345
377
  }
346
378
 
347
- const result = await getRemoteDocument(url, response, load);
379
+ const result = await getRemoteDocument(currentUrl, response, load);
348
380
  span.setAttribute("docloader.document_url", result.documentUrl);
349
381
  if (result.contextUrl != null) {
350
382
  span.setAttribute("docloader.context_url", result.contextUrl);