@fedify/vocab-runtime 2.2.18-dev.2386 → 2.2.19

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.18-dev.2386+41abb5f5",
3
+ "version": "2.2.19",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts",
package/dist/mod.cjs CHANGED
@@ -4593,7 +4593,7 @@ const preloadedContexts = {
4593
4593
  //#endregion
4594
4594
  //#region deno.json
4595
4595
  var name = "@fedify/vocab-runtime";
4596
- var version = "2.2.18-dev.2386+41abb5f5";
4596
+ var version = "2.2.19";
4597
4597
  //#endregion
4598
4598
  //#region src/request.ts
4599
4599
  /**
@@ -5254,7 +5254,10 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
5254
5254
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
5255
5255
  async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
5256
5256
  options?.signal?.throwIfAborted();
5257
- const currentUrl = new URL(url).href;
5257
+ const parsedUrl = new URL(url);
5258
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
5259
+ if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new UrlError("URLs with credentials are not allowed");
5260
+ const currentUrl = parsedUrl.href;
5258
5261
  if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
5259
5262
  logger.debug("Using preloaded context: {url}.", { url: currentUrl });
5260
5263
  return {
package/dist/mod.js CHANGED
@@ -4588,7 +4588,7 @@ const preloadedContexts = {
4588
4588
  //#endregion
4589
4589
  //#region deno.json
4590
4590
  var name = "@fedify/vocab-runtime";
4591
- var version = "2.2.18-dev.2386+41abb5f5";
4591
+ var version = "2.2.19";
4592
4592
  //#endregion
4593
4593
  //#region src/request.ts
4594
4594
  /**
@@ -5249,7 +5249,10 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
5249
5249
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
5250
5250
  async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
5251
5251
  options?.signal?.throwIfAborted();
5252
- const currentUrl = new URL(url).href;
5252
+ const parsedUrl = new URL(url);
5253
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
5254
+ if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new UrlError("URLs with credentials are not allowed");
5255
+ const currentUrl = parsedUrl.href;
5253
5256
  if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
5254
5257
  logger.debug("Using preloaded context: {url}.", { url: currentUrl });
5255
5258
  return {
@@ -1,4 +1,4 @@
1
- import { t as FetchError } from "./request-D5w4xI1V.mjs";
1
+ import { t as FetchError } from "./request-yCis9VCB.mjs";
2
2
  import { getLogger } from "@logtape/logtape";
3
3
  //#region src/body.ts
4
4
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1,5 +1,5 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-CNVibwBp.cjs");
2
+ const require_request = require("./request-DGtx5MkG.cjs");
3
3
  let _logtape_logtape = require("@logtape/logtape");
4
4
  //#region src/body.ts
5
5
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1,5 +1,5 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- const require_body = require("./body-DgJUdoFW.cjs");
2
+ const require_body = require("./body-DhbRZ6Ps.cjs");
3
3
  let node_assert_strict = require("node:assert/strict");
4
4
  let node_test = require("node:test");
5
5
  let node_zlib = require("node:zlib");
@@ -1,4 +1,4 @@
1
- import { r as readBoundedText, t as BodyTooLargeError } from "./body-BblGrtLX.mjs";
1
+ import { r as readBoundedText, t as BodyTooLargeError } from "./body-BOnyCa1y.mjs";
2
2
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
3
3
  import { test } from "node:test";
4
4
  import { gunzipSync, gzipSync } from "node:zlib";
@@ -1,5 +1,5 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- require("./docloader-BpTNd9pG.cjs");
2
+ require("./docloader-BhSiTajy.cjs");
3
3
  require("./url-vZTgkIZg.cjs");
4
4
  require("./key-BarKGLQz.cjs");
5
5
  require("./multibase-8zhYPa7o.cjs");
@@ -1,4 +1,4 @@
1
- import "./docloader-D-e_R3Qj.mjs";
1
+ import "./docloader-BjyFsnAA.mjs";
2
2
  import "./url-3fyJuqC0.mjs";
3
3
  import "./key-BDIsC6hV.mjs";
4
4
  import "./multibase-Ds75BvXs.mjs";
@@ -1,6 +1,6 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-CNVibwBp.cjs");
3
- const require_body = require("./body-DgJUdoFW.cjs");
2
+ const require_request = require("./request-DGtx5MkG.cjs");
3
+ const require_body = require("./body-DhbRZ6Ps.cjs");
4
4
  const require_link = require("./link-qDTmjPw_.cjs");
5
5
  const require_url = require("./url-vZTgkIZg.cjs");
6
6
  let _logtape_logtape = require("@logtape/logtape");
@@ -4705,7 +4705,10 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
4705
4705
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
4706
4706
  async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4707
4707
  options?.signal?.throwIfAborted();
4708
- const currentUrl = new URL(url).href;
4708
+ const parsedUrl = new URL(url);
4709
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new require_url.UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
4710
+ if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new require_url.UrlError("URLs with credentials are not allowed");
4711
+ const currentUrl = parsedUrl.href;
4709
4712
  if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
4710
4713
  logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4711
4714
  return {
@@ -1,5 +1,5 @@
1
- import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-D5w4xI1V.mjs";
2
- import { n as MAX_BODY_SIZE, r as readBoundedText } from "./body-BblGrtLX.mjs";
1
+ import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-yCis9VCB.mjs";
2
+ import { n as MAX_BODY_SIZE, r as readBoundedText } from "./body-BOnyCa1y.mjs";
3
3
  import { t as HttpHeaderLink } from "./link-CYszIegt.mjs";
4
4
  import { o as validatePublicUrl, t as UrlError } from "./url-3fyJuqC0.mjs";
5
5
  import { getLogger } from "@logtape/logtape";
@@ -4704,7 +4704,10 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
4704
4704
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
4705
4705
  async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
4706
4706
  options?.signal?.throwIfAborted();
4707
- const currentUrl = new URL(url).href;
4707
+ const parsedUrl = new URL(url);
4708
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
4709
+ if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new UrlError("URLs with credentials are not allowed");
4710
+ const currentUrl = parsedUrl.href;
4708
4711
  if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
4709
4712
  logger.debug("Using preloaded context: {url}.", { url: currentUrl });
4710
4713
  return {
@@ -1,6 +1,6 @@
1
1
  const require_chunk = require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-CNVibwBp.cjs");
3
- const require_docloader = require("./docloader-BpTNd9pG.cjs");
2
+ const require_request = require("./request-DGtx5MkG.cjs");
3
+ const require_docloader = require("./docloader-BhSiTajy.cjs");
4
4
  const require_url = require("./url-vZTgkIZg.cjs");
5
5
  let node_zlib = require("node:zlib");
6
6
  let _logtape_logtape = require("@logtape/logtape");
@@ -2009,4 +2009,98 @@ var jsonld_default = jsonld_dist_jsonld_esm_js.default;
2009
2009
  esm_default.hardReset();
2010
2010
  }
2011
2011
  });
2012
+ const unsupportedUrls = [
2013
+ "ftp://example.com/collection",
2014
+ "file:///collection",
2015
+ `data:application/ld+json,${encodeURIComponent(JSON.stringify({ id: "https://example.com/data" }))}`
2016
+ ];
2017
+ function isCredentialUrlError(error) {
2018
+ return error instanceof require_url.UrlError && error.reason === "disallowed" && error.message === "URLs with credentials are not allowed";
2019
+ }
2020
+ (0, _fedify_fixture.test)("getDocumentLoader() rejects URLs with credentials", async (t) => {
2021
+ const start = "https://8.8.8.8/start";
2022
+ const target = "https://user:pass@8.8.8.8/document";
2023
+ for (const allowPrivateAddress of [false, true]) {
2024
+ const loader = require_docloader.getDocumentLoader({ allowPrivateAddress });
2025
+ for (const path of [
2026
+ "direct",
2027
+ "redirect",
2028
+ "Link",
2029
+ "HTML"
2030
+ ]) await t.step(`${path}, allowPrivateAddress=${allowPrivateAddress}`, async () => {
2031
+ await withFetchStub((url) => {
2032
+ if (url !== start) return Response.json({});
2033
+ if (path === "redirect") return Response.redirect(target, 302);
2034
+ if (path === "HTML") return new Response(`<link rel="alternate" type="application/activity+json" href="${target}">`, { headers: { "Content-Type": "text/html" } });
2035
+ return new Response("", { headers: {
2036
+ "Content-Type": "text/plain",
2037
+ Link: `<${target}>; rel="alternate"; type="application/activity+json"`
2038
+ } });
2039
+ }, async (fetched) => {
2040
+ await (0, node_assert.rejects)(loader(path === "direct" ? target : start), isCredentialUrlError);
2041
+ (0, node_assert.deepStrictEqual)(fetched, path === "direct" ? [] : [start]);
2042
+ });
2043
+ });
2044
+ for (const url of [
2045
+ "https://user@8.8.8.8/document",
2046
+ "https://:pass@8.8.8.8/document",
2047
+ "https://%75ser:p%40ss@8.8.8.8/document",
2048
+ "http://user:pass@8.8.8.8/document"
2049
+ ]) await t.step(`${url}, allowPrivateAddress=${allowPrivateAddress}`, async () => {
2050
+ await withFetchStub(() => Response.json({}), async (fetched) => {
2051
+ await (0, node_assert.rejects)(loader(url), isCredentialUrlError);
2052
+ (0, node_assert.deepStrictEqual)(fetched, []);
2053
+ });
2054
+ });
2055
+ for (const url of ["https://:@8.8.8.8/document", "https://8.8.8.8/@user?q=@pass"]) await t.step(`allows ${url}, allowPrivateAddress=${allowPrivateAddress}`, async () => {
2056
+ await withFetchStub(() => Response.json({ done: true }), async (fetched) => {
2057
+ (0, node_assert.deepStrictEqual)((await loader(url)).document, { done: true });
2058
+ (0, node_assert.deepStrictEqual)(fetched, [new URL(url).href]);
2059
+ });
2060
+ });
2061
+ }
2062
+ });
2063
+ function isUnsupportedProtocolError(error, url) {
2064
+ return error instanceof require_url.UrlError && error.message === `Unsupported protocol: ${new URL(url).protocol}` && error.reason === "disallowed";
2065
+ }
2066
+ async function withFetchStub(respond, callback) {
2067
+ const fetched = [];
2068
+ const originalFetch = globalThis.fetch;
2069
+ globalThis.fetch = (input) => {
2070
+ const url = input instanceof Request ? input.url : String(input);
2071
+ fetched.push(url);
2072
+ return Promise.resolve(respond(url));
2073
+ };
2074
+ try {
2075
+ await callback(fetched);
2076
+ } finally {
2077
+ globalThis.fetch = originalFetch;
2078
+ }
2079
+ }
2080
+ for (const url of unsupportedUrls) {
2081
+ const scheme = new URL(url).protocol;
2082
+ (0, _fedify_fixture.test)(`getDocumentLoader() rejects ${scheme} URLs with private addresses allowed before fetching`, async () => {
2083
+ await withFetchStub(() => Response.json({}), async (fetched) => {
2084
+ await (0, node_assert.rejects)(require_docloader.getDocumentLoader({ allowPrivateAddress: true })(url), (e) => isUnsupportedProtocolError(e, url));
2085
+ (0, node_assert.deepStrictEqual)(fetched, []);
2086
+ });
2087
+ });
2088
+ (0, _fedify_fixture.test)(`getDocumentLoader() rejects redirects to ${scheme} URLs with private addresses allowed`, async () => {
2089
+ const start = "https://example.com/redirect-to-unsupported";
2090
+ await withFetchStub(() => Response.redirect(url, 302), async (fetched) => {
2091
+ await (0, node_assert.rejects)(require_docloader.getDocumentLoader({ allowPrivateAddress: true })(start), (e) => isUnsupportedProtocolError(e, url));
2092
+ (0, node_assert.deepStrictEqual)(fetched, [start]);
2093
+ });
2094
+ });
2095
+ for (const html of [false, true]) (0, _fedify_fixture.test)(`getDocumentLoader() rejects ${html ? "HTML" : "Link header"} alternates to ${scheme} URLs with private addresses allowed`, async () => {
2096
+ const start = "https://example.com/alternate-to-unsupported";
2097
+ await withFetchStub(() => html ? new Response(`<link rel="alternate" type="application/activity+json" href="${url}">`, { headers: { "Content-Type": "text/html" } }) : new Response("", { headers: {
2098
+ "Content-Type": "text/plain",
2099
+ Link: `<${url}>; rel="alternate"; type="application/activity+json"`
2100
+ } }), async (fetched) => {
2101
+ await (0, node_assert.rejects)(require_docloader.getDocumentLoader({ allowPrivateAddress: true })(start), (e) => isUnsupportedProtocolError(e, url));
2102
+ (0, node_assert.deepStrictEqual)(fetched, [start]);
2103
+ });
2104
+ });
2105
+ }
2012
2106
  //#endregion
@@ -1,5 +1,5 @@
1
- import { t as FetchError } from "./request-D5w4xI1V.mjs";
2
- import { i as cid_v1_default, n as getRemoteDocument, r as preloadedContexts, t as getDocumentLoader } from "./docloader-D-e_R3Qj.mjs";
1
+ import { t as FetchError } from "./request-yCis9VCB.mjs";
2
+ import { i as cid_v1_default, n as getRemoteDocument, r as preloadedContexts, t as getDocumentLoader } from "./docloader-BjyFsnAA.mjs";
3
3
  import { t as UrlError } from "./url-3fyJuqC0.mjs";
4
4
  import { gzipSync } from "node:zlib";
5
5
  import { configure, reset } from "@logtape/logtape";
@@ -2026,5 +2026,99 @@ test("getDocumentLoader() rejects cancellation before fetching", async () => {
2026
2026
  esm_default.hardReset();
2027
2027
  }
2028
2028
  });
2029
+ const unsupportedUrls = [
2030
+ "ftp://example.com/collection",
2031
+ "file:///collection",
2032
+ `data:application/ld+json,${encodeURIComponent(JSON.stringify({ id: "https://example.com/data" }))}`
2033
+ ];
2034
+ function isCredentialUrlError(error) {
2035
+ return error instanceof UrlError && error.reason === "disallowed" && error.message === "URLs with credentials are not allowed";
2036
+ }
2037
+ test("getDocumentLoader() rejects URLs with credentials", async (t) => {
2038
+ const start = "https://8.8.8.8/start";
2039
+ const target = "https://user:pass@8.8.8.8/document";
2040
+ for (const allowPrivateAddress of [false, true]) {
2041
+ const loader = getDocumentLoader({ allowPrivateAddress });
2042
+ for (const path of [
2043
+ "direct",
2044
+ "redirect",
2045
+ "Link",
2046
+ "HTML"
2047
+ ]) await t.step(`${path}, allowPrivateAddress=${allowPrivateAddress}`, async () => {
2048
+ await withFetchStub((url) => {
2049
+ if (url !== start) return Response.json({});
2050
+ if (path === "redirect") return Response.redirect(target, 302);
2051
+ if (path === "HTML") return new Response(`<link rel="alternate" type="application/activity+json" href="${target}">`, { headers: { "Content-Type": "text/html" } });
2052
+ return new Response("", { headers: {
2053
+ "Content-Type": "text/plain",
2054
+ Link: `<${target}>; rel="alternate"; type="application/activity+json"`
2055
+ } });
2056
+ }, async (fetched) => {
2057
+ await rejects(loader(path === "direct" ? target : start), isCredentialUrlError);
2058
+ deepStrictEqual(fetched, path === "direct" ? [] : [start]);
2059
+ });
2060
+ });
2061
+ for (const url of [
2062
+ "https://user@8.8.8.8/document",
2063
+ "https://:pass@8.8.8.8/document",
2064
+ "https://%75ser:p%40ss@8.8.8.8/document",
2065
+ "http://user:pass@8.8.8.8/document"
2066
+ ]) await t.step(`${url}, allowPrivateAddress=${allowPrivateAddress}`, async () => {
2067
+ await withFetchStub(() => Response.json({}), async (fetched) => {
2068
+ await rejects(loader(url), isCredentialUrlError);
2069
+ deepStrictEqual(fetched, []);
2070
+ });
2071
+ });
2072
+ for (const url of ["https://:@8.8.8.8/document", "https://8.8.8.8/@user?q=@pass"]) await t.step(`allows ${url}, allowPrivateAddress=${allowPrivateAddress}`, async () => {
2073
+ await withFetchStub(() => Response.json({ done: true }), async (fetched) => {
2074
+ deepStrictEqual((await loader(url)).document, { done: true });
2075
+ deepStrictEqual(fetched, [new URL(url).href]);
2076
+ });
2077
+ });
2078
+ }
2079
+ });
2080
+ function isUnsupportedProtocolError(error, url) {
2081
+ return error instanceof UrlError && error.message === `Unsupported protocol: ${new URL(url).protocol}` && error.reason === "disallowed";
2082
+ }
2083
+ async function withFetchStub(respond, callback) {
2084
+ const fetched = [];
2085
+ const originalFetch = globalThis.fetch;
2086
+ globalThis.fetch = (input) => {
2087
+ const url = input instanceof Request ? input.url : String(input);
2088
+ fetched.push(url);
2089
+ return Promise.resolve(respond(url));
2090
+ };
2091
+ try {
2092
+ await callback(fetched);
2093
+ } finally {
2094
+ globalThis.fetch = originalFetch;
2095
+ }
2096
+ }
2097
+ for (const url of unsupportedUrls) {
2098
+ const scheme = new URL(url).protocol;
2099
+ test(`getDocumentLoader() rejects ${scheme} URLs with private addresses allowed before fetching`, async () => {
2100
+ await withFetchStub(() => Response.json({}), async (fetched) => {
2101
+ await rejects(getDocumentLoader({ allowPrivateAddress: true })(url), (e) => isUnsupportedProtocolError(e, url));
2102
+ deepStrictEqual(fetched, []);
2103
+ });
2104
+ });
2105
+ test(`getDocumentLoader() rejects redirects to ${scheme} URLs with private addresses allowed`, async () => {
2106
+ const start = "https://example.com/redirect-to-unsupported";
2107
+ await withFetchStub(() => Response.redirect(url, 302), async (fetched) => {
2108
+ await rejects(getDocumentLoader({ allowPrivateAddress: true })(start), (e) => isUnsupportedProtocolError(e, url));
2109
+ deepStrictEqual(fetched, [start]);
2110
+ });
2111
+ });
2112
+ for (const html of [false, true]) test(`getDocumentLoader() rejects ${html ? "HTML" : "Link header"} alternates to ${scheme} URLs with private addresses allowed`, async () => {
2113
+ const start = "https://example.com/alternate-to-unsupported";
2114
+ await withFetchStub(() => html ? new Response(`<link rel="alternate" type="application/activity+json" href="${url}">`, { headers: { "Content-Type": "text/html" } }) : new Response("", { headers: {
2115
+ "Content-Type": "text/plain",
2116
+ Link: `<${url}>; rel="alternate"; type="application/activity+json"`
2117
+ } }), async (fetched) => {
2118
+ await rejects(getDocumentLoader({ allowPrivateAddress: true })(start), (e) => isUnsupportedProtocolError(e, url));
2119
+ deepStrictEqual(fetched, [start]);
2120
+ });
2121
+ });
2122
+ }
2029
2123
  //#endregion
2030
2124
  export {};
@@ -3,7 +3,7 @@ let node_process = require("node:process");
3
3
  node_process = require_chunk.__toESM(node_process);
4
4
  //#region deno.json
5
5
  var name = "@fedify/vocab-runtime";
6
- var version = "2.2.18-dev.2386+41abb5f5";
6
+ var version = "2.2.19";
7
7
  //#endregion
8
8
  //#region src/request.ts
9
9
  /**
@@ -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.2.18-dev.2386+41abb5f5";
4
+ var version = "2.2.19";
5
5
  //#endregion
6
6
  //#region src/request.ts
7
7
  /**
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-CNVibwBp.cjs");
2
+ const require_request = require("./request-DGtx5MkG.cjs");
3
3
  let node_test = require("node:test");
4
4
  let node_process = require("node:process");
5
5
  node_process = require_chunk.__toESM(node_process);
@@ -1,4 +1,4 @@
1
- import { o as version, r as getUserAgent } from "./request-D5w4xI1V.mjs";
1
+ import { o as version, r as getUserAgent } from "./request-yCis9VCB.mjs";
2
2
  import { test } from "node:test";
3
3
  import process from "node:process";
4
4
  import { deepStrictEqual } from "node:assert";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.2.18-dev.2386+41abb5f5",
3
+ "version": "2.2.19",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1103,3 +1103,177 @@ test("getDocumentLoader() rejects cancellation before fetching", async () => {
1103
1103
  fetchMock.hardReset();
1104
1104
  }
1105
1105
  });
1106
+
1107
+ const unsupportedUrls = [
1108
+ "ftp://example.com/collection",
1109
+ "file:///collection",
1110
+ `data:application/ld+json,${
1111
+ encodeURIComponent(JSON.stringify({ id: "https://example.com/data" }))
1112
+ }`,
1113
+ ];
1114
+
1115
+ function isCredentialUrlError(error: unknown): boolean {
1116
+ return error instanceof UrlError && error.reason === "disallowed" &&
1117
+ error.message === "URLs with credentials are not allowed";
1118
+ }
1119
+
1120
+ test("getDocumentLoader() rejects URLs with credentials", async (t) => {
1121
+ const start = "https://8.8.8.8/start";
1122
+ const target = "https://user:pass@8.8.8.8/document";
1123
+ for (const allowPrivateAddress of [false, true]) {
1124
+ const loader = getDocumentLoader({ allowPrivateAddress });
1125
+ for (const path of ["direct", "redirect", "Link", "HTML"]) {
1126
+ await t.step(
1127
+ `${path}, allowPrivateAddress=${allowPrivateAddress}`,
1128
+ async () => {
1129
+ await withFetchStub(
1130
+ (url) => {
1131
+ if (url !== start) return Response.json({});
1132
+ if (path === "redirect") return Response.redirect(target, 302);
1133
+ if (path === "HTML") {
1134
+ return new Response(
1135
+ `<link rel="alternate" type="application/activity+json" href="${target}">`,
1136
+ { headers: { "Content-Type": "text/html" } },
1137
+ );
1138
+ }
1139
+ return new Response("", {
1140
+ headers: {
1141
+ "Content-Type": "text/plain",
1142
+ Link:
1143
+ `<${target}>; rel="alternate"; type="application/activity+json"`,
1144
+ },
1145
+ });
1146
+ },
1147
+ async (fetched) => {
1148
+ await rejects(
1149
+ loader(path === "direct" ? target : start),
1150
+ isCredentialUrlError,
1151
+ );
1152
+ deepStrictEqual(fetched, path === "direct" ? [] : [start]);
1153
+ },
1154
+ );
1155
+ },
1156
+ );
1157
+ }
1158
+ for (
1159
+ const url of [
1160
+ "https://user@8.8.8.8/document",
1161
+ "https://:pass@8.8.8.8/document",
1162
+ "https://%75ser:p%40ss@8.8.8.8/document",
1163
+ "http://user:pass@8.8.8.8/document",
1164
+ ]
1165
+ ) {
1166
+ await t.step(
1167
+ `${url}, allowPrivateAddress=${allowPrivateAddress}`,
1168
+ async () => {
1169
+ await withFetchStub(() => Response.json({}), async (fetched) => {
1170
+ await rejects(loader(url), isCredentialUrlError);
1171
+ deepStrictEqual(fetched, []);
1172
+ });
1173
+ },
1174
+ );
1175
+ }
1176
+ for (
1177
+ const url of [
1178
+ "https://:@8.8.8.8/document",
1179
+ "https://8.8.8.8/@user?q=@pass",
1180
+ ]
1181
+ ) {
1182
+ await t.step(
1183
+ `allows ${url}, allowPrivateAddress=${allowPrivateAddress}`,
1184
+ async () => {
1185
+ await withFetchStub(
1186
+ () => Response.json({ done: true }),
1187
+ async (fetched) => {
1188
+ const document = await loader(url);
1189
+ deepStrictEqual(document.document, { done: true });
1190
+ deepStrictEqual(fetched, [new URL(url).href]);
1191
+ },
1192
+ );
1193
+ },
1194
+ );
1195
+ }
1196
+ }
1197
+ });
1198
+
1199
+ function isUnsupportedProtocolError(error: unknown, url: string): boolean {
1200
+ return error instanceof UrlError &&
1201
+ error.message === `Unsupported protocol: ${new URL(url).protocol}` &&
1202
+ error.reason === "disallowed";
1203
+ }
1204
+
1205
+ async function withFetchStub(
1206
+ respond: (url: string) => Response,
1207
+ callback: (fetched: string[]) => Promise<void>,
1208
+ ): Promise<void> {
1209
+ const fetched: string[] = [];
1210
+ const originalFetch = globalThis.fetch;
1211
+ globalThis.fetch = (input: RequestInfo | URL) => {
1212
+ const url = input instanceof Request ? input.url : String(input);
1213
+ fetched.push(url);
1214
+ return Promise.resolve(respond(url));
1215
+ };
1216
+ try {
1217
+ await callback(fetched);
1218
+ } finally {
1219
+ globalThis.fetch = originalFetch;
1220
+ }
1221
+ }
1222
+
1223
+ for (const url of unsupportedUrls) {
1224
+ const scheme = new URL(url).protocol;
1225
+
1226
+ test(`getDocumentLoader() rejects ${scheme} URLs with private addresses allowed before fetching`, async () => {
1227
+ await withFetchStub(() => Response.json({}), async (fetched) => {
1228
+ await rejects(
1229
+ getDocumentLoader({ allowPrivateAddress: true })(url),
1230
+ (e) => isUnsupportedProtocolError(e, url),
1231
+ );
1232
+ deepStrictEqual(fetched, []);
1233
+ });
1234
+ });
1235
+
1236
+ test(`getDocumentLoader() rejects redirects to ${scheme} URLs with private addresses allowed`, async () => {
1237
+ const start = "https://example.com/redirect-to-unsupported";
1238
+ await withFetchStub(() => Response.redirect(url, 302), async (fetched) => {
1239
+ await rejects(
1240
+ getDocumentLoader({ allowPrivateAddress: true })(start),
1241
+ (e) => isUnsupportedProtocolError(e, url),
1242
+ );
1243
+ deepStrictEqual(fetched, [start]);
1244
+ });
1245
+ });
1246
+
1247
+ for (const html of [false, true]) {
1248
+ test(
1249
+ `getDocumentLoader() rejects ${
1250
+ html ? "HTML" : "Link header"
1251
+ } alternates to ${scheme} URLs with private addresses allowed`,
1252
+ async () => {
1253
+ const start = "https://example.com/alternate-to-unsupported";
1254
+ await withFetchStub(
1255
+ () =>
1256
+ html
1257
+ ? new Response(
1258
+ `<link rel="alternate" type="application/activity+json" href="${url}">`,
1259
+ { headers: { "Content-Type": "text/html" } },
1260
+ )
1261
+ : new Response("", {
1262
+ headers: {
1263
+ "Content-Type": "text/plain",
1264
+ Link:
1265
+ `<${url}>; rel="alternate"; type="application/activity+json"`,
1266
+ },
1267
+ }),
1268
+ async (fetched) => {
1269
+ await rejects(
1270
+ getDocumentLoader({ allowPrivateAddress: true })(start),
1271
+ (e) => isUnsupportedProtocolError(e, url),
1272
+ );
1273
+ deepStrictEqual(fetched, [start]);
1274
+ },
1275
+ );
1276
+ },
1277
+ );
1278
+ }
1279
+ }
package/src/docloader.ts CHANGED
@@ -311,7 +311,16 @@ export function getDocumentLoader(
311
311
  visited = new Set<string>(),
312
312
  ): Promise<RemoteDocument> {
313
313
  options?.signal?.throwIfAborted();
314
- const currentUrl = new URL(url).href;
314
+ const parsedUrl = new URL(url);
315
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
316
+ throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`);
317
+ }
318
+ // When merging forward, keep this guard independent of allowPrivateAddress:
319
+ // private-address opt-in must not permit credentials on any followed URL.
320
+ if (parsedUrl.username !== "" || parsedUrl.password !== "") {
321
+ throw new UrlError("URLs with credentials are not allowed");
322
+ }
323
+ const currentUrl = parsedUrl.href;
315
324
  if (!skipPreloadedContexts && currentUrl in preloadedContexts) {
316
325
  logger.debug("Using preloaded context: {url}.", { url: currentUrl });
317
326
  return {