@fedify/vocab-runtime 2.3.9-dev.2048 → 2.3.9
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 +1 -1
- package/dist/mod.cjs +52 -7
- package/dist/mod.d.cts +10 -0
- package/dist/mod.d.ts +10 -0
- package/dist/mod.js +52 -8
- package/dist/tests/{body-C0gO8fmt.cjs → body-DNvWu531.cjs} +1 -1
- package/dist/tests/{body-DYKjs6_2.mjs → body-DeY0Uv88.mjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +2 -2
- package/dist/tests/decimal.test.mjs +2 -2
- package/dist/tests/{docloader-BqznqaJT.cjs → docloader-D_l8ltNM.cjs} +40 -6
- package/dist/tests/{docloader-B3cffcK1.mjs → docloader-dCXxI9_u.mjs} +40 -6
- package/dist/tests/docloader.test.cjs +241 -3
- package/dist/tests/docloader.test.mjs +240 -3
- package/dist/tests/{request-4o-Cs0CR.cjs → request-B0XFxGPa.cjs} +1 -1
- package/dist/tests/{request-89V7ieWl.mjs → request-DH0zYL7t.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/{url-BBfduh6o.mjs → url-3fyJuqC0.mjs} +14 -4
- package/dist/tests/{url-BbEC9H_R.cjs → url-4PAkYhZC.cjs} +15 -4
- package/dist/tests/url.test.cjs +36 -3
- package/dist/tests/url.test.mjs +37 -4
- package/package.json +1 -1
- package/src/contexts/fep-7aa9.json +24 -0
- package/src/contexts.ts +8 -0
- package/src/docloader.test.ts +307 -0
- package/src/docloader.ts +35 -6
- package/src/url.test.ts +29 -3
- package/src/url.ts +21 -5
package/dist/tests/url.test.cjs
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
require("./chunk-C2EiDwsr.cjs");
|
|
2
|
-
const require_url = require("./url-
|
|
2
|
+
const require_url = require("./url-4PAkYhZC.cjs");
|
|
3
3
|
let node_test = require("node:test");
|
|
4
4
|
let node_assert = require("node:assert");
|
|
5
5
|
//#region src/url.test.ts
|
|
6
|
+
(0, node_test.test)("UrlError reason is independent of its cause", () => {
|
|
7
|
+
const cause = /* @__PURE__ */ new Error("Underlying failure");
|
|
8
|
+
(0, node_assert.strictEqual)(new require_url.UrlError("Rejected URL").reason, "disallowed");
|
|
9
|
+
const disallowedError = new require_url.UrlError("Rejected URL", { cause });
|
|
10
|
+
(0, node_assert.strictEqual)(disallowedError.reason, "disallowed");
|
|
11
|
+
(0, node_assert.strictEqual)(disallowedError.cause, cause);
|
|
12
|
+
const dnsError = new require_url.UrlError("Lookup failed", {
|
|
13
|
+
reason: "dns",
|
|
14
|
+
cause
|
|
15
|
+
});
|
|
16
|
+
(0, node_assert.strictEqual)(dnsError.reason, "dns");
|
|
17
|
+
(0, node_assert.strictEqual)(dnsError.cause, cause);
|
|
18
|
+
});
|
|
19
|
+
(0, node_test.test)("validatePublicUrl() classifies disallowed URLs", async () => {
|
|
20
|
+
for (const url of [
|
|
21
|
+
"ftp://example.com",
|
|
22
|
+
"https://localhost",
|
|
23
|
+
"https://127.0.0.1"
|
|
24
|
+
]) await (0, node_assert.rejects)(() => require_url.validatePublicUrl(url), {
|
|
25
|
+
name: "UrlError",
|
|
26
|
+
reason: "disallowed"
|
|
27
|
+
});
|
|
28
|
+
});
|
|
6
29
|
(0, node_test.test)("validatePublicUrl()", async () => {
|
|
7
30
|
await (0, node_assert.rejects)(() => require_url.validatePublicUrl("ftp://localhost"), require_url.UrlError);
|
|
8
31
|
await (0, node_assert.rejects)(() => require_url.validatePublicUrl("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="), require_url.UrlError);
|
|
@@ -52,11 +75,21 @@ let node_assert = require("node:assert");
|
|
|
52
75
|
}, {
|
|
53
76
|
address: "127.0.0.1",
|
|
54
77
|
family: 4
|
|
55
|
-
}]),
|
|
78
|
+
}]), {
|
|
79
|
+
name: "UrlError",
|
|
80
|
+
reason: "disallowed"
|
|
81
|
+
});
|
|
56
82
|
(0, node_assert.throws)(() => require_url.validateLookupAddresses([{
|
|
57
83
|
address: "app-host.example.net.",
|
|
58
84
|
family: 4
|
|
59
|
-
}]),
|
|
85
|
+
}]), {
|
|
86
|
+
name: "UrlError",
|
|
87
|
+
reason: "dns"
|
|
88
|
+
});
|
|
89
|
+
(0, node_assert.throws)(() => require_url.validateLookupAddresses([]), {
|
|
90
|
+
name: "UrlError",
|
|
91
|
+
reason: "dns"
|
|
92
|
+
});
|
|
60
93
|
});
|
|
61
94
|
(0, node_test.test)("isValidPublicIPv4Address()", () => {
|
|
62
95
|
(0, node_assert.ok)(require_url.isValidPublicIPv4Address("8.8.8.8"));
|
package/dist/tests/url.test.mjs
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
|
-
import { a as validateLookupAddresses, i as isValidPublicIPv6Address, n as expandIPv6Address, o as validatePublicUrl, r as isValidPublicIPv4Address, t as UrlError } from "./url-
|
|
1
|
+
import { a as validateLookupAddresses, i as isValidPublicIPv6Address, n as expandIPv6Address, o as validatePublicUrl, r as isValidPublicIPv4Address, t as UrlError } from "./url-3fyJuqC0.mjs";
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { deepStrictEqual, ok, rejects, throws } from "node:assert";
|
|
3
|
+
import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert";
|
|
4
4
|
//#region src/url.test.ts
|
|
5
|
+
test("UrlError reason is independent of its cause", () => {
|
|
6
|
+
const cause = /* @__PURE__ */ new Error("Underlying failure");
|
|
7
|
+
strictEqual(new UrlError("Rejected URL").reason, "disallowed");
|
|
8
|
+
const disallowedError = new UrlError("Rejected URL", { cause });
|
|
9
|
+
strictEqual(disallowedError.reason, "disallowed");
|
|
10
|
+
strictEqual(disallowedError.cause, cause);
|
|
11
|
+
const dnsError = new UrlError("Lookup failed", {
|
|
12
|
+
reason: "dns",
|
|
13
|
+
cause
|
|
14
|
+
});
|
|
15
|
+
strictEqual(dnsError.reason, "dns");
|
|
16
|
+
strictEqual(dnsError.cause, cause);
|
|
17
|
+
});
|
|
18
|
+
test("validatePublicUrl() classifies disallowed URLs", async () => {
|
|
19
|
+
for (const url of [
|
|
20
|
+
"ftp://example.com",
|
|
21
|
+
"https://localhost",
|
|
22
|
+
"https://127.0.0.1"
|
|
23
|
+
]) await rejects(() => validatePublicUrl(url), {
|
|
24
|
+
name: "UrlError",
|
|
25
|
+
reason: "disallowed"
|
|
26
|
+
});
|
|
27
|
+
});
|
|
5
28
|
test("validatePublicUrl()", async () => {
|
|
6
29
|
await rejects(() => validatePublicUrl("ftp://localhost"), UrlError);
|
|
7
30
|
await rejects(() => validatePublicUrl("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="), UrlError);
|
|
@@ -51,11 +74,21 @@ test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers
|
|
|
51
74
|
}, {
|
|
52
75
|
address: "127.0.0.1",
|
|
53
76
|
family: 4
|
|
54
|
-
}]),
|
|
77
|
+
}]), {
|
|
78
|
+
name: "UrlError",
|
|
79
|
+
reason: "disallowed"
|
|
80
|
+
});
|
|
55
81
|
throws(() => validateLookupAddresses([{
|
|
56
82
|
address: "app-host.example.net.",
|
|
57
83
|
family: 4
|
|
58
|
-
}]),
|
|
84
|
+
}]), {
|
|
85
|
+
name: "UrlError",
|
|
86
|
+
reason: "dns"
|
|
87
|
+
});
|
|
88
|
+
throws(() => validateLookupAddresses([]), {
|
|
89
|
+
name: "UrlError",
|
|
90
|
+
reason: "dns"
|
|
91
|
+
});
|
|
59
92
|
});
|
|
60
93
|
test("isValidPublicIPv4Address()", () => {
|
|
61
94
|
ok(isValidPublicIPv4Address("8.8.8.8"));
|
package/package.json
CHANGED
|
@@ -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
|
@@ -8,6 +8,7 @@ import activitystreams from "./contexts/activitystreams.json" with {
|
|
|
8
8
|
import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
|
|
9
9
|
import didV1 from "./contexts/did-v1.json" with { type: "json" };
|
|
10
10
|
import fep5711 from "./contexts/fep-5711.json" with { type: "json" };
|
|
11
|
+
import fep7aa9 from "./contexts/fep-7aa9.json" with { type: "json" };
|
|
11
12
|
import fepEf61 from "./contexts/fep-ef61.json" with { type: "json" };
|
|
12
13
|
import gotosocial from "./contexts/gotosocial.json" with { type: "json" };
|
|
13
14
|
import identityV1 from "./contexts/identity-v1.json" with { type: "json" };
|
|
@@ -56,6 +57,13 @@ const preloadedContexts: Record<string, unknown> = {
|
|
|
56
57
|
// https://github.com/fedify-dev/fedify/issues/982
|
|
57
58
|
"https://w3id.org/fep/ef61": fepEf61,
|
|
58
59
|
|
|
60
|
+
// The FEP-7aa9 context is hosted on Codeberg Pages, whose outages can
|
|
61
|
+
// prevent actor documents from being parsed. Resolve it locally so that
|
|
62
|
+
// parsing does not depend on the remote context server's availability.
|
|
63
|
+
// See: https://w3id.org/fep/7aa9
|
|
64
|
+
// https://github.com/fedify-dev/fedify/issues/1078
|
|
65
|
+
"https://w3id.org/fep/7aa9": fep7aa9,
|
|
66
|
+
|
|
59
67
|
// Lemmy's context document is served as application/json without the JSON-LD
|
|
60
68
|
// context Link header. The default document loader treats that as a regular
|
|
61
69
|
// JSON response instead of a JSON-LD context, so every Lemmy activity that
|
package/src/docloader.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { configure, type LogRecord, reset } from "@logtape/logtape";
|
|
1
2
|
import fetchMock from "fetch-mock";
|
|
2
3
|
import { deepStrictEqual, ok, rejects } from "node:assert";
|
|
4
|
+
import dns from "node:dns/promises";
|
|
3
5
|
import { test } from "node:test";
|
|
4
6
|
import { createServer } from "node:http";
|
|
5
7
|
import { gzipSync } from "node:zlib";
|
|
@@ -336,6 +338,43 @@ test("getDocumentLoader()", async (t) => {
|
|
|
336
338
|
});
|
|
337
339
|
});
|
|
338
340
|
|
|
341
|
+
// A Codeberg Pages outage must not prevent loading the FEP-7aa9 context.
|
|
342
|
+
// See: https://github.com/fedify-dev/fedify/issues/1078
|
|
343
|
+
fetchMock.get("https://w3id.org/fep/7aa9", { status: 502 });
|
|
344
|
+
await t.test("preloaded FEP-7aa9 context", async () => {
|
|
345
|
+
const url = "https://w3id.org/fep/7aa9";
|
|
346
|
+
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
347
|
+
contextUrl: null,
|
|
348
|
+
documentUrl: url,
|
|
349
|
+
document: {
|
|
350
|
+
"@context": {
|
|
351
|
+
"FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
|
|
352
|
+
"FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
|
|
353
|
+
"FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
|
|
354
|
+
"FeatureAuthorization":
|
|
355
|
+
"https://w3id.org/fep/7aa9#FeatureAuthorization",
|
|
356
|
+
"topic": {
|
|
357
|
+
"@id": "https://w3id.org/fep/7aa9#topic",
|
|
358
|
+
"@type": "@id",
|
|
359
|
+
},
|
|
360
|
+
"featuredObject": {
|
|
361
|
+
"@id": "https://w3id.org/fep/7aa9#featuredObject",
|
|
362
|
+
"@type": "@id",
|
|
363
|
+
},
|
|
364
|
+
"canFeature": {
|
|
365
|
+
"@id": "https://w3id.org/fep/7aa9#canFeature",
|
|
366
|
+
"@type": "@id",
|
|
367
|
+
},
|
|
368
|
+
"featureAuthorization": {
|
|
369
|
+
"@id": "https://w3id.org/fep/7aa9#featureAuthorization",
|
|
370
|
+
"@type": "@id",
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
|
|
376
|
+
});
|
|
377
|
+
|
|
339
378
|
await t.test("deny non-HTTP/HTTPS", async () => {
|
|
340
379
|
await rejects(
|
|
341
380
|
() => fetchDocumentLoader("ftp://localhost"),
|
|
@@ -641,3 +680,271 @@ test("getRemoteDocument() bounds JSON by default", async () => {
|
|
|
641
680
|
deepStrictEqual(canceled, true);
|
|
642
681
|
deepStrictEqual(pulls, 257);
|
|
643
682
|
});
|
|
683
|
+
|
|
684
|
+
test("getDocumentLoader() logs DNS failures as such", async (t) => {
|
|
685
|
+
// The validator skips DNS when Deno has no network permission. Checked
|
|
686
|
+
// here rather than with top-level await, which the CommonJS build rejects.
|
|
687
|
+
if (
|
|
688
|
+
"Deno" in globalThis &&
|
|
689
|
+
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
690
|
+
) {
|
|
691
|
+
t.skip("requires the net permission");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const loader = getDocumentLoader();
|
|
695
|
+
for (const result of ["throws", "empty", "private"] as const) {
|
|
696
|
+
await t.test(result, async () => {
|
|
697
|
+
// Stubbing works only because url.ts uses the default node:dns/promises
|
|
698
|
+
// import; see the FIXME there.
|
|
699
|
+
const originalLookup = dns.lookup;
|
|
700
|
+
dns.lookup = (() =>
|
|
701
|
+
result === "throws"
|
|
702
|
+
? Promise.reject(new Error("Resolver unavailable"))
|
|
703
|
+
: Promise.resolve(
|
|
704
|
+
result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
|
|
705
|
+
)) as typeof dns.lookup;
|
|
706
|
+
const records: LogRecord[] = [];
|
|
707
|
+
await configure({
|
|
708
|
+
sinks: {
|
|
709
|
+
buffer: (record) =>
|
|
710
|
+
records.push(record),
|
|
711
|
+
},
|
|
712
|
+
loggers: [
|
|
713
|
+
{ category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
|
|
714
|
+
{ category: ["logtape", "meta"], sinks: [] },
|
|
715
|
+
],
|
|
716
|
+
reset: true,
|
|
717
|
+
});
|
|
718
|
+
try {
|
|
719
|
+
const url = "https://dns-failure.invalid/object";
|
|
720
|
+
let error: unknown;
|
|
721
|
+
await rejects(() => loader(url), (e) => {
|
|
722
|
+
error = e;
|
|
723
|
+
return true;
|
|
724
|
+
});
|
|
725
|
+
ok(error instanceof UrlError);
|
|
726
|
+
deepStrictEqual(
|
|
727
|
+
error.reason,
|
|
728
|
+
result === "private" ? "disallowed" : "dns",
|
|
729
|
+
);
|
|
730
|
+
deepStrictEqual(
|
|
731
|
+
records.map((r) => [r.level, r.rawMessage, r.properties.url]),
|
|
732
|
+
[
|
|
733
|
+
result === "private"
|
|
734
|
+
? ["error", "Disallowed private URL: {url}", url]
|
|
735
|
+
: ["debug", "DNS lookup failed for {url}", url],
|
|
736
|
+
],
|
|
737
|
+
);
|
|
738
|
+
ok(records[0].properties.error === error);
|
|
739
|
+
} finally {
|
|
740
|
+
await reset();
|
|
741
|
+
dns.lookup = originalLookup;
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("getDocumentLoader() bounds alternate document chains", async (t) => {
|
|
748
|
+
const base = "https://example.com/alternate-chain/";
|
|
749
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
750
|
+
for (const mode of ["header", "html", "mixed"] as const) {
|
|
751
|
+
for (const hops of [20, 21]) {
|
|
752
|
+
await t.test(`${mode}: ${hops} hops`, async () => {
|
|
753
|
+
fetchMock.mockGlobal();
|
|
754
|
+
let requests = 0;
|
|
755
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
756
|
+
requests++;
|
|
757
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
758
|
+
// Keep the unpatched regression bounded too.
|
|
759
|
+
if (index === hops || requests > 30) {
|
|
760
|
+
return Response.json({ done: true });
|
|
761
|
+
}
|
|
762
|
+
const next = `${base}${index + 1}`;
|
|
763
|
+
if (mode === "mixed" && index % 2 === 1) {
|
|
764
|
+
return Response.redirect(next, 302);
|
|
765
|
+
}
|
|
766
|
+
return alternate(next, mode === "html");
|
|
767
|
+
});
|
|
768
|
+
try {
|
|
769
|
+
if (hops === 20) {
|
|
770
|
+
deepStrictEqual((await loader(`${base}0`)).document, {
|
|
771
|
+
done: true,
|
|
772
|
+
});
|
|
773
|
+
} else {
|
|
774
|
+
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
775
|
+
ok(error instanceof FetchError);
|
|
776
|
+
ok(error.message.includes("Too many redirections (21)"));
|
|
777
|
+
return true;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
deepStrictEqual(requests, 21);
|
|
781
|
+
} finally {
|
|
782
|
+
fetchMock.hardReset();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (
|
|
789
|
+
const mode of [
|
|
790
|
+
"header",
|
|
791
|
+
"html",
|
|
792
|
+
"alternate-redirect",
|
|
793
|
+
"redirect-alternate",
|
|
794
|
+
"redirect-intermediate",
|
|
795
|
+
]
|
|
796
|
+
) {
|
|
797
|
+
await t.test(`${mode}: cycle`, async () => {
|
|
798
|
+
fetchMock.mockGlobal();
|
|
799
|
+
let requests = 0;
|
|
800
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
801
|
+
requests++;
|
|
802
|
+
if (requests > 30) return Response.json({ stopped: true });
|
|
803
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
804
|
+
const next = `${base}${
|
|
805
|
+
mode === "redirect-intermediate"
|
|
806
|
+
? (index === 0 ? 1 : index === 1 ? 2 : 1)
|
|
807
|
+
: 1 - index
|
|
808
|
+
}`;
|
|
809
|
+
if (
|
|
810
|
+
(mode === "alternate-redirect" && index === 1) ||
|
|
811
|
+
((mode === "redirect-alternate" ||
|
|
812
|
+
mode === "redirect-intermediate") && index === 0)
|
|
813
|
+
) {
|
|
814
|
+
return Response.redirect(next, 302);
|
|
815
|
+
}
|
|
816
|
+
return alternate(next, mode === "html");
|
|
817
|
+
});
|
|
818
|
+
try {
|
|
819
|
+
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
820
|
+
ok(error instanceof FetchError);
|
|
821
|
+
ok(error.message.includes("Redirect loop detected:"));
|
|
822
|
+
return true;
|
|
823
|
+
});
|
|
824
|
+
deepStrictEqual(requests, mode === "redirect-intermediate" ? 3 : 2);
|
|
825
|
+
} finally {
|
|
826
|
+
fetchMock.hardReset();
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
await t.test("alternate followed by 20 redirects shares the limit", async () => {
|
|
832
|
+
fetchMock.mockGlobal();
|
|
833
|
+
let requests = 0;
|
|
834
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
835
|
+
requests++;
|
|
836
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
837
|
+
if (index === 21) return Response.json({ done: true });
|
|
838
|
+
return index === 0
|
|
839
|
+
? alternate(`${base}1`, false)
|
|
840
|
+
: Response.redirect(`${base}${index + 1}`, 302);
|
|
841
|
+
});
|
|
842
|
+
try {
|
|
843
|
+
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
844
|
+
ok(error instanceof FetchError);
|
|
845
|
+
ok(error.message.includes("Too many redirections (21)"));
|
|
846
|
+
return true;
|
|
847
|
+
});
|
|
848
|
+
deepStrictEqual(requests, 21);
|
|
849
|
+
} finally {
|
|
850
|
+
fetchMock.hardReset();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
await t.test("relative alternate after redirect and isolated calls", async () => {
|
|
855
|
+
fetchMock.mockGlobal();
|
|
856
|
+
fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
|
|
857
|
+
fetchMock.get(`${base}html`, alternate("./document", true));
|
|
858
|
+
fetchMock.get(`${base}document`, Response.json({ done: true }));
|
|
859
|
+
try {
|
|
860
|
+
for (let i = 0; i < 2; i++) {
|
|
861
|
+
const results = await Promise.all([
|
|
862
|
+
loader(`${base}start`),
|
|
863
|
+
loader(`${base}start`),
|
|
864
|
+
]);
|
|
865
|
+
for (const result of results) {
|
|
866
|
+
deepStrictEqual(result.document, { done: true });
|
|
867
|
+
deepStrictEqual(result.documentUrl, `${base}document`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} finally {
|
|
871
|
+
fetchMock.hardReset();
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
function alternate(next: string, html: boolean): Response {
|
|
876
|
+
return html
|
|
877
|
+
? new Response(
|
|
878
|
+
`<link rel="alternate" type="application/activity+json" href="${next}">`,
|
|
879
|
+
{
|
|
880
|
+
headers: { "Content-Type": "text/html" },
|
|
881
|
+
},
|
|
882
|
+
)
|
|
883
|
+
: new Response("not JSON", {
|
|
884
|
+
headers: {
|
|
885
|
+
"Content-Type": "text/plain",
|
|
886
|
+
Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("getDocumentLoader() preserves cancellation across alternates", async (t) => {
|
|
893
|
+
const base = "https://example.com/alternate-abort/";
|
|
894
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
895
|
+
for (const html of [false, true]) {
|
|
896
|
+
await t.test(html ? "HTML" : "Link header", async () => {
|
|
897
|
+
fetchMock.mockGlobal();
|
|
898
|
+
const controller = new AbortController();
|
|
899
|
+
let requests = 0;
|
|
900
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
901
|
+
requests++;
|
|
902
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
903
|
+
if (index === 2) return Response.json({ done: true });
|
|
904
|
+
if (index === 1) controller.abort();
|
|
905
|
+
const next = `${base}${index + 1}`;
|
|
906
|
+
return new Response(
|
|
907
|
+
html
|
|
908
|
+
? `<link rel="alternate" type="application/activity+json" href="${next}">`
|
|
909
|
+
: "not JSON",
|
|
910
|
+
{
|
|
911
|
+
headers: html ? { "Content-Type": "text/html" } : {
|
|
912
|
+
"Content-Type": "text/plain",
|
|
913
|
+
Link:
|
|
914
|
+
`<${next}>; rel="alternate"; type="application/activity+json"`,
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
);
|
|
918
|
+
});
|
|
919
|
+
try {
|
|
920
|
+
await rejects(loader(`${base}0`, { signal: controller.signal }), {
|
|
921
|
+
name: "AbortError",
|
|
922
|
+
});
|
|
923
|
+
deepStrictEqual(requests, 2);
|
|
924
|
+
} finally {
|
|
925
|
+
fetchMock.hardReset();
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("getDocumentLoader() rejects cancellation before fetching", async () => {
|
|
932
|
+
fetchMock.mockGlobal();
|
|
933
|
+
let requests = 0;
|
|
934
|
+
const url = "https://example.com/pre-aborted-alternate";
|
|
935
|
+
fetchMock.get(url, () => {
|
|
936
|
+
requests++;
|
|
937
|
+
return Response.json({ done: true });
|
|
938
|
+
});
|
|
939
|
+
try {
|
|
940
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
941
|
+
const controller = new AbortController();
|
|
942
|
+
controller.abort();
|
|
943
|
+
await rejects(loader(url, { signal: controller.signal }), {
|
|
944
|
+
name: "AbortError",
|
|
945
|
+
});
|
|
946
|
+
deepStrictEqual(requests, 0);
|
|
947
|
+
} finally {
|
|
948
|
+
fetchMock.hardReset();
|
|
949
|
+
}
|
|
950
|
+
});
|
package/src/docloader.ts
CHANGED
|
@@ -274,6 +274,8 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
|
|
|
274
274
|
|
|
275
275
|
/**
|
|
276
276
|
* Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
|
|
277
|
+
* At most 20 HTTP redirects and alternate document links are followed in total
|
|
278
|
+
* per call. Revisiting a URL within that chain throws a {@link FetchError}.
|
|
277
279
|
*
|
|
278
280
|
* The created loader preloads the below frequently used contexts by default
|
|
279
281
|
* (unless `options.skipPreloadedContexts` is set to `true`):
|
|
@@ -285,6 +287,7 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
|
|
|
285
287
|
* - <https://www.w3.org/ns/cid/v1>
|
|
286
288
|
* - <https://w3id.org/security/multikey/v1>
|
|
287
289
|
* - <https://w3id.org/fep/ef61>
|
|
290
|
+
* - <https://w3id.org/fep/7aa9>
|
|
288
291
|
* - <https://purl.archive.org/socialweb/webfinger>
|
|
289
292
|
* - <http://schema.org/>
|
|
290
293
|
* @param options Options for the document loader.
|
|
@@ -320,10 +323,17 @@ export function getDocumentLoader(
|
|
|
320
323
|
await validatePublicUrl(currentUrl);
|
|
321
324
|
} catch (error) {
|
|
322
325
|
if (error instanceof UrlError) {
|
|
323
|
-
|
|
324
|
-
url
|
|
325
|
-
|
|
326
|
-
|
|
326
|
+
if (error.reason === "dns") {
|
|
327
|
+
logger.debug("DNS lookup failed for {url}", {
|
|
328
|
+
url: currentUrl,
|
|
329
|
+
error,
|
|
330
|
+
});
|
|
331
|
+
} else {
|
|
332
|
+
logger.error("Disallowed private URL: {url}", {
|
|
333
|
+
url: currentUrl,
|
|
334
|
+
error,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
327
337
|
}
|
|
328
338
|
throw error;
|
|
329
339
|
}
|
|
@@ -385,7 +395,26 @@ export function getDocumentLoader(
|
|
|
385
395
|
return await load(redirectUrl, options, redirected + 1, visited);
|
|
386
396
|
}
|
|
387
397
|
|
|
388
|
-
const result = await getRemoteDocument(
|
|
398
|
+
const result = await getRemoteDocument(
|
|
399
|
+
currentUrl,
|
|
400
|
+
response,
|
|
401
|
+
async (alternateUrl) => {
|
|
402
|
+
options?.signal?.throwIfAborted();
|
|
403
|
+
if (redirected >= DEFAULT_MAX_REDIRECTION) {
|
|
404
|
+
throw new FetchError(
|
|
405
|
+
currentUrl,
|
|
406
|
+
`Too many redirections (${redirected + 1})`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (visited.has(alternateUrl)) {
|
|
410
|
+
throw new FetchError(
|
|
411
|
+
currentUrl,
|
|
412
|
+
`Redirect loop detected: ${alternateUrl}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return await load(alternateUrl, options, redirected + 1, visited);
|
|
416
|
+
},
|
|
417
|
+
);
|
|
389
418
|
span.setAttribute("docloader.document_url", result.documentUrl);
|
|
390
419
|
if (result.contextUrl != null) {
|
|
391
420
|
span.setAttribute("docloader.context_url", result.contextUrl);
|
|
@@ -404,5 +433,5 @@ export function getDocumentLoader(
|
|
|
404
433
|
},
|
|
405
434
|
);
|
|
406
435
|
}
|
|
407
|
-
return load;
|
|
436
|
+
return (url, options) => load(url, options);
|
|
408
437
|
}
|
package/src/url.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deepStrictEqual, ok, rejects, throws } from "node:assert";
|
|
1
|
+
import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
expandIPv6Address,
|
|
@@ -9,6 +9,28 @@ import {
|
|
|
9
9
|
validatePublicUrl,
|
|
10
10
|
} from "./url.ts";
|
|
11
11
|
|
|
12
|
+
test("UrlError reason is independent of its cause", () => {
|
|
13
|
+
const cause = new Error("Underlying failure");
|
|
14
|
+
strictEqual(new UrlError("Rejected URL").reason, "disallowed");
|
|
15
|
+
const disallowedError = new UrlError("Rejected URL", { cause });
|
|
16
|
+
strictEqual(disallowedError.reason, "disallowed");
|
|
17
|
+
strictEqual(disallowedError.cause, cause);
|
|
18
|
+
const dnsError = new UrlError("Lookup failed", { reason: "dns", cause });
|
|
19
|
+
strictEqual(dnsError.reason, "dns");
|
|
20
|
+
strictEqual(dnsError.cause, cause);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("validatePublicUrl() classifies disallowed URLs", async () => {
|
|
24
|
+
for (
|
|
25
|
+
const url of ["ftp://example.com", "https://localhost", "https://127.0.0.1"]
|
|
26
|
+
) {
|
|
27
|
+
await rejects(() => validatePublicUrl(url), {
|
|
28
|
+
name: "UrlError",
|
|
29
|
+
reason: "disallowed",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
12
34
|
test("validatePublicUrl()", async () => {
|
|
13
35
|
await rejects(() => validatePublicUrl("ftp://localhost"), UrlError);
|
|
14
36
|
await rejects(
|
|
@@ -81,15 +103,19 @@ test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers
|
|
|
81
103
|
{ address: "private-host.example.net.", family: 4 },
|
|
82
104
|
{ address: "127.0.0.1", family: 4 },
|
|
83
105
|
]),
|
|
84
|
-
UrlError,
|
|
106
|
+
{ name: "UrlError", reason: "disallowed" },
|
|
85
107
|
);
|
|
86
108
|
throws(
|
|
87
109
|
() =>
|
|
88
110
|
validateLookupAddresses([
|
|
89
111
|
{ address: "app-host.example.net.", family: 4 },
|
|
90
112
|
]),
|
|
91
|
-
UrlError,
|
|
113
|
+
{ name: "UrlError", reason: "dns" },
|
|
92
114
|
);
|
|
115
|
+
throws(() => validateLookupAddresses([]), {
|
|
116
|
+
name: "UrlError",
|
|
117
|
+
reason: "dns",
|
|
118
|
+
});
|
|
93
119
|
});
|
|
94
120
|
|
|
95
121
|
test("isValidPublicIPv4Address()", () => {
|
package/src/url.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import type { LookupAddress } from "node:dns";
|
|
2
|
-
import
|
|
2
|
+
// FIXME: the default dns import exists since tests can stub `dns.lookup()`.
|
|
3
|
+
// This should be replaced by injecting the lookup function such as an internal
|
|
4
|
+
// option on `validatePublicUrl()`
|
|
5
|
+
import dns from "node:dns/promises";
|
|
3
6
|
import { isIP } from "node:net";
|
|
4
7
|
|
|
5
8
|
export class UrlError extends Error {
|
|
6
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The failure category. Defaults to `"disallowed"`.
|
|
11
|
+
* `"dns"` includes lookup errors and results with no usable IP addresses.
|
|
12
|
+
* @since 2.0.29
|
|
13
|
+
*/
|
|
14
|
+
readonly reason: "dns" | "disallowed";
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
options?: { cause?: unknown; reason?: "dns" | "disallowed" },
|
|
19
|
+
) {
|
|
7
20
|
super(message, options);
|
|
8
21
|
this.name = "UrlError";
|
|
22
|
+
this.reason = options?.reason ?? "disallowed";
|
|
9
23
|
}
|
|
10
24
|
}
|
|
11
25
|
|
|
@@ -50,9 +64,9 @@ export async function validatePublicUrl(url: string): Promise<void> {
|
|
|
50
64
|
// and ensure that they are all public:
|
|
51
65
|
let addresses: LookupAddress[];
|
|
52
66
|
try {
|
|
53
|
-
addresses = await lookup(hostname, { all: true });
|
|
67
|
+
addresses = await dns.lookup(hostname, { all: true });
|
|
54
68
|
} catch (error) {
|
|
55
|
-
throw new UrlError("DNS lookup failed", { cause: error });
|
|
69
|
+
throw new UrlError("DNS lookup failed", { cause: error, reason: "dns" });
|
|
56
70
|
}
|
|
57
71
|
validateLookupAddresses(addresses);
|
|
58
72
|
}
|
|
@@ -85,7 +99,9 @@ export function validateLookupAddresses(
|
|
|
85
99
|
validatePublicIpAddress(address, family);
|
|
86
100
|
}
|
|
87
101
|
if (ipAddressCount === 0) {
|
|
88
|
-
throw new UrlError("DNS lookup did not return any IP address"
|
|
102
|
+
throw new UrlError("DNS lookup did not return any IP address", {
|
|
103
|
+
reason: "dns",
|
|
104
|
+
});
|
|
89
105
|
}
|
|
90
106
|
}
|
|
91
107
|
|