@fedify/vocab-runtime 2.0.22 → 2.0.23-dev.1698
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 +33 -6
- package/dist/mod.d.cts +3 -1
- package/dist/mod.d.ts +3 -2
- package/dist/mod.js +33 -6
- package/dist/tests/docloader.test.cjs +2 -2
- package/dist/tests/docloader.test.mjs +2 -2
- package/dist/tests/{request-C5_wkNY2.cjs → request-CB3GJyfx.cjs} +1 -1
- package/dist/tests/{request-BCamQAi7.mjs → request-jsLl_PUT.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/{url-Cyu1RKqd.mjs → url-BulORwtj.mjs} +33 -6
- package/dist/tests/{url-CjF2c1ff.cjs → url-CqwkmczT.cjs} +38 -5
- package/dist/tests/url.test.cjs +30 -1
- package/dist/tests/url.test.mjs +31 -2
- package/package.json +1 -1
- package/src/url.test.ts +28 -1
- package/src/url.ts +35 -5
package/deno.json
CHANGED
package/dist/mod.cjs
CHANGED
|
@@ -4295,7 +4295,7 @@ const preloadedContexts = {
|
|
|
4295
4295
|
//#endregion
|
|
4296
4296
|
//#region deno.json
|
|
4297
4297
|
var name = "@fedify/vocab-runtime";
|
|
4298
|
-
var version = "2.0.
|
|
4298
|
+
var version = "2.0.23-dev.1698+43673cbc";
|
|
4299
4299
|
//#endregion
|
|
4300
4300
|
//#region src/link.ts
|
|
4301
4301
|
const parametersNeedLowerCase = ["rel", "type"];
|
|
@@ -4544,8 +4544,8 @@ function logRequest(logger, request) {
|
|
|
4544
4544
|
//#endregion
|
|
4545
4545
|
//#region src/url.ts
|
|
4546
4546
|
var UrlError = class extends Error {
|
|
4547
|
-
constructor(message) {
|
|
4548
|
-
super(message);
|
|
4547
|
+
constructor(message, options) {
|
|
4548
|
+
super(message, options);
|
|
4549
4549
|
this.name = "UrlError";
|
|
4550
4550
|
}
|
|
4551
4551
|
};
|
|
@@ -4573,10 +4573,37 @@ async function validatePublicUrl(url) {
|
|
|
4573
4573
|
let addresses;
|
|
4574
4574
|
try {
|
|
4575
4575
|
addresses = await (0, node_dns_promises.lookup)(hostname, { all: true });
|
|
4576
|
-
} catch {
|
|
4577
|
-
|
|
4576
|
+
} catch (error) {
|
|
4577
|
+
throw new UrlError("DNS lookup failed", { cause: error });
|
|
4578
4578
|
}
|
|
4579
|
-
|
|
4579
|
+
validateLookupAddresses(addresses);
|
|
4580
|
+
}
|
|
4581
|
+
/**
|
|
4582
|
+
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
4583
|
+
*
|
|
4584
|
+
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
4585
|
+
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
4586
|
+
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
4587
|
+
* contain an IPv4 or IPv6 literal. See:
|
|
4588
|
+
* https://github.com/cloudflare/workerd/issues/6886
|
|
4589
|
+
*
|
|
4590
|
+
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
4591
|
+
* returns at least one actual IP address. This remains fail-closed: a result
|
|
4592
|
+
* containing no IP addresses is rejected, and every returned IP address is
|
|
4593
|
+
* still validated and must be public. This workaround can be revisited once
|
|
4594
|
+
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
4595
|
+
*
|
|
4596
|
+
* @internal
|
|
4597
|
+
*/
|
|
4598
|
+
function validateLookupAddresses(addresses) {
|
|
4599
|
+
let ipAddressCount = 0;
|
|
4600
|
+
for (const { address } of addresses) {
|
|
4601
|
+
const family = (0, node_net.isIP)(address);
|
|
4602
|
+
if (family === 0) continue;
|
|
4603
|
+
ipAddressCount++;
|
|
4604
|
+
validatePublicIpAddress(address, family);
|
|
4605
|
+
}
|
|
4606
|
+
if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
|
|
4580
4607
|
}
|
|
4581
4608
|
function validatePublicIpAddress(address, family) {
|
|
4582
4609
|
if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
|
package/dist/mod.d.cts
CHANGED
|
@@ -318,7 +318,9 @@ declare function encodingFromBaseData(data: string | Uint8Array): Base;
|
|
|
318
318
|
//#endregion
|
|
319
319
|
//#region src/url.d.ts
|
|
320
320
|
declare class UrlError extends Error {
|
|
321
|
-
constructor(message: string
|
|
321
|
+
constructor(message: string, options?: {
|
|
322
|
+
cause?: unknown;
|
|
323
|
+
});
|
|
322
324
|
}
|
|
323
325
|
/**
|
|
324
326
|
* Validates a URL to prevent SSRF attacks.
|
package/dist/mod.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/// <reference lib="esnext.temporal" />
|
|
2
2
|
import { Logger } from "@logtape/logtape";
|
|
3
|
-
|
|
4
3
|
//#region src/contexts.d.ts
|
|
5
4
|
declare const preloadedContexts: Record<string, unknown>;
|
|
6
5
|
//#endregion
|
|
@@ -318,7 +317,9 @@ declare function encodingFromBaseData(data: string | Uint8Array): Base;
|
|
|
318
317
|
//#endregion
|
|
319
318
|
//#region src/url.d.ts
|
|
320
319
|
declare class UrlError extends Error {
|
|
321
|
-
constructor(message: string
|
|
320
|
+
constructor(message: string, options?: {
|
|
321
|
+
cause?: unknown;
|
|
322
|
+
});
|
|
322
323
|
}
|
|
323
324
|
/**
|
|
324
325
|
* Validates a URL to prevent SSRF attacks.
|
package/dist/mod.js
CHANGED
|
@@ -4291,7 +4291,7 @@ const preloadedContexts = {
|
|
|
4291
4291
|
//#endregion
|
|
4292
4292
|
//#region deno.json
|
|
4293
4293
|
var name = "@fedify/vocab-runtime";
|
|
4294
|
-
var version = "2.0.
|
|
4294
|
+
var version = "2.0.23-dev.1698+43673cbc";
|
|
4295
4295
|
//#endregion
|
|
4296
4296
|
//#region src/link.ts
|
|
4297
4297
|
const parametersNeedLowerCase = ["rel", "type"];
|
|
@@ -4540,8 +4540,8 @@ function logRequest(logger, request) {
|
|
|
4540
4540
|
//#endregion
|
|
4541
4541
|
//#region src/url.ts
|
|
4542
4542
|
var UrlError = class extends Error {
|
|
4543
|
-
constructor(message) {
|
|
4544
|
-
super(message);
|
|
4543
|
+
constructor(message, options) {
|
|
4544
|
+
super(message, options);
|
|
4545
4545
|
this.name = "UrlError";
|
|
4546
4546
|
}
|
|
4547
4547
|
};
|
|
@@ -4569,10 +4569,37 @@ async function validatePublicUrl(url) {
|
|
|
4569
4569
|
let addresses;
|
|
4570
4570
|
try {
|
|
4571
4571
|
addresses = await lookup(hostname, { all: true });
|
|
4572
|
-
} catch {
|
|
4573
|
-
|
|
4572
|
+
} catch (error) {
|
|
4573
|
+
throw new UrlError("DNS lookup failed", { cause: error });
|
|
4574
4574
|
}
|
|
4575
|
-
|
|
4575
|
+
validateLookupAddresses(addresses);
|
|
4576
|
+
}
|
|
4577
|
+
/**
|
|
4578
|
+
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
4579
|
+
*
|
|
4580
|
+
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
4581
|
+
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
4582
|
+
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
4583
|
+
* contain an IPv4 or IPv6 literal. See:
|
|
4584
|
+
* https://github.com/cloudflare/workerd/issues/6886
|
|
4585
|
+
*
|
|
4586
|
+
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
4587
|
+
* returns at least one actual IP address. This remains fail-closed: a result
|
|
4588
|
+
* containing no IP addresses is rejected, and every returned IP address is
|
|
4589
|
+
* still validated and must be public. This workaround can be revisited once
|
|
4590
|
+
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
4591
|
+
*
|
|
4592
|
+
* @internal
|
|
4593
|
+
*/
|
|
4594
|
+
function validateLookupAddresses(addresses) {
|
|
4595
|
+
let ipAddressCount = 0;
|
|
4596
|
+
for (const { address } of addresses) {
|
|
4597
|
+
const family = isIP(address);
|
|
4598
|
+
if (family === 0) continue;
|
|
4599
|
+
ipAddressCount++;
|
|
4600
|
+
validatePublicIpAddress(address, family);
|
|
4601
|
+
}
|
|
4602
|
+
if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
|
|
4576
4603
|
}
|
|
4577
4604
|
function validatePublicIpAddress(address, family) {
|
|
4578
4605
|
if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const require_chunk = require("./chunk-Do9eywBl.cjs");
|
|
2
|
-
const require_request = require("./request-
|
|
2
|
+
const require_request = require("./request-CB3GJyfx.cjs");
|
|
3
3
|
const require_link = require("./link-B6ZWBZhf.cjs");
|
|
4
|
-
const require_url = require("./url-
|
|
4
|
+
const require_url = require("./url-CqwkmczT.cjs");
|
|
5
5
|
let node_assert = require("node:assert");
|
|
6
6
|
let node_test = require("node:test");
|
|
7
7
|
let _logtape_logtape = require("@logtape/logtape");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-
|
|
1
|
+
import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-jsLl_PUT.mjs";
|
|
2
2
|
import { t as HttpHeaderLink } from "./link-B8JGXSS2.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { o as validatePublicUrl, t as UrlError } from "./url-BulORwtj.mjs";
|
|
4
4
|
import { deepStrictEqual, ok, rejects } from "node:assert";
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
import { getLogger } from "@logtape/logtape";
|
|
@@ -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.0.
|
|
6
|
+
var version = "2.0.23-dev.1698+43673cbc";
|
|
7
7
|
//#endregion
|
|
8
8
|
//#region src/request.ts
|
|
9
9
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const require_chunk = require("./chunk-Do9eywBl.cjs");
|
|
2
|
-
const require_request = require("./request-
|
|
2
|
+
const require_request = require("./request-CB3GJyfx.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-
|
|
1
|
+
import { o as version, r as getUserAgent } from "./request-jsLl_PUT.mjs";
|
|
2
2
|
import { deepStrictEqual } from "node:assert";
|
|
3
3
|
import { test } from "node:test";
|
|
4
4
|
import process from "node:process";
|
|
@@ -2,8 +2,8 @@ import { lookup } from "node:dns/promises";
|
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
3
|
//#region src/url.ts
|
|
4
4
|
var UrlError = class extends Error {
|
|
5
|
-
constructor(message) {
|
|
6
|
-
super(message);
|
|
5
|
+
constructor(message, options) {
|
|
6
|
+
super(message, options);
|
|
7
7
|
this.name = "UrlError";
|
|
8
8
|
}
|
|
9
9
|
};
|
|
@@ -31,10 +31,37 @@ async function validatePublicUrl(url) {
|
|
|
31
31
|
let addresses;
|
|
32
32
|
try {
|
|
33
33
|
addresses = await lookup(hostname, { all: true });
|
|
34
|
-
} catch {
|
|
35
|
-
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new UrlError("DNS lookup failed", { cause: error });
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
validateLookupAddresses(addresses);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
41
|
+
*
|
|
42
|
+
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
43
|
+
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
44
|
+
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
45
|
+
* contain an IPv4 or IPv6 literal. See:
|
|
46
|
+
* https://github.com/cloudflare/workerd/issues/6886
|
|
47
|
+
*
|
|
48
|
+
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
49
|
+
* returns at least one actual IP address. This remains fail-closed: a result
|
|
50
|
+
* containing no IP addresses is rejected, and every returned IP address is
|
|
51
|
+
* still validated and must be public. This workaround can be revisited once
|
|
52
|
+
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
function validateLookupAddresses(addresses) {
|
|
57
|
+
let ipAddressCount = 0;
|
|
58
|
+
for (const { address } of addresses) {
|
|
59
|
+
const family = isIP(address);
|
|
60
|
+
if (family === 0) continue;
|
|
61
|
+
ipAddressCount++;
|
|
62
|
+
validatePublicIpAddress(address, family);
|
|
63
|
+
}
|
|
64
|
+
if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
|
|
38
65
|
}
|
|
39
66
|
function validatePublicIpAddress(address, family) {
|
|
40
67
|
if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
|
|
@@ -173,4 +200,4 @@ function matchesIPv6Prefix(address, prefixWords, prefixLength) {
|
|
|
173
200
|
return true;
|
|
174
201
|
}
|
|
175
202
|
//#endregion
|
|
176
|
-
export {
|
|
203
|
+
export { validateLookupAddresses as a, isValidPublicIPv6Address as i, expandIPv6Address as n, validatePublicUrl as o, isValidPublicIPv4Address as r, UrlError as t };
|
|
@@ -3,8 +3,8 @@ let node_dns_promises = require("node:dns/promises");
|
|
|
3
3
|
let node_net = require("node:net");
|
|
4
4
|
//#region src/url.ts
|
|
5
5
|
var UrlError = class extends Error {
|
|
6
|
-
constructor(message) {
|
|
7
|
-
super(message);
|
|
6
|
+
constructor(message, options) {
|
|
7
|
+
super(message, options);
|
|
8
8
|
this.name = "UrlError";
|
|
9
9
|
}
|
|
10
10
|
};
|
|
@@ -32,10 +32,37 @@ async function validatePublicUrl(url) {
|
|
|
32
32
|
let addresses;
|
|
33
33
|
try {
|
|
34
34
|
addresses = await (0, node_dns_promises.lookup)(hostname, { all: true });
|
|
35
|
-
} catch {
|
|
36
|
-
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw new UrlError("DNS lookup failed", { cause: error });
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
validateLookupAddresses(addresses);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
42
|
+
*
|
|
43
|
+
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
44
|
+
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
45
|
+
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
46
|
+
* contain an IPv4 or IPv6 literal. See:
|
|
47
|
+
* https://github.com/cloudflare/workerd/issues/6886
|
|
48
|
+
*
|
|
49
|
+
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
50
|
+
* returns at least one actual IP address. This remains fail-closed: a result
|
|
51
|
+
* containing no IP addresses is rejected, and every returned IP address is
|
|
52
|
+
* still validated and must be public. This workaround can be revisited once
|
|
53
|
+
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
54
|
+
*
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
function validateLookupAddresses(addresses) {
|
|
58
|
+
let ipAddressCount = 0;
|
|
59
|
+
for (const { address } of addresses) {
|
|
60
|
+
const family = (0, node_net.isIP)(address);
|
|
61
|
+
if (family === 0) continue;
|
|
62
|
+
ipAddressCount++;
|
|
63
|
+
validatePublicIpAddress(address, family);
|
|
64
|
+
}
|
|
65
|
+
if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address");
|
|
39
66
|
}
|
|
40
67
|
function validatePublicIpAddress(address, family) {
|
|
41
68
|
if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
|
|
@@ -198,6 +225,12 @@ Object.defineProperty(exports, "isValidPublicIPv6Address", {
|
|
|
198
225
|
return isValidPublicIPv6Address;
|
|
199
226
|
}
|
|
200
227
|
});
|
|
228
|
+
Object.defineProperty(exports, "validateLookupAddresses", {
|
|
229
|
+
enumerable: true,
|
|
230
|
+
get: function() {
|
|
231
|
+
return validateLookupAddresses;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
201
234
|
Object.defineProperty(exports, "validatePublicUrl", {
|
|
202
235
|
enumerable: true,
|
|
203
236
|
get: function() {
|
package/dist/tests/url.test.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
require("./chunk-Do9eywBl.cjs");
|
|
2
|
-
const require_url = require("./url-
|
|
2
|
+
const require_url = require("./url-CqwkmczT.cjs");
|
|
3
3
|
let node_assert = require("node:assert");
|
|
4
4
|
let node_test = require("node:test");
|
|
5
5
|
//#region src/url.test.ts
|
|
@@ -29,6 +29,35 @@ let node_test = require("node:test");
|
|
|
29
29
|
await require_url.validatePublicUrl("https://[2001:db8::1]");
|
|
30
30
|
await require_url.validatePublicUrl("https://[64:ff9b::8.8.8.8]");
|
|
31
31
|
});
|
|
32
|
+
(0, node_test.test)("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
|
|
33
|
+
require_url.validateLookupAddresses([
|
|
34
|
+
{
|
|
35
|
+
address: "app-host.example.net.",
|
|
36
|
+
family: 4
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
address: "93.184.216.34",
|
|
40
|
+
family: 4
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
address: "2606:2800:220:1:248:1893:25c8:1946",
|
|
44
|
+
family: 6
|
|
45
|
+
}
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
(0, node_test.test)("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => {
|
|
49
|
+
(0, node_assert.throws)(() => require_url.validateLookupAddresses([{
|
|
50
|
+
address: "private-host.example.net.",
|
|
51
|
+
family: 4
|
|
52
|
+
}, {
|
|
53
|
+
address: "127.0.0.1",
|
|
54
|
+
family: 4
|
|
55
|
+
}]), require_url.UrlError);
|
|
56
|
+
(0, node_assert.throws)(() => require_url.validateLookupAddresses([{
|
|
57
|
+
address: "app-host.example.net.",
|
|
58
|
+
family: 4
|
|
59
|
+
}]), require_url.UrlError);
|
|
60
|
+
});
|
|
32
61
|
(0, node_test.test)("isValidPublicIPv4Address()", () => {
|
|
33
62
|
(0, node_assert.ok)(require_url.isValidPublicIPv4Address("8.8.8.8"));
|
|
34
63
|
(0, node_assert.ok)(!require_url.isValidPublicIPv4Address("192.168.1.1"));
|
package/dist/tests/url.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { deepStrictEqual, ok, rejects } from "node:assert";
|
|
1
|
+
import { a as validateLookupAddresses, i as isValidPublicIPv6Address, n as expandIPv6Address, o as validatePublicUrl, r as isValidPublicIPv4Address, t as UrlError } from "./url-BulORwtj.mjs";
|
|
2
|
+
import { deepStrictEqual, ok, rejects, throws } from "node:assert";
|
|
3
3
|
import { test } from "node:test";
|
|
4
4
|
//#region src/url.test.ts
|
|
5
5
|
test("validatePublicUrl()", async () => {
|
|
@@ -28,6 +28,35 @@ test("validatePublicUrl()", async () => {
|
|
|
28
28
|
await validatePublicUrl("https://[2001:db8::1]");
|
|
29
29
|
await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
|
|
30
30
|
});
|
|
31
|
+
test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
|
|
32
|
+
validateLookupAddresses([
|
|
33
|
+
{
|
|
34
|
+
address: "app-host.example.net.",
|
|
35
|
+
family: 4
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
address: "93.184.216.34",
|
|
39
|
+
family: 4
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
address: "2606:2800:220:1:248:1893:25c8:1946",
|
|
43
|
+
family: 6
|
|
44
|
+
}
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => {
|
|
48
|
+
throws(() => validateLookupAddresses([{
|
|
49
|
+
address: "private-host.example.net.",
|
|
50
|
+
family: 4
|
|
51
|
+
}, {
|
|
52
|
+
address: "127.0.0.1",
|
|
53
|
+
family: 4
|
|
54
|
+
}]), UrlError);
|
|
55
|
+
throws(() => validateLookupAddresses([{
|
|
56
|
+
address: "app-host.example.net.",
|
|
57
|
+
family: 4
|
|
58
|
+
}]), UrlError);
|
|
59
|
+
});
|
|
31
60
|
test("isValidPublicIPv4Address()", () => {
|
|
32
61
|
ok(isValidPublicIPv4Address("8.8.8.8"));
|
|
33
62
|
ok(!isValidPublicIPv4Address("192.168.1.1"));
|
package/package.json
CHANGED
package/src/url.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { deepStrictEqual, ok, rejects } from "node:assert";
|
|
1
|
+
import { deepStrictEqual, ok, rejects, throws } from "node:assert";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
expandIPv6Address,
|
|
5
5
|
isValidPublicIPv4Address,
|
|
6
6
|
isValidPublicIPv6Address,
|
|
7
7
|
UrlError,
|
|
8
|
+
validateLookupAddresses,
|
|
8
9
|
validatePublicUrl,
|
|
9
10
|
} from "./url.ts";
|
|
10
11
|
|
|
@@ -65,6 +66,32 @@ test("validatePublicUrl()", async () => {
|
|
|
65
66
|
await validatePublicUrl("https://[64:ff9b::8.8.8.8]");
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
test("validateLookupAddresses() tolerates Cloudflare Workers CNAME entries", () => {
|
|
70
|
+
validateLookupAddresses([
|
|
71
|
+
{ address: "app-host.example.net.", family: 4 },
|
|
72
|
+
{ address: "93.184.216.34", family: 4 },
|
|
73
|
+
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("validateLookupAddresses() rejects unsafe or CNAME-only Cloudflare Workers results", () => {
|
|
78
|
+
throws(
|
|
79
|
+
() =>
|
|
80
|
+
validateLookupAddresses([
|
|
81
|
+
{ address: "private-host.example.net.", family: 4 },
|
|
82
|
+
{ address: "127.0.0.1", family: 4 },
|
|
83
|
+
]),
|
|
84
|
+
UrlError,
|
|
85
|
+
);
|
|
86
|
+
throws(
|
|
87
|
+
() =>
|
|
88
|
+
validateLookupAddresses([
|
|
89
|
+
{ address: "app-host.example.net.", family: 4 },
|
|
90
|
+
]),
|
|
91
|
+
UrlError,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
68
95
|
test("isValidPublicIPv4Address()", () => {
|
|
69
96
|
ok(isValidPublicIPv4Address("8.8.8.8")); // Google DNS
|
|
70
97
|
ok(!isValidPublicIPv4Address("192.168.1.1")); // private
|
package/src/url.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { lookup } from "node:dns/promises";
|
|
|
3
3
|
import { isIP } from "node:net";
|
|
4
4
|
|
|
5
5
|
export class UrlError extends Error {
|
|
6
|
-
constructor(message: string) {
|
|
7
|
-
super(message);
|
|
6
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
7
|
+
super(message, options);
|
|
8
8
|
this.name = "UrlError";
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -51,12 +51,42 @@ export async function validatePublicUrl(url: string): Promise<void> {
|
|
|
51
51
|
let addresses: LookupAddress[];
|
|
52
52
|
try {
|
|
53
53
|
addresses = await lookup(hostname, { all: true });
|
|
54
|
-
} catch {
|
|
55
|
-
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new UrlError("DNS lookup failed", { cause: error });
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
validateLookupAddresses(addresses);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validates the IP addresses returned by `node:dns.lookup()`.
|
|
62
|
+
*
|
|
63
|
+
* Cloudflare Workers' `node:dns` implementation currently maps every record
|
|
64
|
+
* in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
|
|
65
|
+
* `LookupAddress`, even though Node.js specifies that the `address` field must
|
|
66
|
+
* contain an IPv4 or IPv6 literal. See:
|
|
67
|
+
* https://github.com/cloudflare/workerd/issues/6886
|
|
68
|
+
*
|
|
69
|
+
* Work around that bug by ignoring non-IP entries only when the lookup also
|
|
70
|
+
* returns at least one actual IP address. This remains fail-closed: a result
|
|
71
|
+
* containing no IP addresses is rejected, and every returned IP address is
|
|
72
|
+
* still validated and must be public. This workaround can be revisited once
|
|
73
|
+
* the Workerd issue is fixed in supported Cloudflare Workers runtimes.
|
|
74
|
+
*
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
77
|
+
export function validateLookupAddresses(
|
|
78
|
+
addresses: readonly LookupAddress[],
|
|
79
|
+
): void {
|
|
80
|
+
let ipAddressCount = 0;
|
|
81
|
+
for (const { address } of addresses) {
|
|
82
|
+
const family = isIP(address);
|
|
83
|
+
if (family === 0) continue;
|
|
84
|
+
ipAddressCount++;
|
|
58
85
|
validatePublicIpAddress(address, family);
|
|
59
86
|
}
|
|
87
|
+
if (ipAddressCount === 0) {
|
|
88
|
+
throw new UrlError("DNS lookup did not return any IP address");
|
|
89
|
+
}
|
|
60
90
|
}
|
|
61
91
|
|
|
62
92
|
function validatePublicIpAddress(address: string, family: number): void {
|