@fedify/vocab-runtime 2.2.0-dev.606 → 2.2.0-dev.613

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.2.0-dev.606+3a976326",
3
+ "version": "2.2.0-dev.613+cf8cd122",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts",
package/dist/mod.cjs CHANGED
@@ -4376,7 +4376,7 @@ var contexts_default = preloadedContexts;
4376
4376
  //#endregion
4377
4377
  //#region deno.json
4378
4378
  var name = "@fedify/vocab-runtime";
4379
- var version = "2.2.0-dev.606+3a976326";
4379
+ var version = "2.2.0-dev.613+cf8cd122";
4380
4380
  var license = "MIT";
4381
4381
  var exports$1 = {
4382
4382
  ".": "./src/mod.ts",
@@ -4735,6 +4735,7 @@ const logger = (0, __logtape_logtape.getLogger)([
4735
4735
  "runtime",
4736
4736
  "docloader"
4737
4737
  ]);
4738
+ const DEFAULT_MAX_REDIRECTION = 20;
4738
4739
  /**
4739
4740
  * Gets a {@link RemoteDocument} from the given response.
4740
4741
  * @param url The URL of the document to load.
@@ -4850,34 +4851,37 @@ async function getRemoteDocument(url, response, fetch$1) {
4850
4851
  * @returns The document loader.
4851
4852
  * @since 1.3.0
4852
4853
  */
4853
- function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
4854
+ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}) {
4854
4855
  const tracerProvider = __opentelemetry_api.trace.getTracerProvider();
4855
4856
  const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
4856
- async function load(url, options) {
4857
+ const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
4858
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4857
4859
  options?.signal?.throwIfAborted();
4858
- if (!skipPreloadedContexts && url in contexts_default) {
4859
- logger.debug("Using preloaded context: {url}.", { url });
4860
+ const currentUrl = new URL(url).href;
4861
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4862
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4860
4863
  return {
4861
4864
  contextUrl: null,
4862
- document: contexts_default[url],
4863
- documentUrl: url
4865
+ document: contexts_default[currentUrl],
4866
+ documentUrl: currentUrl
4864
4867
  };
4865
4868
  }
4866
4869
  if (!allowPrivateAddress) try {
4867
- await validatePublicUrl(url);
4870
+ await validatePublicUrl(currentUrl);
4868
4871
  } catch (error) {
4869
4872
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4870
- url,
4873
+ url: currentUrl,
4871
4874
  error
4872
4875
  });
4873
4876
  throw error;
4874
4877
  }
4878
+ visited.add(currentUrl);
4875
4879
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4876
4880
  kind: __opentelemetry_api.SpanKind.CLIENT,
4877
- attributes: { "url.full": url }
4881
+ attributes: { "url.full": currentUrl }
4878
4882
  }, async (span) => {
4879
4883
  try {
4880
- const request = createActivityPubRequest(url, { userAgent });
4884
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4881
4885
  logRequest(logger, request);
4882
4886
  const response = await fetch(request, {
4883
4887
  redirect: "manual",
@@ -4885,11 +4889,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4885
4889
  });
4886
4890
  span.setAttribute("http.response.status_code", response.status);
4887
4891
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4888
- const redirectUrl = response.headers.get("Location");
4892
+ if (redirected >= maximumRedirection) {
4893
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4894
+ redirections: redirected + 1,
4895
+ url: currentUrl
4896
+ });
4897
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4898
+ }
4899
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4889
4900
  span.setAttribute("http.redirect.url", redirectUrl);
4890
- return await load(redirectUrl, options);
4901
+ if (visited.has(redirectUrl)) {
4902
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4903
+ url: currentUrl,
4904
+ redirectUrl
4905
+ });
4906
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4907
+ }
4908
+ return await load(redirectUrl, options, redirected + 1, visited);
4891
4909
  }
4892
- const result = await getRemoteDocument(url, response, load);
4910
+ const result = await getRemoteDocument(currentUrl, response, load);
4893
4911
  span.setAttribute("docloader.document_url", result.documentUrl);
4894
4912
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4895
4913
  return result;
package/dist/mod.d.cts CHANGED
@@ -136,6 +136,12 @@ interface DocumentLoaderFactoryOptions {
136
136
  * If an object is given, it is passed to {@link getUserAgent} function.
137
137
  */
138
138
  userAgent?: GetUserAgentOptions | string;
139
+ /**
140
+ * The maximum number of redirections to follow.
141
+ * @default `20`
142
+ * @since 2.2.0
143
+ */
144
+ maxRedirection?: number;
139
145
  }
140
146
  /**
141
147
  * A factory function that creates an authenticated {@link DocumentLoader} for
@@ -190,6 +196,7 @@ interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
190
196
  */
191
197
  declare function getDocumentLoader({
192
198
  allowPrivateAddress,
199
+ maxRedirection,
193
200
  skipPreloadedContexts,
194
201
  userAgent
195
202
  }?: GetDocumentLoaderOptions): DocumentLoader;
package/dist/mod.d.ts CHANGED
@@ -136,6 +136,12 @@ interface DocumentLoaderFactoryOptions {
136
136
  * If an object is given, it is passed to {@link getUserAgent} function.
137
137
  */
138
138
  userAgent?: GetUserAgentOptions | string;
139
+ /**
140
+ * The maximum number of redirections to follow.
141
+ * @default `20`
142
+ * @since 2.2.0
143
+ */
144
+ maxRedirection?: number;
139
145
  }
140
146
  /**
141
147
  * A factory function that creates an authenticated {@link DocumentLoader} for
@@ -190,6 +196,7 @@ interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
190
196
  */
191
197
  declare function getDocumentLoader({
192
198
  allowPrivateAddress,
199
+ maxRedirection,
193
200
  skipPreloadedContexts,
194
201
  userAgent
195
202
  }?: GetDocumentLoaderOptions): DocumentLoader;
package/dist/mod.js CHANGED
@@ -4375,7 +4375,7 @@ var contexts_default = preloadedContexts;
4375
4375
  //#endregion
4376
4376
  //#region deno.json
4377
4377
  var name = "@fedify/vocab-runtime";
4378
- var version = "2.2.0-dev.606+3a976326";
4378
+ var version = "2.2.0-dev.613+cf8cd122";
4379
4379
  var license = "MIT";
4380
4380
  var exports = {
4381
4381
  ".": "./src/mod.ts",
@@ -4734,6 +4734,7 @@ const logger = getLogger([
4734
4734
  "runtime",
4735
4735
  "docloader"
4736
4736
  ]);
4737
+ const DEFAULT_MAX_REDIRECTION = 20;
4737
4738
  /**
4738
4739
  * Gets a {@link RemoteDocument} from the given response.
4739
4740
  * @param url The URL of the document to load.
@@ -4849,34 +4850,37 @@ async function getRemoteDocument(url, response, fetch$1) {
4849
4850
  * @returns The document loader.
4850
4851
  * @since 1.3.0
4851
4852
  */
4852
- function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
4853
+ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}) {
4853
4854
  const tracerProvider = trace.getTracerProvider();
4854
4855
  const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
4855
- async function load(url, options) {
4856
+ const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
4857
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4856
4858
  options?.signal?.throwIfAborted();
4857
- if (!skipPreloadedContexts && url in contexts_default) {
4858
- logger.debug("Using preloaded context: {url}.", { url });
4859
+ const currentUrl = new URL(url).href;
4860
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4861
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4859
4862
  return {
4860
4863
  contextUrl: null,
4861
- document: contexts_default[url],
4862
- documentUrl: url
4864
+ document: contexts_default[currentUrl],
4865
+ documentUrl: currentUrl
4863
4866
  };
4864
4867
  }
4865
4868
  if (!allowPrivateAddress) try {
4866
- await validatePublicUrl(url);
4869
+ await validatePublicUrl(currentUrl);
4867
4870
  } catch (error) {
4868
4871
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4869
- url,
4872
+ url: currentUrl,
4870
4873
  error
4871
4874
  });
4872
4875
  throw error;
4873
4876
  }
4877
+ visited.add(currentUrl);
4874
4878
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4875
4879
  kind: SpanKind.CLIENT,
4876
- attributes: { "url.full": url }
4880
+ attributes: { "url.full": currentUrl }
4877
4881
  }, async (span) => {
4878
4882
  try {
4879
- const request = createActivityPubRequest(url, { userAgent });
4883
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4880
4884
  logRequest(logger, request);
4881
4885
  const response = await fetch(request, {
4882
4886
  redirect: "manual",
@@ -4884,11 +4888,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4884
4888
  });
4885
4889
  span.setAttribute("http.response.status_code", response.status);
4886
4890
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4887
- const redirectUrl = response.headers.get("Location");
4891
+ if (redirected >= maximumRedirection) {
4892
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4893
+ redirections: redirected + 1,
4894
+ url: currentUrl
4895
+ });
4896
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4897
+ }
4898
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4888
4899
  span.setAttribute("http.redirect.url", redirectUrl);
4889
- return await load(redirectUrl, options);
4900
+ if (visited.has(redirectUrl)) {
4901
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4902
+ url: currentUrl,
4903
+ redirectUrl
4904
+ });
4905
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4906
+ }
4907
+ return await load(redirectUrl, options, redirected + 1, visited);
4890
4908
  }
4891
- const result = await getRemoteDocument(url, response, load);
4909
+ const result = await getRemoteDocument(currentUrl, response, load);
4892
4910
  span.setAttribute("docloader.document_url", result.documentUrl);
4893
4911
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4894
4912
  return result;
@@ -1,6 +1,6 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- require('./docloader-BVqyre7B.cjs');
3
- require('./request-CvhpFknJ.cjs');
2
+ require('./docloader-txRGFv1x.cjs');
3
+ require('./request-73FIhAHw.cjs');
4
4
  require('./link-DYNFAdNu.cjs');
5
5
  require('./url-DIjOdK8Q.cjs');
6
6
  require('./multicodec--6hQ74zI.cjs');
@@ -1,5 +1,5 @@
1
- import "./docloader-Ch--TwSL.js";
2
- import "./request-DHlP8Ayx.js";
1
+ import "./docloader-DEi540Xh.js";
2
+ import "./request-BO6hGoBJ.js";
3
3
  import "./link-C3q2TC2G.js";
4
4
  import "./url-CWEP9Zs9.js";
5
5
  import "./multicodec-Dq3IiOV4.js";
@@ -1,4 +1,4 @@
1
- import { FetchError, createActivityPubRequest, deno_default, logRequest } from "./request-DHlP8Ayx.js";
1
+ import { FetchError, createActivityPubRequest, deno_default, logRequest } from "./request-BO6hGoBJ.js";
2
2
  import { HttpHeaderLink } from "./link-C3q2TC2G.js";
3
3
  import { UrlError, validatePublicUrl } from "./url-CWEP9Zs9.js";
4
4
  import { getLogger } from "@logtape/logtape";
@@ -4373,6 +4373,7 @@ const logger = getLogger([
4373
4373
  "runtime",
4374
4374
  "docloader"
4375
4375
  ]);
4376
+ const DEFAULT_MAX_REDIRECTION = 20;
4376
4377
  /**
4377
4378
  * Gets a {@link RemoteDocument} from the given response.
4378
4379
  * @param url The URL of the document to load.
@@ -4488,34 +4489,37 @@ async function getRemoteDocument(url, response, fetch$1) {
4488
4489
  * @returns The document loader.
4489
4490
  * @since 1.3.0
4490
4491
  */
4491
- function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
4492
+ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}) {
4492
4493
  const tracerProvider = trace.getTracerProvider();
4493
4494
  const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
4494
- async function load(url, options) {
4495
+ const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
4496
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4495
4497
  options?.signal?.throwIfAborted();
4496
- if (!skipPreloadedContexts && url in contexts_default) {
4497
- logger.debug("Using preloaded context: {url}.", { url });
4498
+ const currentUrl = new URL(url).href;
4499
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4500
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4498
4501
  return {
4499
4502
  contextUrl: null,
4500
- document: contexts_default[url],
4501
- documentUrl: url
4503
+ document: contexts_default[currentUrl],
4504
+ documentUrl: currentUrl
4502
4505
  };
4503
4506
  }
4504
4507
  if (!allowPrivateAddress) try {
4505
- await validatePublicUrl(url);
4508
+ await validatePublicUrl(currentUrl);
4506
4509
  } catch (error) {
4507
4510
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4508
- url,
4511
+ url: currentUrl,
4509
4512
  error
4510
4513
  });
4511
4514
  throw error;
4512
4515
  }
4516
+ visited.add(currentUrl);
4513
4517
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4514
4518
  kind: SpanKind.CLIENT,
4515
- attributes: { "url.full": url }
4519
+ attributes: { "url.full": currentUrl }
4516
4520
  }, async (span) => {
4517
4521
  try {
4518
- const request = createActivityPubRequest(url, { userAgent });
4522
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4519
4523
  logRequest(logger, request);
4520
4524
  const response = await fetch(request, {
4521
4525
  redirect: "manual",
@@ -4523,11 +4527,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4523
4527
  });
4524
4528
  span.setAttribute("http.response.status_code", response.status);
4525
4529
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4526
- const redirectUrl = response.headers.get("Location");
4530
+ if (redirected >= maximumRedirection) {
4531
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4532
+ redirections: redirected + 1,
4533
+ url: currentUrl
4534
+ });
4535
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4536
+ }
4537
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4527
4538
  span.setAttribute("http.redirect.url", redirectUrl);
4528
- return await load(redirectUrl, options);
4539
+ if (visited.has(redirectUrl)) {
4540
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4541
+ url: currentUrl,
4542
+ redirectUrl
4543
+ });
4544
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4545
+ }
4546
+ return await load(redirectUrl, options, redirected + 1, visited);
4529
4547
  }
4530
- const result = await getRemoteDocument(url, response, load);
4548
+ const result = await getRemoteDocument(currentUrl, response, load);
4531
4549
  span.setAttribute("docloader.document_url", result.documentUrl);
4532
4550
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4533
4551
  return result;
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- const require_request = require('./request-CvhpFknJ.cjs');
2
+ const require_request = require('./request-73FIhAHw.cjs');
3
3
  const require_link = require('./link-DYNFAdNu.cjs');
4
4
  const require_url = require('./url-DIjOdK8Q.cjs');
5
5
  const __logtape_logtape = require_chunk.__toESM(require("@logtape/logtape"));
@@ -4374,6 +4374,7 @@ const logger = (0, __logtape_logtape.getLogger)([
4374
4374
  "runtime",
4375
4375
  "docloader"
4376
4376
  ]);
4377
+ const DEFAULT_MAX_REDIRECTION = 20;
4377
4378
  /**
4378
4379
  * Gets a {@link RemoteDocument} from the given response.
4379
4380
  * @param url The URL of the document to load.
@@ -4489,34 +4490,37 @@ async function getRemoteDocument(url, response, fetch$1) {
4489
4490
  * @returns The document loader.
4490
4491
  * @since 1.3.0
4491
4492
  */
4492
- function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAgent } = {}) {
4493
+ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}) {
4493
4494
  const tracerProvider = __opentelemetry_api.trace.getTracerProvider();
4494
4495
  const tracer = tracerProvider.getTracer(require_request.deno_default.name, require_request.deno_default.version);
4495
- async function load(url, options) {
4496
+ const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
4497
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4496
4498
  options?.signal?.throwIfAborted();
4497
- if (!skipPreloadedContexts && url in contexts_default) {
4498
- logger.debug("Using preloaded context: {url}.", { url });
4499
+ const currentUrl = new URL(url).href;
4500
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4501
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4499
4502
  return {
4500
4503
  contextUrl: null,
4501
- document: contexts_default[url],
4502
- documentUrl: url
4504
+ document: contexts_default[currentUrl],
4505
+ documentUrl: currentUrl
4503
4506
  };
4504
4507
  }
4505
4508
  if (!allowPrivateAddress) try {
4506
- await require_url.validatePublicUrl(url);
4509
+ await require_url.validatePublicUrl(currentUrl);
4507
4510
  } catch (error) {
4508
4511
  if (error instanceof require_url.UrlError) logger.error("Disallowed private URL: {url}", {
4509
- url,
4512
+ url: currentUrl,
4510
4513
  error
4511
4514
  });
4512
4515
  throw error;
4513
4516
  }
4517
+ visited.add(currentUrl);
4514
4518
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4515
4519
  kind: __opentelemetry_api.SpanKind.CLIENT,
4516
- attributes: { "url.full": url }
4520
+ attributes: { "url.full": currentUrl }
4517
4521
  }, async (span) => {
4518
4522
  try {
4519
- const request = require_request.createActivityPubRequest(url, { userAgent });
4523
+ const request = require_request.createActivityPubRequest(currentUrl, { userAgent });
4520
4524
  require_request.logRequest(logger, request);
4521
4525
  const response = await fetch(request, {
4522
4526
  redirect: "manual",
@@ -4524,11 +4528,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4524
4528
  });
4525
4529
  span.setAttribute("http.response.status_code", response.status);
4526
4530
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4527
- const redirectUrl = response.headers.get("Location");
4531
+ if (redirected >= maximumRedirection) {
4532
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4533
+ redirections: redirected + 1,
4534
+ url: currentUrl
4535
+ });
4536
+ throw new require_request.FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4537
+ }
4538
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4528
4539
  span.setAttribute("http.redirect.url", redirectUrl);
4529
- return await load(redirectUrl, options);
4540
+ if (visited.has(redirectUrl)) {
4541
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4542
+ url: currentUrl,
4543
+ redirectUrl
4544
+ });
4545
+ throw new require_request.FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4546
+ }
4547
+ return await load(redirectUrl, options, redirected + 1, visited);
4530
4548
  }
4531
- const result = await getRemoteDocument(url, response, load);
4549
+ const result = await getRemoteDocument(currentUrl, response, load);
4532
4550
  span.setAttribute("docloader.document_url", result.documentUrl);
4533
4551
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4534
4552
  return result;
@@ -1,6 +1,6 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- const require_docloader = require('./docloader-BVqyre7B.cjs');
3
- const require_request = require('./request-CvhpFknJ.cjs');
2
+ const require_docloader = require('./docloader-txRGFv1x.cjs');
3
+ const require_request = require('./request-73FIhAHw.cjs');
4
4
  require('./link-DYNFAdNu.cjs');
5
5
  const require_url = require('./url-DIjOdK8Q.cjs');
6
6
  const node_assert = require_chunk.__toESM(require("node:assert"));
@@ -1512,6 +1512,59 @@ var esm_default = FetchMock_default;
1512
1512
  (0, node_assert.deepStrictEqual)(await fetchDocumentLoader2("https://example.com/localhost-redirect"), expected);
1513
1513
  (0, node_assert.deepStrictEqual)(await fetchDocumentLoader2("https://example.com/localhost-link"), expected);
1514
1514
  });
1515
+ let redirectAttempts = 0;
1516
+ esm_default.get("begin:https://example.com/too-many-redirects/", (cl) => {
1517
+ redirectAttempts++;
1518
+ const index = Number(cl.url.split("/").at(-1));
1519
+ return {
1520
+ status: 302,
1521
+ headers: { Location: `https://example.com/too-many-redirects/${index + 1}` }
1522
+ };
1523
+ });
1524
+ await t.test("too many redirects", async () => {
1525
+ redirectAttempts = 0;
1526
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/too-many-redirects/0"), require_request.FetchError, "Too many redirections");
1527
+ (0, node_assert.deepStrictEqual)(redirectAttempts, 21);
1528
+ });
1529
+ await t.test("custom max redirection", async () => {
1530
+ redirectAttempts = 0;
1531
+ const loader = require_docloader.getDocumentLoader({ maxRedirection: 1 });
1532
+ await (0, node_assert.rejects)(() => loader("https://example.com/too-many-redirects/0"), require_request.FetchError, "Too many redirections");
1533
+ (0, node_assert.deepStrictEqual)(redirectAttempts, 2);
1534
+ });
1535
+ let loopAttempts = 0;
1536
+ esm_default.get("https://example.com/redirect-loop-a", () => {
1537
+ loopAttempts++;
1538
+ return {
1539
+ status: 302,
1540
+ headers: { Location: "https://example.com/redirect-loop-b" }
1541
+ };
1542
+ });
1543
+ esm_default.get("https://example.com/redirect-loop-b", () => {
1544
+ loopAttempts++;
1545
+ return {
1546
+ status: 302,
1547
+ headers: { Location: "https://example.com/redirect-loop-a" }
1548
+ };
1549
+ });
1550
+ await t.test("redirect loop", async () => {
1551
+ loopAttempts = 0;
1552
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redirect-loop-a"), require_request.FetchError, "Redirect loop detected");
1553
+ (0, node_assert.deepStrictEqual)(loopAttempts, 2);
1554
+ });
1555
+ let relativeLoopAttempts = 0;
1556
+ esm_default.get("https://example.com/redirect-loop-relative", () => {
1557
+ relativeLoopAttempts++;
1558
+ return {
1559
+ status: 302,
1560
+ headers: { Location: "/redirect-loop-relative" }
1561
+ };
1562
+ });
1563
+ await t.test("redirect loop with relative location", async () => {
1564
+ relativeLoopAttempts = 0;
1565
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redirect-loop-relative"), require_request.FetchError, "Redirect loop detected");
1566
+ (0, node_assert.deepStrictEqual)(relativeLoopAttempts, 1);
1567
+ });
1515
1568
  const maliciousPayload = "<a" + " a=\"b\"".repeat(30) + " ";
1516
1569
  esm_default.get("https://example.com/redos", {
1517
1570
  body: maliciousPayload,
@@ -1,5 +1,5 @@
1
- import { contexts_default, getDocumentLoader } from "./docloader-Ch--TwSL.js";
2
- import { FetchError } from "./request-DHlP8Ayx.js";
1
+ import { contexts_default, getDocumentLoader } from "./docloader-DEi540Xh.js";
2
+ import { FetchError } from "./request-BO6hGoBJ.js";
3
3
  import "./link-C3q2TC2G.js";
4
4
  import { UrlError } from "./url-CWEP9Zs9.js";
5
5
  import "node:module";
@@ -1538,6 +1538,59 @@ test("getDocumentLoader()", async (t) => {
1538
1538
  deepStrictEqual(await fetchDocumentLoader2("https://example.com/localhost-redirect"), expected);
1539
1539
  deepStrictEqual(await fetchDocumentLoader2("https://example.com/localhost-link"), expected);
1540
1540
  });
1541
+ let redirectAttempts = 0;
1542
+ esm_default.get("begin:https://example.com/too-many-redirects/", (cl) => {
1543
+ redirectAttempts++;
1544
+ const index = Number(cl.url.split("/").at(-1));
1545
+ return {
1546
+ status: 302,
1547
+ headers: { Location: `https://example.com/too-many-redirects/${index + 1}` }
1548
+ };
1549
+ });
1550
+ await t.test("too many redirects", async () => {
1551
+ redirectAttempts = 0;
1552
+ await rejects(() => fetchDocumentLoader("https://example.com/too-many-redirects/0"), FetchError, "Too many redirections");
1553
+ deepStrictEqual(redirectAttempts, 21);
1554
+ });
1555
+ await t.test("custom max redirection", async () => {
1556
+ redirectAttempts = 0;
1557
+ const loader = getDocumentLoader({ maxRedirection: 1 });
1558
+ await rejects(() => loader("https://example.com/too-many-redirects/0"), FetchError, "Too many redirections");
1559
+ deepStrictEqual(redirectAttempts, 2);
1560
+ });
1561
+ let loopAttempts = 0;
1562
+ esm_default.get("https://example.com/redirect-loop-a", () => {
1563
+ loopAttempts++;
1564
+ return {
1565
+ status: 302,
1566
+ headers: { Location: "https://example.com/redirect-loop-b" }
1567
+ };
1568
+ });
1569
+ esm_default.get("https://example.com/redirect-loop-b", () => {
1570
+ loopAttempts++;
1571
+ return {
1572
+ status: 302,
1573
+ headers: { Location: "https://example.com/redirect-loop-a" }
1574
+ };
1575
+ });
1576
+ await t.test("redirect loop", async () => {
1577
+ loopAttempts = 0;
1578
+ await rejects(() => fetchDocumentLoader("https://example.com/redirect-loop-a"), FetchError, "Redirect loop detected");
1579
+ deepStrictEqual(loopAttempts, 2);
1580
+ });
1581
+ let relativeLoopAttempts = 0;
1582
+ esm_default.get("https://example.com/redirect-loop-relative", () => {
1583
+ relativeLoopAttempts++;
1584
+ return {
1585
+ status: 302,
1586
+ headers: { Location: "/redirect-loop-relative" }
1587
+ };
1588
+ });
1589
+ await t.test("redirect loop with relative location", async () => {
1590
+ relativeLoopAttempts = 0;
1591
+ await rejects(() => fetchDocumentLoader("https://example.com/redirect-loop-relative"), FetchError, "Redirect loop detected");
1592
+ deepStrictEqual(relativeLoopAttempts, 1);
1593
+ });
1541
1594
  const maliciousPayload = "<a" + " a=\"b\"".repeat(30) + " ";
1542
1595
  esm_default.get("https://example.com/redos", {
1543
1596
  body: maliciousPayload,
@@ -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.2.0-dev.606+3a976326";
6
+ var version = "2.2.0-dev.613+cf8cd122";
7
7
  var license = "MIT";
8
8
  var exports$1 = {
9
9
  ".": "./src/mod.ts",
@@ -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.2.0-dev.606+3a976326";
5
+ var version = "2.2.0-dev.613+cf8cd122";
6
6
  var license = "MIT";
7
7
  var exports = {
8
8
  ".": "./src/mod.ts",
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- const require_request = require('./request-CvhpFknJ.cjs');
2
+ const require_request = require('./request-73FIhAHw.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-DHlP8Ayx.js";
1
+ import { deno_default, getUserAgent } from "./request-BO6hGoBJ.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.2.0-dev.606+3a976326",
3
+ "version": "2.2.0-dev.613+cf8cd122",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -369,6 +369,84 @@ test("getDocumentLoader()", async (t) => {
369
369
  );
370
370
  });
371
371
 
372
+ let redirectAttempts = 0;
373
+ fetchMock.get("begin:https://example.com/too-many-redirects/", (cl) => {
374
+ redirectAttempts++;
375
+ const index = Number(cl.url.split("/").at(-1));
376
+ return {
377
+ status: 302,
378
+ headers: {
379
+ Location: `https://example.com/too-many-redirects/${index + 1}`,
380
+ },
381
+ };
382
+ });
383
+
384
+ await t.test("too many redirects", async () => {
385
+ redirectAttempts = 0;
386
+ await rejects(
387
+ () => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
388
+ FetchError,
389
+ "Too many redirections",
390
+ );
391
+ deepStrictEqual(redirectAttempts, 21);
392
+ });
393
+
394
+ await t.test("custom max redirection", async () => {
395
+ redirectAttempts = 0;
396
+ const loader = getDocumentLoader({ maxRedirection: 1 });
397
+ await rejects(
398
+ () => loader("https://example.com/too-many-redirects/0"),
399
+ FetchError,
400
+ "Too many redirections",
401
+ );
402
+ deepStrictEqual(redirectAttempts, 2);
403
+ });
404
+
405
+ let loopAttempts = 0;
406
+ fetchMock.get("https://example.com/redirect-loop-a", () => {
407
+ loopAttempts++;
408
+ return {
409
+ status: 302,
410
+ headers: { Location: "https://example.com/redirect-loop-b" },
411
+ };
412
+ });
413
+ fetchMock.get("https://example.com/redirect-loop-b", () => {
414
+ loopAttempts++;
415
+ return {
416
+ status: 302,
417
+ headers: { Location: "https://example.com/redirect-loop-a" },
418
+ };
419
+ });
420
+
421
+ await t.test("redirect loop", async () => {
422
+ loopAttempts = 0;
423
+ await rejects(
424
+ () => fetchDocumentLoader("https://example.com/redirect-loop-a"),
425
+ FetchError,
426
+ "Redirect loop detected",
427
+ );
428
+ deepStrictEqual(loopAttempts, 2);
429
+ });
430
+
431
+ let relativeLoopAttempts = 0;
432
+ fetchMock.get("https://example.com/redirect-loop-relative", () => {
433
+ relativeLoopAttempts++;
434
+ return {
435
+ status: 302,
436
+ headers: { Location: "/redirect-loop-relative" },
437
+ };
438
+ });
439
+
440
+ await t.test("redirect loop with relative location", async () => {
441
+ relativeLoopAttempts = 0;
442
+ await rejects(
443
+ () => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
444
+ FetchError,
445
+ "Redirect loop detected",
446
+ );
447
+ deepStrictEqual(relativeLoopAttempts, 1);
448
+ });
449
+
372
450
  // Regression test for ReDoS vulnerability (CVE-2025-68475)
373
451
  // Malicious HTML payload: <a a="b" a="b" ... (unclosed tag)
374
452
  // 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
@@ -87,6 +88,13 @@ export interface DocumentLoaderFactoryOptions {
87
88
  * If an object is given, it is passed to {@link getUserAgent} function.
88
89
  */
89
90
  userAgent?: GetUserAgentOptions | string;
91
+
92
+ /**
93
+ * The maximum number of redirections to follow.
94
+ * @default `20`
95
+ * @since 2.2.0
96
+ */
97
+ maxRedirection?: number;
90
98
  }
91
99
 
92
100
  /**
@@ -284,47 +292,55 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
284
292
  * @since 1.3.0
285
293
  */
286
294
  export function getDocumentLoader(
287
- { allowPrivateAddress, skipPreloadedContexts, userAgent }:
295
+ { allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent }:
288
296
  GetDocumentLoaderOptions = {},
289
297
  ): DocumentLoader {
290
298
  const tracerProvider = trace.getTracerProvider();
291
299
  const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
300
+ const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
292
301
 
293
302
  async function load(
294
303
  url: string,
295
304
  options?: DocumentLoaderOptions,
305
+ redirected = 0,
306
+ visited = new Set<string>(),
296
307
  ): Promise<RemoteDocument> {
297
308
  options?.signal?.throwIfAborted();
298
- if (!skipPreloadedContexts && url in preloadedContexts) {
299
- logger.debug("Using preloaded context: {url}.", { url });
309
+ const currentUrl = new URL(url).href;
310
+ if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
311
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
300
312
  return {
301
313
  contextUrl: null,
302
- document: preloadedContexts[url],
303
- documentUrl: url,
314
+ document: preloadedContexts[currentUrl],
315
+ documentUrl: currentUrl,
304
316
  };
305
317
  }
306
318
  if (!allowPrivateAddress) {
307
319
  try {
308
- await validatePublicUrl(url);
320
+ await validatePublicUrl(currentUrl);
309
321
  } catch (error) {
310
322
  if (error instanceof UrlError) {
311
- logger.error("Disallowed private URL: {url}", { url, error });
323
+ logger.error("Disallowed private URL: {url}", {
324
+ url: currentUrl,
325
+ error,
326
+ });
312
327
  }
313
328
  throw error;
314
329
  }
315
330
  }
331
+ visited.add(currentUrl);
316
332
 
317
333
  return await tracer.startActiveSpan(
318
334
  "activitypub.fetch_document",
319
335
  {
320
336
  kind: SpanKind.CLIENT,
321
337
  attributes: {
322
- "url.full": url,
338
+ "url.full": currentUrl,
323
339
  },
324
340
  },
325
341
  async (span) => {
326
342
  try {
327
- const request = createActivityPubRequest(url, { userAgent });
343
+ const request = createActivityPubRequest(currentUrl, { userAgent });
328
344
  logRequest(logger, request);
329
345
  const response = await fetch(request, {
330
346
  // Since Bun has a bug that ignores the `Request.redirect` option,
@@ -340,12 +356,36 @@ export function getDocumentLoader(
340
356
  response.status >= 300 && response.status < 400 &&
341
357
  response.headers.has("Location")
342
358
  ) {
343
- const redirectUrl = response.headers.get("Location")!;
359
+ if (redirected >= maximumRedirection) {
360
+ logger.error(
361
+ "Too many redirections ({redirections}) while fetching document.",
362
+ { redirections: redirected + 1, url: currentUrl },
363
+ );
364
+ throw new FetchError(
365
+ currentUrl,
366
+ `Too many redirections (${redirected + 1})`,
367
+ );
368
+ }
369
+ const redirectUrl = new URL(
370
+ response.headers.get("Location")!,
371
+ response.url === "" ? currentUrl : response.url,
372
+ ).href;
344
373
  span.setAttribute("http.redirect.url", redirectUrl);
345
- return await load(redirectUrl, options);
374
+ if (visited.has(redirectUrl)) {
375
+ logger.error(
376
+ "Detected a redirect loop while fetching document: {url} -> " +
377
+ "{redirectUrl}",
378
+ { url: currentUrl, redirectUrl },
379
+ );
380
+ throw new FetchError(
381
+ currentUrl,
382
+ `Redirect loop detected: ${redirectUrl}`,
383
+ );
384
+ }
385
+ return await load(redirectUrl, options, redirected + 1, visited);
346
386
  }
347
387
 
348
- const result = await getRemoteDocument(url, response, load);
388
+ const result = await getRemoteDocument(currentUrl, response, load);
349
389
  span.setAttribute("docloader.document_url", result.documentUrl);
350
390
  if (result.contextUrl != null) {
351
391
  span.setAttribute("docloader.context_url", result.contextUrl);