@peac/protocol 0.16.4 → 0.16.5
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/dist/_internal/address-policy.d.ts +28 -0
- package/dist/_internal/address-policy.d.ts.map +1 -0
- package/dist/index.cjs +334 -90
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +334 -90
- package/dist/index.mjs.map +1 -1
- package/dist/jwks-resolver.d.ts +1 -1
- package/dist/jwks-resolver.d.ts.map +1 -1
- package/dist/ssrf-safe-fetch.d.ts +18 -11
- package/dist/ssrf-safe-fetch.d.ts.map +1 -1
- package/dist/verifier-core.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Address policy for outbound key and pointer fetches.
|
|
3
|
+
*
|
|
4
|
+
* An address is allowed only when it is a global unicast address outside every
|
|
5
|
+
* block in the IANA IPv4 and IPv6 Special-Purpose Address Registries (RFC 6890
|
|
6
|
+
* and updates). IPv4-mapped IPv6 addresses (::ffff:0:0/96) and NAT64
|
|
7
|
+
* well-known prefix addresses (64:ff9b::/96) are judged by the embedded IPv4
|
|
8
|
+
* address. Anything that does not parse as an address is refused.
|
|
9
|
+
*
|
|
10
|
+
* Pure function: no I/O, runtime-neutral.
|
|
11
|
+
*/
|
|
12
|
+
export type AddressBlockReason = 'private_ip' | 'loopback' | 'link_local';
|
|
13
|
+
export type AddressVerdict = {
|
|
14
|
+
allowed: true;
|
|
15
|
+
} | {
|
|
16
|
+
allowed: false;
|
|
17
|
+
reason: AddressBlockReason;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Classify an IP address string against the address policy.
|
|
21
|
+
*/
|
|
22
|
+
export declare function classifyAddress(ip: string): AddressVerdict;
|
|
23
|
+
/**
|
|
24
|
+
* Return the IP literal carried by a URL hostname (brackets removed), or null
|
|
25
|
+
* for a domain name. WHATWG URL parsing has already normalised IPv4 forms.
|
|
26
|
+
*/
|
|
27
|
+
export declare function hostIpLiteral(hostname: string): string | null;
|
|
28
|
+
//# sourceMappingURL=address-policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"address-policy.d.ts","sourceRoot":"","sources":["../../src/_internal/address-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,kBAAkB,GAAG,YAAY,GAAG,UAAU,GAAG,YAAY,CAAC;AAE1E,MAAM,MAAM,cAAc,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,kBAAkB,CAAA;CAAE,CAAC;AAmJhG;;GAEG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,cAAc,CAM1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI7D"}
|
package/dist/index.cjs
CHANGED
|
@@ -1530,6 +1530,138 @@ async function fetchDiscovery(issuerUrl) {
|
|
|
1530
1530
|
);
|
|
1531
1531
|
}
|
|
1532
1532
|
}
|
|
1533
|
+
|
|
1534
|
+
// src/_internal/address-policy.ts
|
|
1535
|
+
var IPV4_OCTET = /^(?:0|[1-9]\d{0,2})$/;
|
|
1536
|
+
var IPV6_GROUP = /^[0-9a-fA-F]{1,4}$/;
|
|
1537
|
+
function parseIPv4(text) {
|
|
1538
|
+
const parts = text.split(".");
|
|
1539
|
+
if (parts.length !== 4) return null;
|
|
1540
|
+
let value = 0;
|
|
1541
|
+
for (const part of parts) {
|
|
1542
|
+
if (!IPV4_OCTET.test(part)) return null;
|
|
1543
|
+
const octet = Number(part);
|
|
1544
|
+
if (octet > 255) return null;
|
|
1545
|
+
value = value * 256 + octet;
|
|
1546
|
+
}
|
|
1547
|
+
return value;
|
|
1548
|
+
}
|
|
1549
|
+
function parseGroups(text) {
|
|
1550
|
+
if (text === "") return [];
|
|
1551
|
+
const groups = [];
|
|
1552
|
+
for (const group of text.split(":")) {
|
|
1553
|
+
if (!IPV6_GROUP.test(group)) return null;
|
|
1554
|
+
groups.push(parseInt(group, 16));
|
|
1555
|
+
}
|
|
1556
|
+
return groups;
|
|
1557
|
+
}
|
|
1558
|
+
function parseIPv6(text) {
|
|
1559
|
+
if (text.length === 0 || text.length > 45) return null;
|
|
1560
|
+
let body = text;
|
|
1561
|
+
const lastColon = body.lastIndexOf(":");
|
|
1562
|
+
if (lastColon === -1) return null;
|
|
1563
|
+
const last = body.slice(lastColon + 1);
|
|
1564
|
+
if (last.includes(".")) {
|
|
1565
|
+
const v4 = parseIPv4(last);
|
|
1566
|
+
if (v4 === null) return null;
|
|
1567
|
+
body = `${body.slice(0, lastColon + 1)}${Math.floor(v4 / 65536).toString(16)}:${(v4 % 65536).toString(16)}`;
|
|
1568
|
+
}
|
|
1569
|
+
const halves = body.split("::");
|
|
1570
|
+
if (halves.length > 2) return null;
|
|
1571
|
+
const head = parseGroups(halves[0]);
|
|
1572
|
+
if (head === null) return null;
|
|
1573
|
+
if (halves.length === 1) return head.length === 8 ? head : null;
|
|
1574
|
+
const tail = parseGroups(halves[1]);
|
|
1575
|
+
if (tail === null) return null;
|
|
1576
|
+
const fill = 8 - head.length - tail.length;
|
|
1577
|
+
if (fill < 1) return null;
|
|
1578
|
+
return [...head, ...new Array(fill).fill(0), ...tail];
|
|
1579
|
+
}
|
|
1580
|
+
var IPV4_BLOCKS = [
|
|
1581
|
+
["0.0.0.0", 8, "private_ip"],
|
|
1582
|
+
["10.0.0.0", 8, "private_ip"],
|
|
1583
|
+
["100.64.0.0", 10, "private_ip"],
|
|
1584
|
+
["127.0.0.0", 8, "loopback"],
|
|
1585
|
+
["169.254.0.0", 16, "link_local"],
|
|
1586
|
+
["172.16.0.0", 12, "private_ip"],
|
|
1587
|
+
["192.0.0.0", 24, "private_ip"],
|
|
1588
|
+
["192.0.2.0", 24, "private_ip"],
|
|
1589
|
+
["192.31.196.0", 24, "private_ip"],
|
|
1590
|
+
["192.52.193.0", 24, "private_ip"],
|
|
1591
|
+
["192.88.99.0", 24, "private_ip"],
|
|
1592
|
+
["192.168.0.0", 16, "private_ip"],
|
|
1593
|
+
["192.175.48.0", 24, "private_ip"],
|
|
1594
|
+
["198.18.0.0", 15, "private_ip"],
|
|
1595
|
+
["198.51.100.0", 24, "private_ip"],
|
|
1596
|
+
["203.0.113.0", 24, "private_ip"],
|
|
1597
|
+
["224.0.0.0", 4, "private_ip"],
|
|
1598
|
+
["240.0.0.0", 4, "private_ip"]
|
|
1599
|
+
];
|
|
1600
|
+
var IPV4_BLOCK_TABLE = IPV4_BLOCKS.map(([network, prefix, reason]) => {
|
|
1601
|
+
const base = parseIPv4(network);
|
|
1602
|
+
const size = 2 ** (32 - prefix);
|
|
1603
|
+
return { start: base, end: base + size - 1, reason };
|
|
1604
|
+
});
|
|
1605
|
+
function classifyIPv4(value) {
|
|
1606
|
+
for (const block of IPV4_BLOCK_TABLE) {
|
|
1607
|
+
if (value >= block.start && value <= block.end) {
|
|
1608
|
+
return { allowed: false, reason: block.reason };
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return { allowed: true };
|
|
1612
|
+
}
|
|
1613
|
+
function matchesPrefix(groups, network, bits) {
|
|
1614
|
+
for (let i = 0; i < 8; i++) {
|
|
1615
|
+
const bitsHere = Math.min(16, Math.max(0, bits - 16 * i));
|
|
1616
|
+
if (bitsHere === 0) return true;
|
|
1617
|
+
const mask = 65535 << 16 - bitsHere & 65535;
|
|
1618
|
+
if ((groups[i] & mask) !== (network[i] & mask)) return false;
|
|
1619
|
+
}
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
function v6(text) {
|
|
1623
|
+
return parseIPv6(text);
|
|
1624
|
+
}
|
|
1625
|
+
var GLOBAL_UNICAST_V6 = v6("2000::");
|
|
1626
|
+
var IPV6_BLOCKS_IN_GLOBAL = [
|
|
1627
|
+
[v6("2001::"), 23],
|
|
1628
|
+
[v6("2001:db8::"), 32],
|
|
1629
|
+
[v6("2002::"), 16],
|
|
1630
|
+
[v6("2620:4f:8000::"), 48],
|
|
1631
|
+
[v6("3fff::"), 20]
|
|
1632
|
+
];
|
|
1633
|
+
var IPV6_LOOPBACK = v6("::1");
|
|
1634
|
+
var IPV6_LINK_LOCAL = v6("fe80::");
|
|
1635
|
+
var IPV4_MAPPED = v6("::ffff:0:0");
|
|
1636
|
+
var NAT64_WELL_KNOWN = v6("64:ff9b::");
|
|
1637
|
+
function classifyIPv6(groups) {
|
|
1638
|
+
if (matchesPrefix(groups, IPV4_MAPPED, 96) || matchesPrefix(groups, NAT64_WELL_KNOWN, 96)) {
|
|
1639
|
+
return classifyIPv4(groups[6] * 65536 + groups[7]);
|
|
1640
|
+
}
|
|
1641
|
+
if (!matchesPrefix(groups, GLOBAL_UNICAST_V6, 3)) {
|
|
1642
|
+
if (matchesPrefix(groups, IPV6_LOOPBACK, 128)) return { allowed: false, reason: "loopback" };
|
|
1643
|
+
if (matchesPrefix(groups, IPV6_LINK_LOCAL, 10)) return { allowed: false, reason: "link_local" };
|
|
1644
|
+
return { allowed: false, reason: "private_ip" };
|
|
1645
|
+
}
|
|
1646
|
+
for (const [network, bits] of IPV6_BLOCKS_IN_GLOBAL) {
|
|
1647
|
+
if (matchesPrefix(groups, network, bits)) return { allowed: false, reason: "private_ip" };
|
|
1648
|
+
}
|
|
1649
|
+
return { allowed: true };
|
|
1650
|
+
}
|
|
1651
|
+
function classifyAddress(ip) {
|
|
1652
|
+
const v4 = parseIPv4(ip);
|
|
1653
|
+
if (v4 !== null) return classifyIPv4(v4);
|
|
1654
|
+
const groups = parseIPv6(ip);
|
|
1655
|
+
if (groups !== null) return classifyIPv6(groups);
|
|
1656
|
+
return { allowed: false, reason: "private_ip" };
|
|
1657
|
+
}
|
|
1658
|
+
function hostIpLiteral(hostname) {
|
|
1659
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return hostname.slice(1, -1);
|
|
1660
|
+
if (/^[0-9.]+$/.test(hostname)) return hostname;
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// src/ssrf-safe-fetch.ts
|
|
1533
1665
|
var cachedCapabilities = null;
|
|
1534
1666
|
function getSSRFCapabilities() {
|
|
1535
1667
|
if (cachedCapabilities) {
|
|
@@ -1547,9 +1679,9 @@ function detectCapabilities() {
|
|
|
1547
1679
|
networkIsolation: false,
|
|
1548
1680
|
protectionLevel: "full",
|
|
1549
1681
|
notes: [
|
|
1550
|
-
"
|
|
1551
|
-
"
|
|
1552
|
-
"
|
|
1682
|
+
"Hostnames are resolved once via the Node.js dns module",
|
|
1683
|
+
"Every resolved address must be a global unicast address",
|
|
1684
|
+
"The connection is made only to a vetted address"
|
|
1553
1685
|
]
|
|
1554
1686
|
};
|
|
1555
1687
|
}
|
|
@@ -1627,61 +1759,16 @@ function detectCapabilities() {
|
|
|
1627
1759
|
function resetSSRFCapabilitiesCache() {
|
|
1628
1760
|
cachedCapabilities = null;
|
|
1629
1761
|
}
|
|
1630
|
-
function parseIPv4(ip) {
|
|
1631
|
-
const parts = ip.split(".");
|
|
1632
|
-
if (parts.length !== 4) return null;
|
|
1633
|
-
const octets = [];
|
|
1634
|
-
for (const part of parts) {
|
|
1635
|
-
const num = parseInt(part, 10);
|
|
1636
|
-
if (isNaN(num) || num < 0 || num > 255) return null;
|
|
1637
|
-
octets.push(num);
|
|
1638
|
-
}
|
|
1639
|
-
return { octets };
|
|
1640
|
-
}
|
|
1641
|
-
function isInCIDR(ip, cidr) {
|
|
1642
|
-
const [rangeStr, maskStr] = cidr.split("/");
|
|
1643
|
-
const range = parseIPv4(rangeStr);
|
|
1644
|
-
if (!range) return false;
|
|
1645
|
-
const maskBits = parseInt(maskStr, 10);
|
|
1646
|
-
if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
|
|
1647
|
-
const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
|
|
1648
|
-
const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
|
|
1649
|
-
const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
|
|
1650
|
-
return (ipNum & mask) === (rangeNum & mask);
|
|
1651
|
-
}
|
|
1652
|
-
function isIPv6Loopback(ip) {
|
|
1653
|
-
const normalized = ip.toLowerCase().replace(/^::ffff:/, "");
|
|
1654
|
-
return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
|
|
1655
|
-
}
|
|
1656
|
-
function isIPv6LinkLocal(ip) {
|
|
1657
|
-
const normalized = ip.toLowerCase();
|
|
1658
|
-
return normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
|
1659
|
-
}
|
|
1660
1762
|
function isBlockedIP(ip) {
|
|
1661
|
-
const
|
|
1662
|
-
|
|
1663
|
-
const ipv4 = parseIPv4(effectiveIP);
|
|
1664
|
-
if (ipv4) {
|
|
1665
|
-
if (isInCIDR(ipv4, "10.0.0.0/8") || isInCIDR(ipv4, "172.16.0.0/12") || isInCIDR(ipv4, "192.168.0.0/16")) {
|
|
1666
|
-
return { blocked: true, reason: "private_ip" };
|
|
1667
|
-
}
|
|
1668
|
-
if (isInCIDR(ipv4, "127.0.0.0/8")) {
|
|
1669
|
-
return { blocked: true, reason: "loopback" };
|
|
1670
|
-
}
|
|
1671
|
-
if (isInCIDR(ipv4, "169.254.0.0/16")) {
|
|
1672
|
-
return { blocked: true, reason: "link_local" };
|
|
1673
|
-
}
|
|
1674
|
-
return { blocked: false };
|
|
1675
|
-
}
|
|
1676
|
-
if (isIPv6Loopback(ip)) {
|
|
1677
|
-
return { blocked: true, reason: "loopback" };
|
|
1678
|
-
}
|
|
1679
|
-
if (isIPv6LinkLocal(ip)) {
|
|
1680
|
-
return { blocked: true, reason: "link_local" };
|
|
1681
|
-
}
|
|
1682
|
-
return { blocked: false };
|
|
1763
|
+
const verdict = classifyAddress(ip);
|
|
1764
|
+
return verdict.allowed ? { blocked: false } : { blocked: true, reason: verdict.reason };
|
|
1683
1765
|
}
|
|
1684
1766
|
async function resolveHostname(hostname) {
|
|
1767
|
+
const literal = hostIpLiteral(hostname);
|
|
1768
|
+
if (literal !== null) {
|
|
1769
|
+
const nodeRuntime = typeof process !== "undefined" && Boolean(process.versions?.node);
|
|
1770
|
+
return { ok: true, ips: [literal], browser: !nodeRuntime };
|
|
1771
|
+
}
|
|
1685
1772
|
if (typeof process !== "undefined" && process.versions?.node) {
|
|
1686
1773
|
try {
|
|
1687
1774
|
const dns = await import('dns');
|
|
@@ -1712,7 +1799,10 @@ async function resolveHostname(hostname) {
|
|
|
1712
1799
|
message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
|
|
1713
1800
|
};
|
|
1714
1801
|
}
|
|
1715
|
-
return {
|
|
1802
|
+
return {
|
|
1803
|
+
ok: false,
|
|
1804
|
+
message: `DNS resolution returned no addresses for ${hostname}`
|
|
1805
|
+
};
|
|
1716
1806
|
} catch (err) {
|
|
1717
1807
|
return {
|
|
1718
1808
|
ok: false,
|
|
@@ -1722,6 +1812,88 @@ async function resolveHostname(hostname) {
|
|
|
1722
1812
|
}
|
|
1723
1813
|
return { ok: true, ips: [], browser: true };
|
|
1724
1814
|
}
|
|
1815
|
+
var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
1816
|
+
async function discardBody(response) {
|
|
1817
|
+
await response.body?.cancel();
|
|
1818
|
+
}
|
|
1819
|
+
async function fetchFromVettedAddresses(url, addresses, headers, signal) {
|
|
1820
|
+
const https = await import('https');
|
|
1821
|
+
const { isIP } = await import('net');
|
|
1822
|
+
const target = new URL(url);
|
|
1823
|
+
const host = hostIpLiteral(target.hostname) ?? target.hostname;
|
|
1824
|
+
if (addresses.length === 0 || isIP(host) !== 0 && !addresses.includes(host)) {
|
|
1825
|
+
throw new Error(`Refusing to connect to an address that was not vetted: ${host}`);
|
|
1826
|
+
}
|
|
1827
|
+
const entries = addresses.map((address) => ({
|
|
1828
|
+
address,
|
|
1829
|
+
family: address.includes(":") ? 6 : 4
|
|
1830
|
+
}));
|
|
1831
|
+
const lookup = (_hostname, options, callback) => {
|
|
1832
|
+
if (options.all) {
|
|
1833
|
+
callback(null, entries);
|
|
1834
|
+
} else {
|
|
1835
|
+
callback(null, entries[0].address, entries[0].family);
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
return new Promise((resolve, reject) => {
|
|
1839
|
+
const req = https.request(
|
|
1840
|
+
target,
|
|
1841
|
+
{ method: "GET", headers, lookup, agent: false, signal },
|
|
1842
|
+
(res) => {
|
|
1843
|
+
try {
|
|
1844
|
+
const status = res.statusCode ?? 0;
|
|
1845
|
+
const responseHeaders = new Headers();
|
|
1846
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
1847
|
+
if (value === void 0) continue;
|
|
1848
|
+
if (Array.isArray(value)) {
|
|
1849
|
+
for (const item of value) responseHeaders.append(name, item);
|
|
1850
|
+
} else {
|
|
1851
|
+
responseHeaders.set(name, value);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (NULL_BODY_STATUSES.has(status)) {
|
|
1855
|
+
res.resume();
|
|
1856
|
+
resolve(new Response(null, { status, headers: responseHeaders }));
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
let settled = false;
|
|
1860
|
+
const body = new ReadableStream({
|
|
1861
|
+
start(controller) {
|
|
1862
|
+
res.on("data", (chunk) => {
|
|
1863
|
+
if (settled) return;
|
|
1864
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
1865
|
+
if ((controller.desiredSize ?? 0) <= 0) res.pause();
|
|
1866
|
+
});
|
|
1867
|
+
res.on("end", () => {
|
|
1868
|
+
if (settled) return;
|
|
1869
|
+
settled = true;
|
|
1870
|
+
controller.close();
|
|
1871
|
+
});
|
|
1872
|
+
res.on("error", (err) => {
|
|
1873
|
+
if (settled) return;
|
|
1874
|
+
settled = true;
|
|
1875
|
+
controller.error(err);
|
|
1876
|
+
});
|
|
1877
|
+
},
|
|
1878
|
+
pull() {
|
|
1879
|
+
res.resume();
|
|
1880
|
+
},
|
|
1881
|
+
cancel() {
|
|
1882
|
+
settled = true;
|
|
1883
|
+
res.destroy();
|
|
1884
|
+
}
|
|
1885
|
+
});
|
|
1886
|
+
resolve(new Response(body, { status, headers: responseHeaders }));
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
res.destroy();
|
|
1889
|
+
reject(err);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
);
|
|
1893
|
+
req.on("error", reject);
|
|
1894
|
+
req.end();
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1725
1897
|
async function ssrfSafeFetch(url, options = {}) {
|
|
1726
1898
|
const {
|
|
1727
1899
|
timeoutMs = kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
@@ -1770,38 +1942,44 @@ async function ssrfSafeFetch(url, options = {}) {
|
|
|
1770
1942
|
blockedUrl: url
|
|
1771
1943
|
};
|
|
1772
1944
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
};
|
|
1783
|
-
}
|
|
1945
|
+
for (const ip of dnsResult.ips) {
|
|
1946
|
+
const blockResult = isBlockedIP(ip);
|
|
1947
|
+
if (blockResult.blocked) {
|
|
1948
|
+
return {
|
|
1949
|
+
ok: false,
|
|
1950
|
+
reason: blockResult.reason,
|
|
1951
|
+
message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
|
|
1952
|
+
blockedUrl: url
|
|
1953
|
+
};
|
|
1784
1954
|
}
|
|
1785
1955
|
}
|
|
1786
1956
|
let redirectCount = 0;
|
|
1787
1957
|
let currentUrl = url;
|
|
1958
|
+
let vettedAddresses = dnsResult.browser ? null : dnsResult.ips;
|
|
1788
1959
|
const originalOrigin = parsedUrl.origin;
|
|
1789
1960
|
while (true) {
|
|
1790
1961
|
try {
|
|
1791
1962
|
const controller = new AbortController();
|
|
1792
1963
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1964
|
+
const requestHeaders = {
|
|
1965
|
+
Accept: "application/json, text/plain",
|
|
1966
|
+
...headers
|
|
1967
|
+
};
|
|
1968
|
+
const response = vettedAddresses === null ? await fetch(currentUrl, {
|
|
1969
|
+
headers: requestHeaders,
|
|
1798
1970
|
signal: controller.signal,
|
|
1799
1971
|
redirect: "manual"
|
|
1800
1972
|
// Handle redirects manually for security
|
|
1801
|
-
})
|
|
1973
|
+
}) : await fetchFromVettedAddresses(
|
|
1974
|
+
currentUrl,
|
|
1975
|
+
vettedAddresses,
|
|
1976
|
+
requestHeaders,
|
|
1977
|
+
controller.signal
|
|
1978
|
+
);
|
|
1802
1979
|
clearTimeout(timeoutId);
|
|
1803
1980
|
if (response.status >= 300 && response.status < 400) {
|
|
1804
1981
|
const location = response.headers.get("location");
|
|
1982
|
+
await discardBody(response);
|
|
1805
1983
|
if (!location) {
|
|
1806
1984
|
return {
|
|
1807
1985
|
ok: false,
|
|
@@ -1870,24 +2048,24 @@ async function ssrfSafeFetch(url, options = {}) {
|
|
|
1870
2048
|
blockedUrl: redirectUrl.href
|
|
1871
2049
|
};
|
|
1872
2050
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
};
|
|
1883
|
-
}
|
|
2051
|
+
for (const ip of redirectDnsResult.ips) {
|
|
2052
|
+
const blockResult = isBlockedIP(ip);
|
|
2053
|
+
if (blockResult.blocked) {
|
|
2054
|
+
return {
|
|
2055
|
+
ok: false,
|
|
2056
|
+
reason: blockResult.reason,
|
|
2057
|
+
message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
|
|
2058
|
+
blockedUrl: redirectUrl.href
|
|
2059
|
+
};
|
|
1884
2060
|
}
|
|
1885
2061
|
}
|
|
1886
2062
|
currentUrl = redirectUrl.href;
|
|
2063
|
+
vettedAddresses = redirectDnsResult.browser ? null : redirectDnsResult.ips;
|
|
1887
2064
|
continue;
|
|
1888
2065
|
}
|
|
1889
2066
|
const contentLength = response.headers.get("content-length");
|
|
1890
2067
|
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
|
2068
|
+
await discardBody(response);
|
|
1891
2069
|
return {
|
|
1892
2070
|
ok: false,
|
|
1893
2071
|
reason: "response_too_large",
|
|
@@ -2118,6 +2296,25 @@ function mapSSRFError(reason, context) {
|
|
|
2118
2296
|
}
|
|
2119
2297
|
return { code, reason };
|
|
2120
2298
|
}
|
|
2299
|
+
var CANONICAL_ED25519_X = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
|
|
2300
|
+
function isPlainObject(value) {
|
|
2301
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2302
|
+
}
|
|
2303
|
+
function jwkEntryProblem(entry) {
|
|
2304
|
+
if (!isPlainObject(entry)) return "not an object";
|
|
2305
|
+
if (entry.kty !== "OKP") return "kty must be OKP";
|
|
2306
|
+
if (entry.crv !== "Ed25519") return "crv must be Ed25519";
|
|
2307
|
+
if (typeof entry.x !== "string" || !CANONICAL_ED25519_X.test(entry.x)) {
|
|
2308
|
+
return "x must be a canonical base64url 32-byte value";
|
|
2309
|
+
}
|
|
2310
|
+
if (Object.prototype.hasOwnProperty.call(entry, "d")) return "private key member d present";
|
|
2311
|
+
if (entry.kid !== void 0 && typeof entry.kid !== "string") return "kid must be a string";
|
|
2312
|
+
if (entry.use !== void 0 && entry.use !== "sig") return "use must be sig";
|
|
2313
|
+
if (entry.alg !== void 0 && entry.alg !== "EdDSA" && entry.alg !== "Ed25519") {
|
|
2314
|
+
return "alg must be EdDSA or Ed25519";
|
|
2315
|
+
}
|
|
2316
|
+
return null;
|
|
2317
|
+
}
|
|
2121
2318
|
async function resolveJWKS(issuerUrl, options) {
|
|
2122
2319
|
const cacheTtlMs = options?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
2123
2320
|
const maxCacheEntries = options?.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES;
|
|
@@ -2207,9 +2404,9 @@ async function resolveJWKS(issuerUrl, options) {
|
|
|
2207
2404
|
blockedUrl: jwksResult.blockedUrl
|
|
2208
2405
|
};
|
|
2209
2406
|
}
|
|
2210
|
-
let
|
|
2407
|
+
let parsed;
|
|
2211
2408
|
try {
|
|
2212
|
-
|
|
2409
|
+
parsed = JSON.parse(jwksResult.body);
|
|
2213
2410
|
} catch {
|
|
2214
2411
|
return {
|
|
2215
2412
|
ok: false,
|
|
@@ -2217,20 +2414,37 @@ async function resolveJWKS(issuerUrl, options) {
|
|
|
2217
2414
|
message: "JWKS response is not valid JSON"
|
|
2218
2415
|
};
|
|
2219
2416
|
}
|
|
2220
|
-
|
|
2417
|
+
const rawKeys = isPlainObject(parsed) ? parsed.keys : void 0;
|
|
2418
|
+
if (!Array.isArray(rawKeys)) {
|
|
2221
2419
|
return {
|
|
2222
2420
|
ok: false,
|
|
2223
2421
|
code: "E_VERIFY_JWKS_INVALID",
|
|
2224
2422
|
message: "JWKS missing required keys array"
|
|
2225
2423
|
};
|
|
2226
2424
|
}
|
|
2227
|
-
if (
|
|
2425
|
+
if (rawKeys.length > kernel.VERIFIER_LIMITS.maxJwksKeys) {
|
|
2228
2426
|
return {
|
|
2229
2427
|
ok: false,
|
|
2230
2428
|
code: "E_VERIFY_JWKS_TOO_MANY_KEYS",
|
|
2231
|
-
message: `JWKS has too many keys: ${
|
|
2429
|
+
message: `JWKS has too many keys: ${rawKeys.length} > ${kernel.VERIFIER_LIMITS.maxJwksKeys}`
|
|
2232
2430
|
};
|
|
2233
2431
|
}
|
|
2432
|
+
const validKeys = [];
|
|
2433
|
+
const skipped = [];
|
|
2434
|
+
rawKeys.forEach((entry, index) => {
|
|
2435
|
+
const problem = jwkEntryProblem(entry);
|
|
2436
|
+
if (problem === null) {
|
|
2437
|
+
validKeys.push(entry);
|
|
2438
|
+
} else {
|
|
2439
|
+
skipped.push(`#${index}: ${problem}`);
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
if (skipped.length > 0) {
|
|
2443
|
+
console.warn(
|
|
2444
|
+
`[peac:jwks] Skipped ${skipped.length} invalid JWKS entr${skipped.length === 1 ? "y" : "ies"} for ${normalizedIssuer} (${skipped.join("; ")})`
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
const jwks = { keys: validKeys };
|
|
2234
2448
|
const kidReuseError = checkKidReuse(normalizedIssuer, jwks, now);
|
|
2235
2449
|
if (kidReuseError) {
|
|
2236
2450
|
return {
|
|
@@ -3269,9 +3483,13 @@ async function fetchIssuerJWKS(issuerOrigin) {
|
|
|
3269
3483
|
return {
|
|
3270
3484
|
jwks: result.jwks,
|
|
3271
3485
|
fromCache: result.fromCache,
|
|
3272
|
-
rawBytes: result.rawBytes
|
|
3486
|
+
rawBytes: result.rawBytes,
|
|
3487
|
+
revokedKeys: result.revokedKeys
|
|
3273
3488
|
};
|
|
3274
3489
|
}
|
|
3490
|
+
function findRevokedKey(kid, revokedKeys) {
|
|
3491
|
+
return revokedKeys?.find((rk) => rk.kid === kid);
|
|
3492
|
+
}
|
|
3275
3493
|
async function verifyReceiptCore(options) {
|
|
3276
3494
|
const {
|
|
3277
3495
|
receipt,
|
|
@@ -3441,6 +3659,19 @@ async function verifyReceiptCore(options) {
|
|
|
3441
3659
|
from_cache: jwksResult.fromCache,
|
|
3442
3660
|
keys_count: jwksResult.jwks.keys.length
|
|
3443
3661
|
});
|
|
3662
|
+
const revokedPinned = findRevokedKey(kid, jwksResult.revokedKeys);
|
|
3663
|
+
if (revokedPinned) {
|
|
3664
|
+
builder.fail("key.resolve", "E_REVOKED_KEY_USED", {
|
|
3665
|
+
kid,
|
|
3666
|
+
revoked_at: revokedPinned.revoked_at,
|
|
3667
|
+
...revokedPinned.reason && { reason: revokedPinned.reason }
|
|
3668
|
+
});
|
|
3669
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
3670
|
+
return {
|
|
3671
|
+
valid: false,
|
|
3672
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3444
3675
|
const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
|
|
3445
3676
|
if (!jwk) {
|
|
3446
3677
|
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|
|
@@ -3502,6 +3733,19 @@ async function verifyReceiptCore(options) {
|
|
|
3502
3733
|
from_cache: jwksResult.fromCache,
|
|
3503
3734
|
keys_count: jwksResult.jwks.keys.length
|
|
3504
3735
|
});
|
|
3736
|
+
const revoked = findRevokedKey(kid, jwksResult.revokedKeys);
|
|
3737
|
+
if (revoked) {
|
|
3738
|
+
builder.fail("key.resolve", "E_REVOKED_KEY_USED", {
|
|
3739
|
+
kid,
|
|
3740
|
+
revoked_at: revoked.revoked_at,
|
|
3741
|
+
...revoked.reason && { reason: revoked.reason }
|
|
3742
|
+
});
|
|
3743
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
3744
|
+
return {
|
|
3745
|
+
valid: false,
|
|
3746
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
3747
|
+
};
|
|
3748
|
+
}
|
|
3505
3749
|
const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
|
|
3506
3750
|
if (!jwk) {
|
|
3507
3751
|
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|