@fedify/vocab-runtime 2.1.0 → 2.1.1

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.1.0",
3
+ "version": "2.1.1",
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.1.0";
4379
+ var version = "2.1.1";
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.
@@ -4853,31 +4854,33 @@ async function getRemoteDocument(url, response, fetch$1) {
4853
4854
  function getDocumentLoader({ allowPrivateAddress, 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
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4857
4858
  options?.signal?.throwIfAborted();
4858
- if (!skipPreloadedContexts && url in contexts_default) {
4859
- 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 });
4860
4862
  return {
4861
4863
  contextUrl: null,
4862
- document: contexts_default[url],
4863
- documentUrl: url
4864
+ document: contexts_default[currentUrl],
4865
+ documentUrl: currentUrl
4864
4866
  };
4865
4867
  }
4866
4868
  if (!allowPrivateAddress) try {
4867
- await validatePublicUrl(url);
4869
+ await validatePublicUrl(currentUrl);
4868
4870
  } catch (error) {
4869
4871
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4870
- url,
4872
+ url: currentUrl,
4871
4873
  error
4872
4874
  });
4873
4875
  throw error;
4874
4876
  }
4877
+ visited.add(currentUrl);
4875
4878
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4876
4879
  kind: __opentelemetry_api.SpanKind.CLIENT,
4877
- attributes: { "url.full": url }
4880
+ attributes: { "url.full": currentUrl }
4878
4881
  }, async (span) => {
4879
4882
  try {
4880
- const request = createActivityPubRequest(url, { userAgent });
4883
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4881
4884
  logRequest(logger, request);
4882
4885
  const response = await fetch(request, {
4883
4886
  redirect: "manual",
@@ -4885,11 +4888,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4885
4888
  });
4886
4889
  span.setAttribute("http.response.status_code", response.status);
4887
4890
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4888
- const redirectUrl = response.headers.get("Location");
4891
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
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;
4889
4899
  span.setAttribute("http.redirect.url", redirectUrl);
4890
- 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);
4891
4908
  }
4892
- const result = await getRemoteDocument(url, response, load);
4909
+ const result = await getRemoteDocument(currentUrl, response, load);
4893
4910
  span.setAttribute("docloader.document_url", result.documentUrl);
4894
4911
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4895
4912
  return result;
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.1.0";
4378
+ var version = "2.1.1";
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.
@@ -4852,31 +4853,33 @@ async function getRemoteDocument(url, response, fetch$1) {
4852
4853
  function getDocumentLoader({ allowPrivateAddress, 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
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4856
4857
  options?.signal?.throwIfAborted();
4857
- if (!skipPreloadedContexts && url in contexts_default) {
4858
- logger.debug("Using preloaded context: {url}.", { url });
4858
+ const currentUrl = new URL(url).href;
4859
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4860
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4859
4861
  return {
4860
4862
  contextUrl: null,
4861
- document: contexts_default[url],
4862
- documentUrl: url
4863
+ document: contexts_default[currentUrl],
4864
+ documentUrl: currentUrl
4863
4865
  };
4864
4866
  }
4865
4867
  if (!allowPrivateAddress) try {
4866
- await validatePublicUrl(url);
4868
+ await validatePublicUrl(currentUrl);
4867
4869
  } catch (error) {
4868
4870
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4869
- url,
4871
+ url: currentUrl,
4870
4872
  error
4871
4873
  });
4872
4874
  throw error;
4873
4875
  }
4876
+ visited.add(currentUrl);
4874
4877
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4875
4878
  kind: SpanKind.CLIENT,
4876
- attributes: { "url.full": url }
4879
+ attributes: { "url.full": currentUrl }
4877
4880
  }, async (span) => {
4878
4881
  try {
4879
- const request = createActivityPubRequest(url, { userAgent });
4882
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4880
4883
  logRequest(logger, request);
4881
4884
  const response = await fetch(request, {
4882
4885
  redirect: "manual",
@@ -4884,11 +4887,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4884
4887
  });
4885
4888
  span.setAttribute("http.response.status_code", response.status);
4886
4889
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4887
- const redirectUrl = response.headers.get("Location");
4890
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
4891
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4892
+ redirections: redirected + 1,
4893
+ url: currentUrl
4894
+ });
4895
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4896
+ }
4897
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4888
4898
  span.setAttribute("http.redirect.url", redirectUrl);
4889
- return await load(redirectUrl, options);
4899
+ if (visited.has(redirectUrl)) {
4900
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4901
+ url: currentUrl,
4902
+ redirectUrl
4903
+ });
4904
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4905
+ }
4906
+ return await load(redirectUrl, options, redirected + 1, visited);
4890
4907
  }
4891
- const result = await getRemoteDocument(url, response, load);
4908
+ const result = await getRemoteDocument(currentUrl, response, load);
4892
4909
  span.setAttribute("docloader.document_url", result.documentUrl);
4893
4910
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4894
4911
  return result;
@@ -1,6 +1,6 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- require('./docloader-D3nu2LmR.cjs');
3
- require('./request-DyrEDYQ-.cjs');
2
+ require('./docloader-RQoMNF99.cjs');
3
+ require('./request-kJU0S4zp.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-U31begIa.js";
2
- import "./request-BH_NlxCL.js";
1
+ import "./docloader-DIUeF-5W.js";
2
+ import "./request-D6fgLaP4.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-BH_NlxCL.js";
1
+ import { FetchError, createActivityPubRequest, deno_default, logRequest } from "./request-D6fgLaP4.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.
@@ -4491,31 +4492,33 @@ async function getRemoteDocument(url, response, fetch$1) {
4491
4492
  function getDocumentLoader({ allowPrivateAddress, 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
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4495
4496
  options?.signal?.throwIfAborted();
4496
- if (!skipPreloadedContexts && url in contexts_default) {
4497
- logger.debug("Using preloaded context: {url}.", { url });
4497
+ const currentUrl = new URL(url).href;
4498
+ if (!skipPreloadedContexts && currentUrl in contexts_default) {
4499
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4498
4500
  return {
4499
4501
  contextUrl: null,
4500
- document: contexts_default[url],
4501
- documentUrl: url
4502
+ document: contexts_default[currentUrl],
4503
+ documentUrl: currentUrl
4502
4504
  };
4503
4505
  }
4504
4506
  if (!allowPrivateAddress) try {
4505
- await validatePublicUrl(url);
4507
+ await validatePublicUrl(currentUrl);
4506
4508
  } catch (error) {
4507
4509
  if (error instanceof UrlError) logger.error("Disallowed private URL: {url}", {
4508
- url,
4510
+ url: currentUrl,
4509
4511
  error
4510
4512
  });
4511
4513
  throw error;
4512
4514
  }
4515
+ visited.add(currentUrl);
4513
4516
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4514
4517
  kind: SpanKind.CLIENT,
4515
- attributes: { "url.full": url }
4518
+ attributes: { "url.full": currentUrl }
4516
4519
  }, async (span) => {
4517
4520
  try {
4518
- const request = createActivityPubRequest(url, { userAgent });
4521
+ const request = createActivityPubRequest(currentUrl, { userAgent });
4519
4522
  logRequest(logger, request);
4520
4523
  const response = await fetch(request, {
4521
4524
  redirect: "manual",
@@ -4523,11 +4526,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4523
4526
  });
4524
4527
  span.setAttribute("http.response.status_code", response.status);
4525
4528
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4526
- const redirectUrl = response.headers.get("Location");
4529
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
4530
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4531
+ redirections: redirected + 1,
4532
+ url: currentUrl
4533
+ });
4534
+ throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4535
+ }
4536
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4527
4537
  span.setAttribute("http.redirect.url", redirectUrl);
4528
- return await load(redirectUrl, options);
4538
+ if (visited.has(redirectUrl)) {
4539
+ logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
4540
+ url: currentUrl,
4541
+ redirectUrl
4542
+ });
4543
+ throw new FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4544
+ }
4545
+ return await load(redirectUrl, options, redirected + 1, visited);
4529
4546
  }
4530
- const result = await getRemoteDocument(url, response, load);
4547
+ const result = await getRemoteDocument(currentUrl, response, load);
4531
4548
  span.setAttribute("docloader.document_url", result.documentUrl);
4532
4549
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4533
4550
  return result;
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- const require_request = require('./request-DyrEDYQ-.cjs');
2
+ const require_request = require('./request-kJU0S4zp.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.
@@ -4492,31 +4493,33 @@ async function getRemoteDocument(url, response, fetch$1) {
4492
4493
  function getDocumentLoader({ allowPrivateAddress, 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
+ async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4496
4497
  options?.signal?.throwIfAborted();
4497
- if (!skipPreloadedContexts && url in contexts_default) {
4498
- 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 });
4499
4501
  return {
4500
4502
  contextUrl: null,
4501
- document: contexts_default[url],
4502
- documentUrl: url
4503
+ document: contexts_default[currentUrl],
4504
+ documentUrl: currentUrl
4503
4505
  };
4504
4506
  }
4505
4507
  if (!allowPrivateAddress) try {
4506
- await require_url.validatePublicUrl(url);
4508
+ await require_url.validatePublicUrl(currentUrl);
4507
4509
  } catch (error) {
4508
4510
  if (error instanceof require_url.UrlError) logger.error("Disallowed private URL: {url}", {
4509
- url,
4511
+ url: currentUrl,
4510
4512
  error
4511
4513
  });
4512
4514
  throw error;
4513
4515
  }
4516
+ visited.add(currentUrl);
4514
4517
  return await tracer.startActiveSpan("activitypub.fetch_document", {
4515
4518
  kind: __opentelemetry_api.SpanKind.CLIENT,
4516
- attributes: { "url.full": url }
4519
+ attributes: { "url.full": currentUrl }
4517
4520
  }, async (span) => {
4518
4521
  try {
4519
- const request = require_request.createActivityPubRequest(url, { userAgent });
4522
+ const request = require_request.createActivityPubRequest(currentUrl, { userAgent });
4520
4523
  require_request.logRequest(logger, request);
4521
4524
  const response = await fetch(request, {
4522
4525
  redirect: "manual",
@@ -4524,11 +4527,25 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
4524
4527
  });
4525
4528
  span.setAttribute("http.response.status_code", response.status);
4526
4529
  if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
4527
- const redirectUrl = response.headers.get("Location");
4530
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
4531
+ logger.error("Too many redirections ({redirections}) while fetching document.", {
4532
+ redirections: redirected + 1,
4533
+ url: currentUrl
4534
+ });
4535
+ throw new require_request.FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
4536
+ }
4537
+ const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
4528
4538
  span.setAttribute("http.redirect.url", redirectUrl);
4529
- 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 require_request.FetchError(currentUrl, `Redirect loop detected: ${redirectUrl}`);
4545
+ }
4546
+ return await load(redirectUrl, options, redirected + 1, visited);
4530
4547
  }
4531
- const result = await getRemoteDocument(url, response, load);
4548
+ const result = await getRemoteDocument(currentUrl, response, load);
4532
4549
  span.setAttribute("docloader.document_url", result.documentUrl);
4533
4550
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
4534
4551
  return result;
@@ -1,6 +1,6 @@
1
1
  const require_chunk = require('./chunk-DWy1uDak.cjs');
2
- const require_docloader = require('./docloader-D3nu2LmR.cjs');
3
- const require_request = require('./request-DyrEDYQ-.cjs');
2
+ const require_docloader = require('./docloader-RQoMNF99.cjs');
3
+ const require_request = require('./request-kJU0S4zp.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,53 @@ 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
+ let loopAttempts = 0;
1530
+ esm_default.get("https://example.com/redirect-loop-a", () => {
1531
+ loopAttempts++;
1532
+ return {
1533
+ status: 302,
1534
+ headers: { Location: "https://example.com/redirect-loop-b" }
1535
+ };
1536
+ });
1537
+ esm_default.get("https://example.com/redirect-loop-b", () => {
1538
+ loopAttempts++;
1539
+ return {
1540
+ status: 302,
1541
+ headers: { Location: "https://example.com/redirect-loop-a" }
1542
+ };
1543
+ });
1544
+ await t.test("redirect loop", async () => {
1545
+ loopAttempts = 0;
1546
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redirect-loop-a"), require_request.FetchError, "Redirect loop detected");
1547
+ (0, node_assert.deepStrictEqual)(loopAttempts, 2);
1548
+ });
1549
+ let relativeLoopAttempts = 0;
1550
+ esm_default.get("https://example.com/redirect-loop-relative", () => {
1551
+ relativeLoopAttempts++;
1552
+ return {
1553
+ status: 302,
1554
+ headers: { Location: "/redirect-loop-relative" }
1555
+ };
1556
+ });
1557
+ await t.test("redirect loop with relative location", async () => {
1558
+ relativeLoopAttempts = 0;
1559
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redirect-loop-relative"), require_request.FetchError, "Redirect loop detected");
1560
+ (0, node_assert.deepStrictEqual)(relativeLoopAttempts, 1);
1561
+ });
1515
1562
  const maliciousPayload = "<a" + " a=\"b\"".repeat(30) + " ";
1516
1563
  esm_default.get("https://example.com/redos", {
1517
1564
  body: maliciousPayload,
@@ -1,5 +1,5 @@
1
- import { contexts_default, getDocumentLoader } from "./docloader-U31begIa.js";
2
- import { FetchError } from "./request-BH_NlxCL.js";
1
+ import { contexts_default, getDocumentLoader } from "./docloader-DIUeF-5W.js";
2
+ import { FetchError } from "./request-D6fgLaP4.js";
3
3
  import "./link-C3q2TC2G.js";
4
4
  import { UrlError } from "./url-CWEP9Zs9.js";
5
5
  import "node:module";
@@ -1538,6 +1538,53 @@ 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
+ let loopAttempts = 0;
1556
+ esm_default.get("https://example.com/redirect-loop-a", () => {
1557
+ loopAttempts++;
1558
+ return {
1559
+ status: 302,
1560
+ headers: { Location: "https://example.com/redirect-loop-b" }
1561
+ };
1562
+ });
1563
+ esm_default.get("https://example.com/redirect-loop-b", () => {
1564
+ loopAttempts++;
1565
+ return {
1566
+ status: 302,
1567
+ headers: { Location: "https://example.com/redirect-loop-a" }
1568
+ };
1569
+ });
1570
+ await t.test("redirect loop", async () => {
1571
+ loopAttempts = 0;
1572
+ await rejects(() => fetchDocumentLoader("https://example.com/redirect-loop-a"), FetchError, "Redirect loop detected");
1573
+ deepStrictEqual(loopAttempts, 2);
1574
+ });
1575
+ let relativeLoopAttempts = 0;
1576
+ esm_default.get("https://example.com/redirect-loop-relative", () => {
1577
+ relativeLoopAttempts++;
1578
+ return {
1579
+ status: 302,
1580
+ headers: { Location: "/redirect-loop-relative" }
1581
+ };
1582
+ });
1583
+ await t.test("redirect loop with relative location", async () => {
1584
+ relativeLoopAttempts = 0;
1585
+ await rejects(() => fetchDocumentLoader("https://example.com/redirect-loop-relative"), FetchError, "Redirect loop detected");
1586
+ deepStrictEqual(relativeLoopAttempts, 1);
1587
+ });
1541
1588
  const maliciousPayload = "<a" + " a=\"b\"".repeat(30) + " ";
1542
1589
  esm_default.get("https://example.com/redos", {
1543
1590
  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.1.0";
5
+ var version = "2.1.1";
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.1.0";
6
+ var version = "2.1.1";
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-DyrEDYQ-.cjs');
2
+ const require_request = require('./request-kJU0S4zp.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-BH_NlxCL.js";
1
+ import { deno_default, getUserAgent } from "./request-D6fgLaP4.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.1.0",
3
+ "version": "2.1.1",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -369,6 +369,73 @@ 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
+ let loopAttempts = 0;
395
+ fetchMock.get("https://example.com/redirect-loop-a", () => {
396
+ loopAttempts++;
397
+ return {
398
+ status: 302,
399
+ headers: { Location: "https://example.com/redirect-loop-b" },
400
+ };
401
+ });
402
+ fetchMock.get("https://example.com/redirect-loop-b", () => {
403
+ loopAttempts++;
404
+ return {
405
+ status: 302,
406
+ headers: { Location: "https://example.com/redirect-loop-a" },
407
+ };
408
+ });
409
+
410
+ await t.test("redirect loop", async () => {
411
+ loopAttempts = 0;
412
+ await rejects(
413
+ () => fetchDocumentLoader("https://example.com/redirect-loop-a"),
414
+ FetchError,
415
+ "Redirect loop detected",
416
+ );
417
+ deepStrictEqual(loopAttempts, 2);
418
+ });
419
+
420
+ let relativeLoopAttempts = 0;
421
+ fetchMock.get("https://example.com/redirect-loop-relative", () => {
422
+ relativeLoopAttempts++;
423
+ return {
424
+ status: 302,
425
+ headers: { Location: "/redirect-loop-relative" },
426
+ };
427
+ });
428
+
429
+ await t.test("redirect loop with relative location", async () => {
430
+ relativeLoopAttempts = 0;
431
+ await rejects(
432
+ () => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
433
+ FetchError,
434
+ "Redirect loop detected",
435
+ );
436
+ deepStrictEqual(relativeLoopAttempts, 1);
437
+ });
438
+
372
439
  // Regression test for ReDoS vulnerability (CVE-2025-68475)
373
440
  // Malicious HTML payload: <a a="b" a="b" ... (unclosed tag)
374
441
  // 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
@@ -293,38 +294,45 @@ export function getDocumentLoader(
293
294
  async function load(
294
295
  url: string,
295
296
  options?: DocumentLoaderOptions,
297
+ redirected = 0,
298
+ visited = new Set<string>(),
296
299
  ): Promise<RemoteDocument> {
297
300
  options?.signal?.throwIfAborted();
298
- if (!skipPreloadedContexts && url in preloadedContexts) {
299
- logger.debug("Using preloaded context: {url}.", { url });
301
+ const currentUrl = new URL(url).href;
302
+ if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
303
+ logger.debug("Using preloaded context: {url}.", { url: currentUrl });
300
304
  return {
301
305
  contextUrl: null,
302
- document: preloadedContexts[url],
303
- documentUrl: url,
306
+ document: preloadedContexts[currentUrl],
307
+ documentUrl: currentUrl,
304
308
  };
305
309
  }
306
310
  if (!allowPrivateAddress) {
307
311
  try {
308
- await validatePublicUrl(url);
312
+ await validatePublicUrl(currentUrl);
309
313
  } catch (error) {
310
314
  if (error instanceof UrlError) {
311
- logger.error("Disallowed private URL: {url}", { url, error });
315
+ logger.error("Disallowed private URL: {url}", {
316
+ url: currentUrl,
317
+ error,
318
+ });
312
319
  }
313
320
  throw error;
314
321
  }
315
322
  }
323
+ visited.add(currentUrl);
316
324
 
317
325
  return await tracer.startActiveSpan(
318
326
  "activitypub.fetch_document",
319
327
  {
320
328
  kind: SpanKind.CLIENT,
321
329
  attributes: {
322
- "url.full": url,
330
+ "url.full": currentUrl,
323
331
  },
324
332
  },
325
333
  async (span) => {
326
334
  try {
327
- const request = createActivityPubRequest(url, { userAgent });
335
+ const request = createActivityPubRequest(currentUrl, { userAgent });
328
336
  logRequest(logger, request);
329
337
  const response = await fetch(request, {
330
338
  // Since Bun has a bug that ignores the `Request.redirect` option,
@@ -340,12 +348,36 @@ export function getDocumentLoader(
340
348
  response.status >= 300 && response.status < 400 &&
341
349
  response.headers.has("Location")
342
350
  ) {
343
- const redirectUrl = response.headers.get("Location")!;
351
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
352
+ logger.error(
353
+ "Too many redirections ({redirections}) while fetching document.",
354
+ { redirections: redirected + 1, url: currentUrl },
355
+ );
356
+ throw new FetchError(
357
+ currentUrl,
358
+ `Too many redirections (${redirected + 1})`,
359
+ );
360
+ }
361
+ const redirectUrl = new URL(
362
+ response.headers.get("Location")!,
363
+ response.url === "" ? currentUrl : response.url,
364
+ ).href;
344
365
  span.setAttribute("http.redirect.url", redirectUrl);
345
- return await load(redirectUrl, options);
366
+ if (visited.has(redirectUrl)) {
367
+ logger.error(
368
+ "Detected a redirect loop while fetching document: {url} -> " +
369
+ "{redirectUrl}",
370
+ { url: currentUrl, redirectUrl },
371
+ );
372
+ throw new FetchError(
373
+ currentUrl,
374
+ `Redirect loop detected: ${redirectUrl}`,
375
+ );
376
+ }
377
+ return await load(redirectUrl, options, redirected + 1, visited);
346
378
  }
347
379
 
348
- const result = await getRemoteDocument(url, response, load);
380
+ const result = await getRemoteDocument(currentUrl, response, load);
349
381
  span.setAttribute("docloader.document_url", result.documentUrl);
350
382
  if (result.contextUrl != null) {
351
383
  span.setAttribute("docloader.context_url", result.contextUrl);