@fedify/vocab-runtime 2.4.0-dev.1528 → 2.4.0-dev.1564

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.4.0-dev.1528+fea670ad",
3
+ "version": "2.4.0-dev.1564+5a2c6c2c",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts",
package/dist/mod.cjs CHANGED
@@ -4287,6 +4287,28 @@ const preloadedContexts = {
4287
4287
  "@type": "@id"
4288
4288
  }
4289
4289
  } },
4290
+ "https://w3id.org/fep/7aa9": { "@context": {
4291
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
4292
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
4293
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
4294
+ "FeatureAuthorization": "https://w3id.org/fep/7aa9#FeatureAuthorization",
4295
+ "topic": {
4296
+ "@id": "https://w3id.org/fep/7aa9#topic",
4297
+ "@type": "@id"
4298
+ },
4299
+ "featuredObject": {
4300
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
4301
+ "@type": "@id"
4302
+ },
4303
+ "canFeature": {
4304
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
4305
+ "@type": "@id"
4306
+ },
4307
+ "featureAuthorization": {
4308
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
4309
+ "@type": "@id"
4310
+ }
4311
+ } },
4290
4312
  "https://join-lemmy.org/context.json": { "@context": ["https://w3id.org/security/v1", {
4291
4313
  "as": "https://www.w3.org/ns/activitystreams#",
4292
4314
  "lemmy": "https://join-lemmy.org/ns#",
@@ -4345,7 +4367,7 @@ const preloadedContexts = {
4345
4367
  //#endregion
4346
4368
  //#region deno.json
4347
4369
  var name = "@fedify/vocab-runtime";
4348
- var version = "2.4.0-dev.1528+fea670ad";
4370
+ var version = "2.4.0-dev.1564+5a2c6c2c";
4349
4371
  //#endregion
4350
4372
  //#region src/link.ts
4351
4373
  const parametersNeedLowerCase = ["rel", "type"];
@@ -4605,6 +4627,66 @@ const logger = (0, _logtape_logtape.getLogger)([
4605
4627
  "docloader"
4606
4628
  ]);
4607
4629
  const DEFAULT_MAX_REDIRECTION = 20;
4630
+ const MAX_HTML_SIZE = 1024 * 1024;
4631
+ function createResponseMetadata(response) {
4632
+ return new Response(null, {
4633
+ headers: response.headers,
4634
+ status: response.status,
4635
+ statusText: response.statusText
4636
+ });
4637
+ }
4638
+ async function cancelResponseBody(response) {
4639
+ if (response.body != null) await response.body.cancel();
4640
+ }
4641
+ async function readBoundedText(response, maxBytes) {
4642
+ const contentLength = response.headers.get("Content-Length");
4643
+ if (contentLength != null) {
4644
+ const size = Number(contentLength);
4645
+ if (size > maxBytes) {
4646
+ await cancelResponseBody(response);
4647
+ return {
4648
+ text: "",
4649
+ size,
4650
+ tooLarge: true
4651
+ };
4652
+ }
4653
+ }
4654
+ if (response.body == null) return {
4655
+ text: "",
4656
+ size: 0,
4657
+ tooLarge: false
4658
+ };
4659
+ const reader = response.body.getReader();
4660
+ const decoder = new TextDecoder();
4661
+ let text = "";
4662
+ let size = 0;
4663
+ try {
4664
+ while (true) {
4665
+ const result = await reader.read();
4666
+ if (result.done) break;
4667
+ const chunkSize = result.value.byteLength;
4668
+ if (size + chunkSize > maxBytes) {
4669
+ size += chunkSize;
4670
+ await reader.cancel();
4671
+ return {
4672
+ text: "",
4673
+ size,
4674
+ tooLarge: true
4675
+ };
4676
+ }
4677
+ size += chunkSize;
4678
+ text += decoder.decode(result.value, { stream: true });
4679
+ }
4680
+ text += decoder.decode();
4681
+ return {
4682
+ text,
4683
+ size,
4684
+ tooLarge: false
4685
+ };
4686
+ } finally {
4687
+ reader.releaseLock();
4688
+ }
4689
+ }
4608
4690
  /**
4609
4691
  * Gets a {@link RemoteDocument} from the given response.
4610
4692
  * @param url The URL of the document to load.
@@ -4659,19 +4741,19 @@ async function getRemoteDocument(url, response, fetch) {
4659
4741
  }
4660
4742
  let document;
4661
4743
  if (!jsonLd && (contentType === "text/html" || contentType?.startsWith("text/html;") || contentType === "application/xhtml+xml" || contentType?.startsWith("application/xhtml+xml;"))) {
4662
- const MAX_HTML_SIZE = 1024 * 1024;
4663
- const html = await response.text();
4664
- if (html.length > MAX_HTML_SIZE) {
4744
+ const errorResponse = createResponseMetadata(response);
4745
+ const html = await readBoundedText(response, MAX_HTML_SIZE);
4746
+ if (html.tooLarge) {
4665
4747
  logger.warn("HTML response too large, skipping alternate link discovery: {url}", {
4666
4748
  url: documentUrl,
4667
- size: html.length
4749
+ size: html.size
4668
4750
  });
4669
- document = JSON.parse(html);
4751
+ throw new FetchError(documentUrl, `HTML document is too large to scan for an ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4670
4752
  } else {
4671
4753
  const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
4672
4754
  const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
4673
4755
  let tagMatch;
4674
- while ((tagMatch = tagPattern.exec(html)) !== null) {
4756
+ while ((tagMatch = tagPattern.exec(html.text)) !== null) {
4675
4757
  const tagContent = tagMatch[2];
4676
4758
  let attrMatch;
4677
4759
  const attribs = {};
@@ -4688,7 +4770,12 @@ async function getRemoteDocument(url, response, fetch) {
4688
4770
  return await fetch(new URL(attribs.href, docUrl).href);
4689
4771
  }
4690
4772
  }
4691
- document = JSON.parse(html);
4773
+ try {
4774
+ document = JSON.parse(html.text);
4775
+ } catch (error) {
4776
+ if (!(error instanceof SyntaxError)) throw error;
4777
+ throw new FetchError(documentUrl, `HTML document has no ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4778
+ }
4692
4779
  }
4693
4780
  } else document = await response.json();
4694
4781
  logger.debug("Fetched document: {status} {url} {headers}", {
package/dist/mod.js CHANGED
@@ -4283,6 +4283,28 @@ const preloadedContexts = {
4283
4283
  "@type": "@id"
4284
4284
  }
4285
4285
  } },
4286
+ "https://w3id.org/fep/7aa9": { "@context": {
4287
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
4288
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
4289
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
4290
+ "FeatureAuthorization": "https://w3id.org/fep/7aa9#FeatureAuthorization",
4291
+ "topic": {
4292
+ "@id": "https://w3id.org/fep/7aa9#topic",
4293
+ "@type": "@id"
4294
+ },
4295
+ "featuredObject": {
4296
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
4297
+ "@type": "@id"
4298
+ },
4299
+ "canFeature": {
4300
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
4301
+ "@type": "@id"
4302
+ },
4303
+ "featureAuthorization": {
4304
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
4305
+ "@type": "@id"
4306
+ }
4307
+ } },
4286
4308
  "https://join-lemmy.org/context.json": { "@context": ["https://w3id.org/security/v1", {
4287
4309
  "as": "https://www.w3.org/ns/activitystreams#",
4288
4310
  "lemmy": "https://join-lemmy.org/ns#",
@@ -4341,7 +4363,7 @@ const preloadedContexts = {
4341
4363
  //#endregion
4342
4364
  //#region deno.json
4343
4365
  var name = "@fedify/vocab-runtime";
4344
- var version = "2.4.0-dev.1528+fea670ad";
4366
+ var version = "2.4.0-dev.1564+5a2c6c2c";
4345
4367
  //#endregion
4346
4368
  //#region src/link.ts
4347
4369
  const parametersNeedLowerCase = ["rel", "type"];
@@ -4601,6 +4623,66 @@ const logger = getLogger([
4601
4623
  "docloader"
4602
4624
  ]);
4603
4625
  const DEFAULT_MAX_REDIRECTION = 20;
4626
+ const MAX_HTML_SIZE = 1024 * 1024;
4627
+ function createResponseMetadata(response) {
4628
+ return new Response(null, {
4629
+ headers: response.headers,
4630
+ status: response.status,
4631
+ statusText: response.statusText
4632
+ });
4633
+ }
4634
+ async function cancelResponseBody(response) {
4635
+ if (response.body != null) await response.body.cancel();
4636
+ }
4637
+ async function readBoundedText(response, maxBytes) {
4638
+ const contentLength = response.headers.get("Content-Length");
4639
+ if (contentLength != null) {
4640
+ const size = Number(contentLength);
4641
+ if (size > maxBytes) {
4642
+ await cancelResponseBody(response);
4643
+ return {
4644
+ text: "",
4645
+ size,
4646
+ tooLarge: true
4647
+ };
4648
+ }
4649
+ }
4650
+ if (response.body == null) return {
4651
+ text: "",
4652
+ size: 0,
4653
+ tooLarge: false
4654
+ };
4655
+ const reader = response.body.getReader();
4656
+ const decoder = new TextDecoder();
4657
+ let text = "";
4658
+ let size = 0;
4659
+ try {
4660
+ while (true) {
4661
+ const result = await reader.read();
4662
+ if (result.done) break;
4663
+ const chunkSize = result.value.byteLength;
4664
+ if (size + chunkSize > maxBytes) {
4665
+ size += chunkSize;
4666
+ await reader.cancel();
4667
+ return {
4668
+ text: "",
4669
+ size,
4670
+ tooLarge: true
4671
+ };
4672
+ }
4673
+ size += chunkSize;
4674
+ text += decoder.decode(result.value, { stream: true });
4675
+ }
4676
+ text += decoder.decode();
4677
+ return {
4678
+ text,
4679
+ size,
4680
+ tooLarge: false
4681
+ };
4682
+ } finally {
4683
+ reader.releaseLock();
4684
+ }
4685
+ }
4604
4686
  /**
4605
4687
  * Gets a {@link RemoteDocument} from the given response.
4606
4688
  * @param url The URL of the document to load.
@@ -4655,19 +4737,19 @@ async function getRemoteDocument(url, response, fetch) {
4655
4737
  }
4656
4738
  let document;
4657
4739
  if (!jsonLd && (contentType === "text/html" || contentType?.startsWith("text/html;") || contentType === "application/xhtml+xml" || contentType?.startsWith("application/xhtml+xml;"))) {
4658
- const MAX_HTML_SIZE = 1024 * 1024;
4659
- const html = await response.text();
4660
- if (html.length > MAX_HTML_SIZE) {
4740
+ const errorResponse = createResponseMetadata(response);
4741
+ const html = await readBoundedText(response, MAX_HTML_SIZE);
4742
+ if (html.tooLarge) {
4661
4743
  logger.warn("HTML response too large, skipping alternate link discovery: {url}", {
4662
4744
  url: documentUrl,
4663
- size: html.length
4745
+ size: html.size
4664
4746
  });
4665
- document = JSON.parse(html);
4747
+ throw new FetchError(documentUrl, `HTML document is too large to scan for an ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4666
4748
  } else {
4667
4749
  const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
4668
4750
  const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
4669
4751
  let tagMatch;
4670
- while ((tagMatch = tagPattern.exec(html)) !== null) {
4752
+ while ((tagMatch = tagPattern.exec(html.text)) !== null) {
4671
4753
  const tagContent = tagMatch[2];
4672
4754
  let attrMatch;
4673
4755
  const attribs = {};
@@ -4684,7 +4766,12 @@ async function getRemoteDocument(url, response, fetch) {
4684
4766
  return await fetch(new URL(attribs.href, docUrl).href);
4685
4767
  }
4686
4768
  }
4687
- document = JSON.parse(html);
4769
+ try {
4770
+ document = JSON.parse(html.text);
4771
+ } catch (error) {
4772
+ if (!(error instanceof SyntaxError)) throw error;
4773
+ throw new FetchError(documentUrl, `HTML document has no ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4774
+ }
4688
4775
  }
4689
4776
  } else document = await response.json();
4690
4777
  logger.debug("Fetched document: {status} {url} {headers}", {
@@ -1,5 +1,5 @@
1
1
  require("./chunk-C2EiDwsr.cjs");
2
- require("./docloader-yxXccHZv.cjs");
2
+ require("./docloader-UAdXnDwt.cjs");
3
3
  require("./url-2XwVbUS_.cjs");
4
4
  require("./key-pMmqUKuo.cjs");
5
5
  require("./multibase-Bz_UUDtL.cjs");
@@ -1,4 +1,4 @@
1
- import "./docloader-9JRkIgYh.mjs";
1
+ import "./docloader-CYQvKbtL.mjs";
2
2
  import "./url-YWJbnRlf.mjs";
3
3
  import "./key-CrrK9mYh.mjs";
4
4
  import "./multibase-B4bvakyA.mjs";
@@ -1,4 +1,4 @@
1
- import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-4-bNhrbG.mjs";
1
+ import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-DgAlI7RF.mjs";
2
2
  import { t as HttpHeaderLink } from "./link-NUUWCdnK.mjs";
3
3
  import { l as validatePublicUrl, t as UrlError } from "./url-YWJbnRlf.mjs";
4
4
  import { getLogger } from "@logtape/logtape";
@@ -4277,6 +4277,28 @@ const preloadedContexts = {
4277
4277
  "@type": "@id"
4278
4278
  }
4279
4279
  } },
4280
+ "https://w3id.org/fep/7aa9": { "@context": {
4281
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
4282
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
4283
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
4284
+ "FeatureAuthorization": "https://w3id.org/fep/7aa9#FeatureAuthorization",
4285
+ "topic": {
4286
+ "@id": "https://w3id.org/fep/7aa9#topic",
4287
+ "@type": "@id"
4288
+ },
4289
+ "featuredObject": {
4290
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
4291
+ "@type": "@id"
4292
+ },
4293
+ "canFeature": {
4294
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
4295
+ "@type": "@id"
4296
+ },
4297
+ "featureAuthorization": {
4298
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
4299
+ "@type": "@id"
4300
+ }
4301
+ } },
4280
4302
  "https://join-lemmy.org/context.json": { "@context": ["https://w3id.org/security/v1", {
4281
4303
  "as": "https://www.w3.org/ns/activitystreams#",
4282
4304
  "lemmy": "https://join-lemmy.org/ns#",
@@ -4340,6 +4362,66 @@ const logger = getLogger([
4340
4362
  "docloader"
4341
4363
  ]);
4342
4364
  const DEFAULT_MAX_REDIRECTION = 20;
4365
+ const MAX_HTML_SIZE = 1024 * 1024;
4366
+ function createResponseMetadata(response) {
4367
+ return new Response(null, {
4368
+ headers: response.headers,
4369
+ status: response.status,
4370
+ statusText: response.statusText
4371
+ });
4372
+ }
4373
+ async function cancelResponseBody(response) {
4374
+ if (response.body != null) await response.body.cancel();
4375
+ }
4376
+ async function readBoundedText(response, maxBytes) {
4377
+ const contentLength = response.headers.get("Content-Length");
4378
+ if (contentLength != null) {
4379
+ const size = Number(contentLength);
4380
+ if (size > maxBytes) {
4381
+ await cancelResponseBody(response);
4382
+ return {
4383
+ text: "",
4384
+ size,
4385
+ tooLarge: true
4386
+ };
4387
+ }
4388
+ }
4389
+ if (response.body == null) return {
4390
+ text: "",
4391
+ size: 0,
4392
+ tooLarge: false
4393
+ };
4394
+ const reader = response.body.getReader();
4395
+ const decoder = new TextDecoder();
4396
+ let text = "";
4397
+ let size = 0;
4398
+ try {
4399
+ while (true) {
4400
+ const result = await reader.read();
4401
+ if (result.done) break;
4402
+ const chunkSize = result.value.byteLength;
4403
+ if (size + chunkSize > maxBytes) {
4404
+ size += chunkSize;
4405
+ await reader.cancel();
4406
+ return {
4407
+ text: "",
4408
+ size,
4409
+ tooLarge: true
4410
+ };
4411
+ }
4412
+ size += chunkSize;
4413
+ text += decoder.decode(result.value, { stream: true });
4414
+ }
4415
+ text += decoder.decode();
4416
+ return {
4417
+ text,
4418
+ size,
4419
+ tooLarge: false
4420
+ };
4421
+ } finally {
4422
+ reader.releaseLock();
4423
+ }
4424
+ }
4343
4425
  /**
4344
4426
  * Gets a {@link RemoteDocument} from the given response.
4345
4427
  * @param url The URL of the document to load.
@@ -4394,19 +4476,19 @@ async function getRemoteDocument(url, response, fetch) {
4394
4476
  }
4395
4477
  let document;
4396
4478
  if (!jsonLd && (contentType === "text/html" || contentType?.startsWith("text/html;") || contentType === "application/xhtml+xml" || contentType?.startsWith("application/xhtml+xml;"))) {
4397
- const MAX_HTML_SIZE = 1024 * 1024;
4398
- const html = await response.text();
4399
- if (html.length > MAX_HTML_SIZE) {
4479
+ const errorResponse = createResponseMetadata(response);
4480
+ const html = await readBoundedText(response, MAX_HTML_SIZE);
4481
+ if (html.tooLarge) {
4400
4482
  logger.warn("HTML response too large, skipping alternate link discovery: {url}", {
4401
4483
  url: documentUrl,
4402
- size: html.length
4484
+ size: html.size
4403
4485
  });
4404
- document = JSON.parse(html);
4486
+ throw new FetchError(documentUrl, `HTML document is too large to scan for an ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4405
4487
  } else {
4406
4488
  const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
4407
4489
  const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
4408
4490
  let tagMatch;
4409
- while ((tagMatch = tagPattern.exec(html)) !== null) {
4491
+ while ((tagMatch = tagPattern.exec(html.text)) !== null) {
4410
4492
  const tagContent = tagMatch[2];
4411
4493
  let attrMatch;
4412
4494
  const attribs = {};
@@ -4423,7 +4505,12 @@ async function getRemoteDocument(url, response, fetch) {
4423
4505
  return await fetch(new URL(attribs.href, docUrl).href);
4424
4506
  }
4425
4507
  }
4426
- document = JSON.parse(html);
4508
+ try {
4509
+ document = JSON.parse(html.text);
4510
+ } catch (error) {
4511
+ if (!(error instanceof SyntaxError)) throw error;
4512
+ throw new FetchError(documentUrl, `HTML document has no ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4513
+ }
4427
4514
  }
4428
4515
  } else document = await response.json();
4429
4516
  logger.debug("Fetched document: {status} {url} {headers}", {
@@ -4528,4 +4615,4 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
4528
4615
  return load;
4529
4616
  }
4530
4617
  //#endregion
4531
- export { preloadedContexts as n, getDocumentLoader as t };
4618
+ export { getRemoteDocument as n, preloadedContexts as r, getDocumentLoader as t };
@@ -1,5 +1,5 @@
1
1
  require("./chunk-C2EiDwsr.cjs");
2
- const require_request = require("./request-_LWX0dnv.cjs");
2
+ const require_request = require("./request-DnzhAfki.cjs");
3
3
  const require_link = require("./link-FguCydMA.cjs");
4
4
  const require_url = require("./url-2XwVbUS_.cjs");
5
5
  let _logtape_logtape = require("@logtape/logtape");
@@ -4278,6 +4278,28 @@ const preloadedContexts = {
4278
4278
  "@type": "@id"
4279
4279
  }
4280
4280
  } },
4281
+ "https://w3id.org/fep/7aa9": { "@context": {
4282
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
4283
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
4284
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
4285
+ "FeatureAuthorization": "https://w3id.org/fep/7aa9#FeatureAuthorization",
4286
+ "topic": {
4287
+ "@id": "https://w3id.org/fep/7aa9#topic",
4288
+ "@type": "@id"
4289
+ },
4290
+ "featuredObject": {
4291
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
4292
+ "@type": "@id"
4293
+ },
4294
+ "canFeature": {
4295
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
4296
+ "@type": "@id"
4297
+ },
4298
+ "featureAuthorization": {
4299
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
4300
+ "@type": "@id"
4301
+ }
4302
+ } },
4281
4303
  "https://join-lemmy.org/context.json": { "@context": ["https://w3id.org/security/v1", {
4282
4304
  "as": "https://www.w3.org/ns/activitystreams#",
4283
4305
  "lemmy": "https://join-lemmy.org/ns#",
@@ -4341,6 +4363,66 @@ const logger = (0, _logtape_logtape.getLogger)([
4341
4363
  "docloader"
4342
4364
  ]);
4343
4365
  const DEFAULT_MAX_REDIRECTION = 20;
4366
+ const MAX_HTML_SIZE = 1024 * 1024;
4367
+ function createResponseMetadata(response) {
4368
+ return new Response(null, {
4369
+ headers: response.headers,
4370
+ status: response.status,
4371
+ statusText: response.statusText
4372
+ });
4373
+ }
4374
+ async function cancelResponseBody(response) {
4375
+ if (response.body != null) await response.body.cancel();
4376
+ }
4377
+ async function readBoundedText(response, maxBytes) {
4378
+ const contentLength = response.headers.get("Content-Length");
4379
+ if (contentLength != null) {
4380
+ const size = Number(contentLength);
4381
+ if (size > maxBytes) {
4382
+ await cancelResponseBody(response);
4383
+ return {
4384
+ text: "",
4385
+ size,
4386
+ tooLarge: true
4387
+ };
4388
+ }
4389
+ }
4390
+ if (response.body == null) return {
4391
+ text: "",
4392
+ size: 0,
4393
+ tooLarge: false
4394
+ };
4395
+ const reader = response.body.getReader();
4396
+ const decoder = new TextDecoder();
4397
+ let text = "";
4398
+ let size = 0;
4399
+ try {
4400
+ while (true) {
4401
+ const result = await reader.read();
4402
+ if (result.done) break;
4403
+ const chunkSize = result.value.byteLength;
4404
+ if (size + chunkSize > maxBytes) {
4405
+ size += chunkSize;
4406
+ await reader.cancel();
4407
+ return {
4408
+ text: "",
4409
+ size,
4410
+ tooLarge: true
4411
+ };
4412
+ }
4413
+ size += chunkSize;
4414
+ text += decoder.decode(result.value, { stream: true });
4415
+ }
4416
+ text += decoder.decode();
4417
+ return {
4418
+ text,
4419
+ size,
4420
+ tooLarge: false
4421
+ };
4422
+ } finally {
4423
+ reader.releaseLock();
4424
+ }
4425
+ }
4344
4426
  /**
4345
4427
  * Gets a {@link RemoteDocument} from the given response.
4346
4428
  * @param url The URL of the document to load.
@@ -4395,19 +4477,19 @@ async function getRemoteDocument(url, response, fetch) {
4395
4477
  }
4396
4478
  let document;
4397
4479
  if (!jsonLd && (contentType === "text/html" || contentType?.startsWith("text/html;") || contentType === "application/xhtml+xml" || contentType?.startsWith("application/xhtml+xml;"))) {
4398
- const MAX_HTML_SIZE = 1024 * 1024;
4399
- const html = await response.text();
4400
- if (html.length > MAX_HTML_SIZE) {
4480
+ const errorResponse = createResponseMetadata(response);
4481
+ const html = await readBoundedText(response, MAX_HTML_SIZE);
4482
+ if (html.tooLarge) {
4401
4483
  logger.warn("HTML response too large, skipping alternate link discovery: {url}", {
4402
4484
  url: documentUrl,
4403
- size: html.length
4485
+ size: html.size
4404
4486
  });
4405
- document = JSON.parse(html);
4487
+ throw new require_request.FetchError(documentUrl, `HTML document is too large to scan for an ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4406
4488
  } else {
4407
4489
  const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
4408
4490
  const attrPattern = /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
4409
4491
  let tagMatch;
4410
- while ((tagMatch = tagPattern.exec(html)) !== null) {
4492
+ while ((tagMatch = tagPattern.exec(html.text)) !== null) {
4411
4493
  const tagContent = tagMatch[2];
4412
4494
  let attrMatch;
4413
4495
  const attribs = {};
@@ -4424,7 +4506,12 @@ async function getRemoteDocument(url, response, fetch) {
4424
4506
  return await fetch(new URL(attribs.href, docUrl).href);
4425
4507
  }
4426
4508
  }
4427
- document = JSON.parse(html);
4509
+ try {
4510
+ document = JSON.parse(html.text);
4511
+ } catch (error) {
4512
+ if (!(error instanceof SyntaxError)) throw error;
4513
+ throw new require_request.FetchError(documentUrl, `HTML document has no ActivityPub alternate link (Content-Type: ${contentType})`, errorResponse);
4514
+ }
4428
4515
  }
4429
4516
  } else document = await response.json();
4430
4517
  logger.debug("Fetched document: {status} {url} {headers}", {
@@ -4535,6 +4622,12 @@ Object.defineProperty(exports, "getDocumentLoader", {
4535
4622
  return getDocumentLoader;
4536
4623
  }
4537
4624
  });
4625
+ Object.defineProperty(exports, "getRemoteDocument", {
4626
+ enumerable: true,
4627
+ get: function() {
4628
+ return getRemoteDocument;
4629
+ }
4630
+ });
4538
4631
  Object.defineProperty(exports, "preloadedContexts", {
4539
4632
  enumerable: true,
4540
4633
  get: function() {
@@ -1,6 +1,6 @@
1
1
  const require_chunk = require("./chunk-C2EiDwsr.cjs");
2
- const require_docloader = require("./docloader-yxXccHZv.cjs");
3
- const require_request = require("./request-_LWX0dnv.cjs");
2
+ const require_docloader = require("./docloader-UAdXnDwt.cjs");
3
+ const require_request = require("./request-DnzhAfki.cjs");
4
4
  const require_url = require("./url-2XwVbUS_.cjs");
5
5
  let node_assert = require("node:assert");
6
6
  let node_test = require("node:test");
@@ -1267,7 +1267,7 @@ var esm_default = new class FetchMock {
1267
1267
  type: "Object"
1268
1268
  },
1269
1269
  headers: {
1270
- "Content-Type": "text/html; charset=utf-8",
1270
+ "Content-Type": "application/activity+json",
1271
1271
  Link: "<https://example.com/object>; rel=\"alternate\"; type=\"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"\""
1272
1272
  }
1273
1273
  });
@@ -1399,6 +1399,27 @@ var esm_default = new class FetchMock {
1399
1399
  }
1400
1400
  });
1401
1401
  });
1402
+ esm_default.get("https://example.com/html-no-alternate", {
1403
+ body: `<!DOCTYPE html>
1404
+ <html>
1405
+ <head>
1406
+ <title>Not an ActivityPub document</title>
1407
+ </head>
1408
+ <body>Not found</body>
1409
+ </html>`,
1410
+ headers: { "Content-Type": "text/html; charset=utf-8" }
1411
+ });
1412
+ await t.test("HTML without ActivityPub alternate link", async () => {
1413
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/html-no-alternate"), (error) => {
1414
+ (0, node_assert.ok)(error instanceof require_request.FetchError);
1415
+ (0, node_assert.ok)(error.message.includes("HTML document has no ActivityPub alternate link"));
1416
+ (0, node_assert.ok)(error.message.includes("Content-Type: text/html; charset=utf-8"));
1417
+ (0, node_assert.deepStrictEqual)(error.url, new URL("https://example.com/html-no-alternate"));
1418
+ (0, node_assert.ok)(error.response != null);
1419
+ (0, node_assert.deepStrictEqual)(error.response.headers.get("Content-Type"), "text/html; charset=utf-8");
1420
+ return true;
1421
+ });
1422
+ });
1402
1423
  esm_default.get("https://example.com/wrong-content-type", {
1403
1424
  body: {
1404
1425
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -1408,7 +1429,7 @@ var esm_default = new class FetchMock {
1408
1429
  },
1409
1430
  headers: { "Content-Type": "text/html; charset=utf-8" }
1410
1431
  });
1411
- await t.test("Wrong Content-Type", async () => {
1432
+ await t.test("wrong Content-Type with JSON body", async () => {
1412
1433
  (0, node_assert.deepStrictEqual)(await fetchDocumentLoader("https://example.com/wrong-content-type"), {
1413
1434
  contextUrl: null,
1414
1435
  documentUrl: "https://example.com/wrong-content-type",
@@ -1420,6 +1441,37 @@ var esm_default = new class FetchMock {
1420
1441
  }
1421
1442
  });
1422
1443
  });
1444
+ esm_default.get("https://example.com/large-html", {
1445
+ body: "<!DOCTYPE html>",
1446
+ headers: {
1447
+ "Content-Length": String(1048577),
1448
+ "Content-Type": "text/html; charset=utf-8"
1449
+ }
1450
+ });
1451
+ await t.test("HTML Content-Length over limit", async () => {
1452
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/large-html"), (error) => {
1453
+ (0, node_assert.ok)(error instanceof require_request.FetchError);
1454
+ (0, node_assert.ok)(error.message.includes("HTML document is too large to scan for an ActivityPub alternate link"));
1455
+ (0, node_assert.ok)(error.response != null);
1456
+ (0, node_assert.deepStrictEqual)(error.response.status, 200);
1457
+ (0, node_assert.deepStrictEqual)(error.response.headers.get("Content-Type"), "text/html; charset=utf-8");
1458
+ return true;
1459
+ });
1460
+ });
1461
+ await t.test("HTML Content-Length over limit cancels body", async () => {
1462
+ let canceled = false;
1463
+ const response = new Response("<!DOCTYPE html>", { headers: {
1464
+ "Content-Length": String(1048577),
1465
+ "Content-Type": "text/html; charset=utf-8"
1466
+ } });
1467
+ Object.defineProperty(response, "body", { value: { cancel: () => {
1468
+ canceled = true;
1469
+ } } });
1470
+ await (0, node_assert.rejects)(() => require_docloader.getRemoteDocument("https://example.com/large-html-cancel", response, () => {
1471
+ throw new Error("unexpected alternate fetch");
1472
+ }), require_request.FetchError);
1473
+ (0, node_assert.deepStrictEqual)(canceled, true);
1474
+ });
1423
1475
  esm_default.get("https://example.com/404", { status: 404 });
1424
1476
  await t.test("not ok", async () => {
1425
1477
  await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/404"), require_request.FetchError, "HTTP 404: https://example.com/404");
@@ -1536,7 +1588,7 @@ var esm_default = new class FetchMock {
1536
1588
  });
1537
1589
  await t.test("ReDoS resistance (CVE-2025-68475)", async () => {
1538
1590
  const start = performance.now();
1539
- await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redos"), SyntaxError);
1591
+ await (0, node_assert.rejects)(() => fetchDocumentLoader("https://example.com/redos"), require_request.FetchError);
1540
1592
  const elapsed = performance.now() - start;
1541
1593
  (0, node_assert.ok)(elapsed < 1e3, `Potential ReDoS vulnerability detected: ${elapsed}ms (expected < 1000ms)`);
1542
1594
  });
@@ -1,5 +1,5 @@
1
- import { n as preloadedContexts, t as getDocumentLoader } from "./docloader-9JRkIgYh.mjs";
2
- import { t as FetchError } from "./request-4-bNhrbG.mjs";
1
+ import { n as getRemoteDocument, r as preloadedContexts, t as getDocumentLoader } from "./docloader-CYQvKbtL.mjs";
2
+ import { t as FetchError } from "./request-DgAlI7RF.mjs";
3
3
  import { t as UrlError } from "./url-YWJbnRlf.mjs";
4
4
  import { deepStrictEqual, ok, rejects } from "node:assert";
5
5
  import { test } from "node:test";
@@ -1286,7 +1286,7 @@ test("getDocumentLoader()", async (t) => {
1286
1286
  type: "Object"
1287
1287
  },
1288
1288
  headers: {
1289
- "Content-Type": "text/html; charset=utf-8",
1289
+ "Content-Type": "application/activity+json",
1290
1290
  Link: "<https://example.com/object>; rel=\"alternate\"; type=\"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"\""
1291
1291
  }
1292
1292
  });
@@ -1418,6 +1418,27 @@ test("getDocumentLoader()", async (t) => {
1418
1418
  }
1419
1419
  });
1420
1420
  });
1421
+ esm_default.get("https://example.com/html-no-alternate", {
1422
+ body: `<!DOCTYPE html>
1423
+ <html>
1424
+ <head>
1425
+ <title>Not an ActivityPub document</title>
1426
+ </head>
1427
+ <body>Not found</body>
1428
+ </html>`,
1429
+ headers: { "Content-Type": "text/html; charset=utf-8" }
1430
+ });
1431
+ await t.test("HTML without ActivityPub alternate link", async () => {
1432
+ await rejects(() => fetchDocumentLoader("https://example.com/html-no-alternate"), (error) => {
1433
+ ok(error instanceof FetchError);
1434
+ ok(error.message.includes("HTML document has no ActivityPub alternate link"));
1435
+ ok(error.message.includes("Content-Type: text/html; charset=utf-8"));
1436
+ deepStrictEqual(error.url, new URL("https://example.com/html-no-alternate"));
1437
+ ok(error.response != null);
1438
+ deepStrictEqual(error.response.headers.get("Content-Type"), "text/html; charset=utf-8");
1439
+ return true;
1440
+ });
1441
+ });
1421
1442
  esm_default.get("https://example.com/wrong-content-type", {
1422
1443
  body: {
1423
1444
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -1427,7 +1448,7 @@ test("getDocumentLoader()", async (t) => {
1427
1448
  },
1428
1449
  headers: { "Content-Type": "text/html; charset=utf-8" }
1429
1450
  });
1430
- await t.test("Wrong Content-Type", async () => {
1451
+ await t.test("wrong Content-Type with JSON body", async () => {
1431
1452
  deepStrictEqual(await fetchDocumentLoader("https://example.com/wrong-content-type"), {
1432
1453
  contextUrl: null,
1433
1454
  documentUrl: "https://example.com/wrong-content-type",
@@ -1439,6 +1460,37 @@ test("getDocumentLoader()", async (t) => {
1439
1460
  }
1440
1461
  });
1441
1462
  });
1463
+ esm_default.get("https://example.com/large-html", {
1464
+ body: "<!DOCTYPE html>",
1465
+ headers: {
1466
+ "Content-Length": String(1048577),
1467
+ "Content-Type": "text/html; charset=utf-8"
1468
+ }
1469
+ });
1470
+ await t.test("HTML Content-Length over limit", async () => {
1471
+ await rejects(() => fetchDocumentLoader("https://example.com/large-html"), (error) => {
1472
+ ok(error instanceof FetchError);
1473
+ ok(error.message.includes("HTML document is too large to scan for an ActivityPub alternate link"));
1474
+ ok(error.response != null);
1475
+ deepStrictEqual(error.response.status, 200);
1476
+ deepStrictEqual(error.response.headers.get("Content-Type"), "text/html; charset=utf-8");
1477
+ return true;
1478
+ });
1479
+ });
1480
+ await t.test("HTML Content-Length over limit cancels body", async () => {
1481
+ let canceled = false;
1482
+ const response = new Response("<!DOCTYPE html>", { headers: {
1483
+ "Content-Length": String(1048577),
1484
+ "Content-Type": "text/html; charset=utf-8"
1485
+ } });
1486
+ Object.defineProperty(response, "body", { value: { cancel: () => {
1487
+ canceled = true;
1488
+ } } });
1489
+ await rejects(() => getRemoteDocument("https://example.com/large-html-cancel", response, () => {
1490
+ throw new Error("unexpected alternate fetch");
1491
+ }), FetchError);
1492
+ deepStrictEqual(canceled, true);
1493
+ });
1442
1494
  esm_default.get("https://example.com/404", { status: 404 });
1443
1495
  await t.test("not ok", async () => {
1444
1496
  await rejects(() => fetchDocumentLoader("https://example.com/404"), FetchError, "HTTP 404: https://example.com/404");
@@ -1555,7 +1607,7 @@ test("getDocumentLoader()", async (t) => {
1555
1607
  });
1556
1608
  await t.test("ReDoS resistance (CVE-2025-68475)", async () => {
1557
1609
  const start = performance.now();
1558
- await rejects(() => fetchDocumentLoader("https://example.com/redos"), SyntaxError);
1610
+ await rejects(() => fetchDocumentLoader("https://example.com/redos"), FetchError);
1559
1611
  const elapsed = performance.now() - start;
1560
1612
  ok(elapsed < 1e3, `Potential ReDoS vulnerability detected: ${elapsed}ms (expected < 1000ms)`);
1561
1613
  });
@@ -1,7 +1,7 @@
1
1
  import process from "node:process";
2
2
  //#region deno.json
3
3
  var name = "@fedify/vocab-runtime";
4
- var version = "2.4.0-dev.1528+fea670ad";
4
+ var version = "2.4.0-dev.1564+5a2c6c2c";
5
5
  //#endregion
6
6
  //#region src/request.ts
7
7
  /**
@@ -3,7 +3,7 @@ let node_process = require("node:process");
3
3
  node_process = require_chunk.__toESM(node_process, 1);
4
4
  //#region deno.json
5
5
  var name = "@fedify/vocab-runtime";
6
- var version = "2.4.0-dev.1528+fea670ad";
6
+ var version = "2.4.0-dev.1564+5a2c6c2c";
7
7
  //#endregion
8
8
  //#region src/request.ts
9
9
  /**
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-C2EiDwsr.cjs");
2
- const require_request = require("./request-_LWX0dnv.cjs");
2
+ const require_request = require("./request-DnzhAfki.cjs");
3
3
  let node_assert = require("node:assert");
4
4
  let node_test = require("node:test");
5
5
  let node_process = require("node:process");
@@ -1,4 +1,4 @@
1
- import { o as version, r as getUserAgent } from "./request-4-bNhrbG.mjs";
1
+ import { o as version, r as getUserAgent } from "./request-DgAlI7RF.mjs";
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.4.0-dev.1528+fea670ad",
3
+ "version": "2.4.0-dev.1564+5a2c6c2c",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,24 @@
1
+ {
2
+ "@context": {
3
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
4
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
5
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
6
+ "FeatureAuthorization": "https://w3id.org/fep/7aa9#FeatureAuthorization",
7
+ "topic": {
8
+ "@id": "https://w3id.org/fep/7aa9#topic",
9
+ "@type": "@id"
10
+ },
11
+ "featuredObject": {
12
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
13
+ "@type": "@id"
14
+ },
15
+ "canFeature": {
16
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
17
+ "@type": "@id"
18
+ },
19
+ "featureAuthorization": {
20
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
21
+ "@type": "@id"
22
+ }
23
+ }
24
+ }
package/src/contexts.ts CHANGED
@@ -7,6 +7,7 @@ import activitystreams from "./contexts/activitystreams.json" with {
7
7
  };
8
8
  import didV1 from "./contexts/did-v1.json" with { type: "json" };
9
9
  import fep5711 from "./contexts/fep-5711.json" with { type: "json" };
10
+ import fep7aa9 from "./contexts/fep-7aa9.json" with { type: "json" };
10
11
  import gotosocial from "./contexts/gotosocial.json" with { type: "json" };
11
12
  import identityV1 from "./contexts/identity-v1.json" with { type: "json" };
12
13
  import joinLemmyContext from "./contexts/join-lemmy.json" with { type: "json" };
@@ -35,6 +36,7 @@ const preloadedContexts: Record<string, unknown> = {
35
36
  "http://schema.org/": schemaorg,
36
37
  "https://gotosocial.org/ns": gotosocial,
37
38
  "https://w3id.org/fep/5711": fep5711,
39
+ "https://w3id.org/fep/7aa9": fep7aa9,
38
40
 
39
41
  // Lemmy's context document is served as application/json without the JSON-LD
40
42
  // context Link header. The default document loader treats that as a regular
@@ -2,7 +2,7 @@ import fetchMock from "fetch-mock";
2
2
  import { deepStrictEqual, ok, rejects } from "node:assert";
3
3
  import { test } from "node:test";
4
4
  import preloadedContexts from "./contexts.ts";
5
- import { getDocumentLoader } from "./docloader.ts";
5
+ import { getDocumentLoader, getRemoteDocument } from "./docloader.ts";
6
6
  import { FetchError } from "./request.ts";
7
7
  import { UrlError } from "./url.ts";
8
8
 
@@ -90,7 +90,7 @@ test("getDocumentLoader()", async (t) => {
90
90
  type: "Object",
91
91
  },
92
92
  headers: {
93
- "Content-Type": "text/html; charset=utf-8",
93
+ "Content-Type": "application/activity+json",
94
94
  Link: '<https://example.com/object>; rel="alternate"; ' +
95
95
  'type="application/ld+json; profile="https://www.w3.org/ns/activitystreams""',
96
96
  },
@@ -247,6 +247,44 @@ test("getDocumentLoader()", async (t) => {
247
247
  });
248
248
  });
249
249
 
250
+ fetchMock.get("https://example.com/html-no-alternate", {
251
+ body: `<!DOCTYPE html>
252
+ <html>
253
+ <head>
254
+ <title>Not an ActivityPub document</title>
255
+ </head>
256
+ <body>Not found</body>
257
+ </html>`,
258
+ headers: { "Content-Type": "text/html; charset=utf-8" },
259
+ });
260
+
261
+ await t.test("HTML without ActivityPub alternate link", async () => {
262
+ await rejects(
263
+ () => fetchDocumentLoader("https://example.com/html-no-alternate"),
264
+ (error) => {
265
+ ok(error instanceof FetchError);
266
+ ok(
267
+ error.message.includes(
268
+ "HTML document has no ActivityPub alternate link",
269
+ ),
270
+ );
271
+ ok(
272
+ error.message.includes("Content-Type: text/html; charset=utf-8"),
273
+ );
274
+ deepStrictEqual(
275
+ error.url,
276
+ new URL("https://example.com/html-no-alternate"),
277
+ );
278
+ ok(error.response != null);
279
+ deepStrictEqual(
280
+ error.response.headers.get("Content-Type"),
281
+ "text/html; charset=utf-8",
282
+ );
283
+ return true;
284
+ },
285
+ );
286
+ });
287
+
250
288
  fetchMock.get("https://example.com/wrong-content-type", {
251
289
  body: {
252
290
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -257,7 +295,7 @@ test("getDocumentLoader()", async (t) => {
257
295
  headers: { "Content-Type": "text/html; charset=utf-8" },
258
296
  });
259
297
 
260
- await t.test("Wrong Content-Type", async () => {
298
+ await t.test("wrong Content-Type with JSON body", async () => {
261
299
  deepStrictEqual(
262
300
  await fetchDocumentLoader("https://example.com/wrong-content-type"),
263
301
  {
@@ -273,6 +311,64 @@ test("getDocumentLoader()", async (t) => {
273
311
  );
274
312
  });
275
313
 
314
+ fetchMock.get("https://example.com/large-html", {
315
+ body: "<!DOCTYPE html>",
316
+ headers: {
317
+ "Content-Length": String(1024 * 1024 + 1),
318
+ "Content-Type": "text/html; charset=utf-8",
319
+ },
320
+ });
321
+
322
+ await t.test("HTML Content-Length over limit", async () => {
323
+ await rejects(
324
+ () => fetchDocumentLoader("https://example.com/large-html"),
325
+ (error) => {
326
+ ok(error instanceof FetchError);
327
+ ok(
328
+ error.message.includes(
329
+ "HTML document is too large to scan for an ActivityPub alternate link",
330
+ ),
331
+ );
332
+ ok(error.response != null);
333
+ deepStrictEqual(error.response.status, 200);
334
+ deepStrictEqual(
335
+ error.response.headers.get("Content-Type"),
336
+ "text/html; charset=utf-8",
337
+ );
338
+ return true;
339
+ },
340
+ );
341
+ });
342
+
343
+ await t.test("HTML Content-Length over limit cancels body", async () => {
344
+ let canceled = false;
345
+ const response = new Response("<!DOCTYPE html>", {
346
+ headers: {
347
+ "Content-Length": String(1024 * 1024 + 1),
348
+ "Content-Type": "text/html; charset=utf-8",
349
+ },
350
+ });
351
+ Object.defineProperty(response, "body", {
352
+ value: {
353
+ cancel: () => {
354
+ canceled = true;
355
+ },
356
+ },
357
+ });
358
+ await rejects(
359
+ () =>
360
+ getRemoteDocument(
361
+ "https://example.com/large-html-cancel",
362
+ response,
363
+ () => {
364
+ throw new Error("unexpected alternate fetch");
365
+ },
366
+ ),
367
+ FetchError,
368
+ );
369
+ deepStrictEqual(canceled, true);
370
+ });
371
+
276
372
  fetchMock.get("https://example.com/404", { status: 404 });
277
373
 
278
374
  await t.test("not ok", async () => {
@@ -459,11 +555,11 @@ test("getDocumentLoader()", async (t) => {
459
555
 
460
556
  await t.test("ReDoS resistance (CVE-2025-68475)", async () => {
461
557
  const start = performance.now();
462
- // The malicious HTML will fail JSON parsing, but the important thing is
463
- // that it should complete quickly (not hang due to ReDoS)
558
+ // The malicious HTML will fail alternate discovery, but the important
559
+ // thing is that it should complete quickly (not hang due to ReDoS).
464
560
  await rejects(
465
561
  () => fetchDocumentLoader("https://example.com/redos"),
466
- SyntaxError,
562
+ FetchError,
467
563
  );
468
564
  const elapsed = performance.now() - start;
469
565
 
package/src/docloader.ts CHANGED
@@ -13,6 +13,7 @@ import { UrlError, validatePublicUrl } from "./url.ts";
13
13
 
14
14
  const logger = getLogger(["fedify", "runtime", "docloader"]);
15
15
  const DEFAULT_MAX_REDIRECTION = 20;
16
+ const MAX_HTML_SIZE = 1024 * 1024; // 1MB
16
17
 
17
18
  /**
18
19
  * A remote JSON-LD document and its context fetched by
@@ -112,6 +113,59 @@ export type AuthenticatedDocumentLoaderFactory = (
112
113
  options?: DocumentLoaderFactoryOptions,
113
114
  ) => DocumentLoader;
114
115
 
116
+ function createResponseMetadata(response: Response): Response {
117
+ return new Response(null, {
118
+ headers: response.headers,
119
+ status: response.status,
120
+ statusText: response.statusText,
121
+ });
122
+ }
123
+
124
+ async function cancelResponseBody(response: Response): Promise<void> {
125
+ if (response.body != null) {
126
+ await response.body.cancel();
127
+ }
128
+ }
129
+
130
+ async function readBoundedText(
131
+ response: Response,
132
+ maxBytes: number,
133
+ ): Promise<{ text: string; size: number; tooLarge: boolean }> {
134
+ const contentLength = response.headers.get("Content-Length");
135
+ if (contentLength != null) {
136
+ const size = Number(contentLength);
137
+ if (size > maxBytes) {
138
+ await cancelResponseBody(response);
139
+ return { text: "", size, tooLarge: true };
140
+ }
141
+ }
142
+
143
+ if (response.body == null) return { text: "", size: 0, tooLarge: false };
144
+
145
+ const reader = response.body.getReader();
146
+ const decoder = new TextDecoder();
147
+ let text = "";
148
+ let size = 0;
149
+ try {
150
+ while (true) {
151
+ const result = await reader.read();
152
+ if (result.done) break;
153
+ const chunkSize = result.value.byteLength;
154
+ if (size + chunkSize > maxBytes) {
155
+ size += chunkSize;
156
+ await reader.cancel();
157
+ return { text: "", size, tooLarge: true };
158
+ }
159
+ size += chunkSize;
160
+ text += decoder.decode(result.value, { stream: true });
161
+ }
162
+ text += decoder.decode();
163
+ return { text, size, tooLarge: false };
164
+ } finally {
165
+ reader.releaseLock();
166
+ }
167
+ }
168
+
115
169
  /**
116
170
  * Gets a {@link RemoteDocument} from the given response.
117
171
  * @param url The URL of the document to load.
@@ -200,15 +254,19 @@ export async function getRemoteDocument(
200
254
  contentType === "application/xhtml+xml" ||
201
255
  contentType?.startsWith("application/xhtml+xml;"))
202
256
  ) {
203
- // Security: Limit HTML response size to mitigate ReDoS attacks
204
- const MAX_HTML_SIZE = 1024 * 1024; // 1MB
205
- const html = await response.text();
206
- if (html.length > MAX_HTML_SIZE) {
257
+ const errorResponse = createResponseMetadata(response);
258
+ const html = await readBoundedText(response, MAX_HTML_SIZE);
259
+ if (html.tooLarge) {
207
260
  logger.warn(
208
261
  "HTML response too large, skipping alternate link discovery: {url}",
209
- { url: documentUrl, size: html.length },
262
+ { url: documentUrl, size: html.size },
263
+ );
264
+ throw new FetchError(
265
+ documentUrl,
266
+ `HTML document is too large to scan for an ActivityPub alternate link ` +
267
+ `(Content-Type: ${contentType})`,
268
+ errorResponse,
210
269
  );
211
- document = JSON.parse(html);
212
270
  } else {
213
271
  // Safe regex patterns without nested quantifiers to prevent ReDoS
214
272
  // (CVE-2025-68475)
@@ -219,7 +277,7 @@ export async function getRemoteDocument(
219
277
  /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
220
278
 
221
279
  let tagMatch: RegExpExecArray | null;
222
- while ((tagMatch = tagPattern.exec(html)) !== null) {
280
+ while ((tagMatch = tagPattern.exec(html.text)) !== null) {
223
281
  const tagContent = tagMatch[2];
224
282
  let attrMatch: RegExpExecArray | null;
225
283
  const attribs: Record<string, string> = {};
@@ -247,7 +305,17 @@ export async function getRemoteDocument(
247
305
  return await fetch(new URL(attribs.href, docUrl).href);
248
306
  }
249
307
  }
250
- document = JSON.parse(html);
308
+ try {
309
+ document = JSON.parse(html.text);
310
+ } catch (error) {
311
+ if (!(error instanceof SyntaxError)) throw error;
312
+ throw new FetchError(
313
+ documentUrl,
314
+ `HTML document has no ActivityPub alternate link ` +
315
+ `(Content-Type: ${contentType})`,
316
+ errorResponse,
317
+ );
318
+ }
251
319
  }
252
320
  } else {
253
321
  document = await response.json();