@peac/protocol 0.16.3 → 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/_internal/record-core/validators/jose-hardening.d.ts +1 -1
- package/dist/_internal/record-core/validators/jose-hardening.d.ts.map +1 -1
- package/dist/_internal/test-helpers/fixture-manifest.d.ts.map +1 -1
- package/dist/index.cjs +383 -93
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +384 -94
- package/dist/index.mjs.map +1 -1
- package/dist/issue.d.ts +2 -2
- package/dist/issue.d.ts.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/dist/verify-local.cjs +21 -2
- package/dist/verify-local.cjs.map +1 -1
- package/dist/verify-local.mjs +21 -2
- package/dist/verify-local.mjs.map +1 -1
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { uuidv7 } from 'uuidv7';
|
|
2
|
-
import { sign, decode, verify, jcsHash, sha256Hex, computeJwkThumbprint, jwkToPublicKeyBytes, base64urlDecode, signWire02, base64urlDecodeString, base64urlEncode } from '@peac/crypto';
|
|
2
|
+
import { sign, CryptoError, decode, verify, jcsHash, sha256Hex, computeJwkThumbprint, jwkToPublicKeyBytes, base64urlDecode, signWire02, base64urlDecodeString, base64urlEncode } from '@peac/crypto';
|
|
3
3
|
export { base64urlDecode, base64urlEncode, computeJwkThumbprint, generateKeypair, jwkToPublicKeyBytes, sha256Bytes, sha256Hex, verify } from '@peac/crypto';
|
|
4
4
|
import { isValidPurposeToken, isCanonicalPurpose, isValidPurposeReason, isValidWorkflowContext, createWorkflowContextInvalidError, hasValidDagSemantics, createWorkflowDagInvalidError, WORKFLOW_EXTENSION_KEY, validateKernelConstraints, createConstraintViolationError, ReceiptClaims, createEvidenceNotJsonError, validateSubjectSnapshot, isCanonicalIss, Wire02ClaimsSchema, PEAC_ISSUER_CONFIG_MAX_BYTES, validateRevokedKeys, PEAC_ISSUER_CONFIG_PATH, PEAC_POLICY_MAX_BYTES, PEAC_POLICY_PATH, PEAC_POLICY_FALLBACK_PATH, WARNING_TYP_MISSING, parseReceiptClaims, checkOccurredAtSkew, REGISTERED_RECEIPT_TYPES, WARNING_TYPE_UNREGISTERED, REGISTERED_EXTENSION_GROUP_KEYS, isValidExtensionKey, WARNING_UNKNOWN_EXTENSION, WARNING_EXTENSION_GROUP_MISSING, WARNING_EXTENSION_GROUP_MISMATCH, verifyPolicyBinding, sortWarnings, PEAC_RECEIPT_HEADER, PEAC_PURPOSE_HEADER, parsePurposeHeader, PEAC_PURPOSE_APPLIED_HEADER, PEAC_PURPOSE_REASON_HEADER, KERNEL_CONSTRAINTS } from '@peac/schema';
|
|
5
5
|
import { VERIFIER_LIMITS, VERIFIER_NETWORK, TYPE_TO_EXTENSION_MAP, HASH, VERIFIER_POLICY_VERSION, VERIFICATION_REPORT_VERSION, WIRE_TYPE, OCCURRED_AT_TOLERANCE_SECONDS, ISS_CANONICAL, EXTENSION_BUDGET } from '@peac/kernel';
|
|
@@ -210,13 +210,32 @@ function validateTypeExtensionMappingInternal(input) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
// src/_internal/record-core/validators/jose-hardening.ts
|
|
213
|
-
var
|
|
213
|
+
var KID_MAX_UTF8_BYTES = 256;
|
|
214
|
+
function isAcceptableKid(kid) {
|
|
215
|
+
if (typeof kid !== "string" || kid.length === 0) return false;
|
|
216
|
+
let bytes = 0;
|
|
217
|
+
for (let i = 0; i < kid.length; i++) {
|
|
218
|
+
const c = kid.charCodeAt(i);
|
|
219
|
+
if (c < 128) bytes += 1;
|
|
220
|
+
else if (c < 2048) bytes += 2;
|
|
221
|
+
else if (c >= 55296 && c <= 56319) {
|
|
222
|
+
const next = i + 1 < kid.length ? kid.charCodeAt(i + 1) : 0;
|
|
223
|
+
if (next < 56320 || next > 57343) return false;
|
|
224
|
+
bytes += 4;
|
|
225
|
+
i++;
|
|
226
|
+
} else if (c >= 56320 && c <= 57343) {
|
|
227
|
+
return false;
|
|
228
|
+
} else bytes += 3;
|
|
229
|
+
if (bytes > KID_MAX_UTF8_BYTES) return false;
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
214
233
|
var ACCEPTED = { accepted: true };
|
|
215
234
|
function rejected(errorCode) {
|
|
216
235
|
return { accepted: false, errorCode };
|
|
217
236
|
}
|
|
218
237
|
function validateJoseHardeningInternal(header) {
|
|
219
|
-
if (!header.kid
|
|
238
|
+
if (!isAcceptableKid(header.kid)) {
|
|
220
239
|
return rejected("CRYPTO_JWS_MISSING_KID");
|
|
221
240
|
}
|
|
222
241
|
if (header.jwk !== void 0 || header.x5c !== void 0 || header.x5u !== void 0 || header.jku !== void 0) {
|
|
@@ -941,6 +960,25 @@ var IssueError = class extends Error {
|
|
|
941
960
|
this.peacError = peacError;
|
|
942
961
|
}
|
|
943
962
|
};
|
|
963
|
+
var CRYPTO_ISSUANCE_CODE_MAP = {
|
|
964
|
+
CRYPTO_JWS_MISSING_KID: "E_JWS_MISSING_KID",
|
|
965
|
+
CRYPTO_IJSON_INVALID_STRING: "E_IJSON_INVALID_STRING",
|
|
966
|
+
CRYPTO_IJSON_NUMBER_OUT_OF_RANGE: "E_IJSON_NUMBER_OUT_OF_RANGE",
|
|
967
|
+
CRYPTO_IJSON_DUPLICATE_MEMBER_NAME: "E_IJSON_DUPLICATE_MEMBER_NAME"
|
|
968
|
+
};
|
|
969
|
+
function issueErrorFromCryptoError(err) {
|
|
970
|
+
const code = CRYPTO_ISSUANCE_CODE_MAP[err.code] ?? "E_INVALID_FORMAT";
|
|
971
|
+
const issueError = new IssueError({
|
|
972
|
+
code,
|
|
973
|
+
category: "validation",
|
|
974
|
+
severity: "error",
|
|
975
|
+
retryable: false,
|
|
976
|
+
http_status: 400,
|
|
977
|
+
details: { message: err.message }
|
|
978
|
+
});
|
|
979
|
+
issueError.cause = err;
|
|
980
|
+
return issueError;
|
|
981
|
+
}
|
|
944
982
|
async function issueWire01(options) {
|
|
945
983
|
if (!options.iss.startsWith("https://")) {
|
|
946
984
|
throw new Error("Issuer URL must start with https://");
|
|
@@ -1149,7 +1187,15 @@ async function issueWire02(options) {
|
|
|
1149
1187
|
});
|
|
1150
1188
|
}
|
|
1151
1189
|
}
|
|
1152
|
-
|
|
1190
|
+
let jws;
|
|
1191
|
+
try {
|
|
1192
|
+
jws = await defaultCodec.encode(claims, options.privateKey, options.kid);
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
if (err instanceof CryptoError) {
|
|
1195
|
+
throw issueErrorFromCryptoError(err);
|
|
1196
|
+
}
|
|
1197
|
+
throw err;
|
|
1198
|
+
}
|
|
1153
1199
|
if (legacyPath && isShadowEnabled(options)) {
|
|
1154
1200
|
scheduleShadow({
|
|
1155
1201
|
call: "issue",
|
|
@@ -1483,6 +1529,138 @@ async function fetchDiscovery(issuerUrl) {
|
|
|
1483
1529
|
);
|
|
1484
1530
|
}
|
|
1485
1531
|
}
|
|
1532
|
+
|
|
1533
|
+
// src/_internal/address-policy.ts
|
|
1534
|
+
var IPV4_OCTET = /^(?:0|[1-9]\d{0,2})$/;
|
|
1535
|
+
var IPV6_GROUP = /^[0-9a-fA-F]{1,4}$/;
|
|
1536
|
+
function parseIPv4(text) {
|
|
1537
|
+
const parts = text.split(".");
|
|
1538
|
+
if (parts.length !== 4) return null;
|
|
1539
|
+
let value = 0;
|
|
1540
|
+
for (const part of parts) {
|
|
1541
|
+
if (!IPV4_OCTET.test(part)) return null;
|
|
1542
|
+
const octet = Number(part);
|
|
1543
|
+
if (octet > 255) return null;
|
|
1544
|
+
value = value * 256 + octet;
|
|
1545
|
+
}
|
|
1546
|
+
return value;
|
|
1547
|
+
}
|
|
1548
|
+
function parseGroups(text) {
|
|
1549
|
+
if (text === "") return [];
|
|
1550
|
+
const groups = [];
|
|
1551
|
+
for (const group of text.split(":")) {
|
|
1552
|
+
if (!IPV6_GROUP.test(group)) return null;
|
|
1553
|
+
groups.push(parseInt(group, 16));
|
|
1554
|
+
}
|
|
1555
|
+
return groups;
|
|
1556
|
+
}
|
|
1557
|
+
function parseIPv6(text) {
|
|
1558
|
+
if (text.length === 0 || text.length > 45) return null;
|
|
1559
|
+
let body = text;
|
|
1560
|
+
const lastColon = body.lastIndexOf(":");
|
|
1561
|
+
if (lastColon === -1) return null;
|
|
1562
|
+
const last = body.slice(lastColon + 1);
|
|
1563
|
+
if (last.includes(".")) {
|
|
1564
|
+
const v4 = parseIPv4(last);
|
|
1565
|
+
if (v4 === null) return null;
|
|
1566
|
+
body = `${body.slice(0, lastColon + 1)}${Math.floor(v4 / 65536).toString(16)}:${(v4 % 65536).toString(16)}`;
|
|
1567
|
+
}
|
|
1568
|
+
const halves = body.split("::");
|
|
1569
|
+
if (halves.length > 2) return null;
|
|
1570
|
+
const head = parseGroups(halves[0]);
|
|
1571
|
+
if (head === null) return null;
|
|
1572
|
+
if (halves.length === 1) return head.length === 8 ? head : null;
|
|
1573
|
+
const tail = parseGroups(halves[1]);
|
|
1574
|
+
if (tail === null) return null;
|
|
1575
|
+
const fill = 8 - head.length - tail.length;
|
|
1576
|
+
if (fill < 1) return null;
|
|
1577
|
+
return [...head, ...new Array(fill).fill(0), ...tail];
|
|
1578
|
+
}
|
|
1579
|
+
var IPV4_BLOCKS = [
|
|
1580
|
+
["0.0.0.0", 8, "private_ip"],
|
|
1581
|
+
["10.0.0.0", 8, "private_ip"],
|
|
1582
|
+
["100.64.0.0", 10, "private_ip"],
|
|
1583
|
+
["127.0.0.0", 8, "loopback"],
|
|
1584
|
+
["169.254.0.0", 16, "link_local"],
|
|
1585
|
+
["172.16.0.0", 12, "private_ip"],
|
|
1586
|
+
["192.0.0.0", 24, "private_ip"],
|
|
1587
|
+
["192.0.2.0", 24, "private_ip"],
|
|
1588
|
+
["192.31.196.0", 24, "private_ip"],
|
|
1589
|
+
["192.52.193.0", 24, "private_ip"],
|
|
1590
|
+
["192.88.99.0", 24, "private_ip"],
|
|
1591
|
+
["192.168.0.0", 16, "private_ip"],
|
|
1592
|
+
["192.175.48.0", 24, "private_ip"],
|
|
1593
|
+
["198.18.0.0", 15, "private_ip"],
|
|
1594
|
+
["198.51.100.0", 24, "private_ip"],
|
|
1595
|
+
["203.0.113.0", 24, "private_ip"],
|
|
1596
|
+
["224.0.0.0", 4, "private_ip"],
|
|
1597
|
+
["240.0.0.0", 4, "private_ip"]
|
|
1598
|
+
];
|
|
1599
|
+
var IPV4_BLOCK_TABLE = IPV4_BLOCKS.map(([network, prefix, reason]) => {
|
|
1600
|
+
const base = parseIPv4(network);
|
|
1601
|
+
const size = 2 ** (32 - prefix);
|
|
1602
|
+
return { start: base, end: base + size - 1, reason };
|
|
1603
|
+
});
|
|
1604
|
+
function classifyIPv4(value) {
|
|
1605
|
+
for (const block of IPV4_BLOCK_TABLE) {
|
|
1606
|
+
if (value >= block.start && value <= block.end) {
|
|
1607
|
+
return { allowed: false, reason: block.reason };
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return { allowed: true };
|
|
1611
|
+
}
|
|
1612
|
+
function matchesPrefix(groups, network, bits) {
|
|
1613
|
+
for (let i = 0; i < 8; i++) {
|
|
1614
|
+
const bitsHere = Math.min(16, Math.max(0, bits - 16 * i));
|
|
1615
|
+
if (bitsHere === 0) return true;
|
|
1616
|
+
const mask = 65535 << 16 - bitsHere & 65535;
|
|
1617
|
+
if ((groups[i] & mask) !== (network[i] & mask)) return false;
|
|
1618
|
+
}
|
|
1619
|
+
return true;
|
|
1620
|
+
}
|
|
1621
|
+
function v6(text) {
|
|
1622
|
+
return parseIPv6(text);
|
|
1623
|
+
}
|
|
1624
|
+
var GLOBAL_UNICAST_V6 = v6("2000::");
|
|
1625
|
+
var IPV6_BLOCKS_IN_GLOBAL = [
|
|
1626
|
+
[v6("2001::"), 23],
|
|
1627
|
+
[v6("2001:db8::"), 32],
|
|
1628
|
+
[v6("2002::"), 16],
|
|
1629
|
+
[v6("2620:4f:8000::"), 48],
|
|
1630
|
+
[v6("3fff::"), 20]
|
|
1631
|
+
];
|
|
1632
|
+
var IPV6_LOOPBACK = v6("::1");
|
|
1633
|
+
var IPV6_LINK_LOCAL = v6("fe80::");
|
|
1634
|
+
var IPV4_MAPPED = v6("::ffff:0:0");
|
|
1635
|
+
var NAT64_WELL_KNOWN = v6("64:ff9b::");
|
|
1636
|
+
function classifyIPv6(groups) {
|
|
1637
|
+
if (matchesPrefix(groups, IPV4_MAPPED, 96) || matchesPrefix(groups, NAT64_WELL_KNOWN, 96)) {
|
|
1638
|
+
return classifyIPv4(groups[6] * 65536 + groups[7]);
|
|
1639
|
+
}
|
|
1640
|
+
if (!matchesPrefix(groups, GLOBAL_UNICAST_V6, 3)) {
|
|
1641
|
+
if (matchesPrefix(groups, IPV6_LOOPBACK, 128)) return { allowed: false, reason: "loopback" };
|
|
1642
|
+
if (matchesPrefix(groups, IPV6_LINK_LOCAL, 10)) return { allowed: false, reason: "link_local" };
|
|
1643
|
+
return { allowed: false, reason: "private_ip" };
|
|
1644
|
+
}
|
|
1645
|
+
for (const [network, bits] of IPV6_BLOCKS_IN_GLOBAL) {
|
|
1646
|
+
if (matchesPrefix(groups, network, bits)) return { allowed: false, reason: "private_ip" };
|
|
1647
|
+
}
|
|
1648
|
+
return { allowed: true };
|
|
1649
|
+
}
|
|
1650
|
+
function classifyAddress(ip) {
|
|
1651
|
+
const v4 = parseIPv4(ip);
|
|
1652
|
+
if (v4 !== null) return classifyIPv4(v4);
|
|
1653
|
+
const groups = parseIPv6(ip);
|
|
1654
|
+
if (groups !== null) return classifyIPv6(groups);
|
|
1655
|
+
return { allowed: false, reason: "private_ip" };
|
|
1656
|
+
}
|
|
1657
|
+
function hostIpLiteral(hostname) {
|
|
1658
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return hostname.slice(1, -1);
|
|
1659
|
+
if (/^[0-9.]+$/.test(hostname)) return hostname;
|
|
1660
|
+
return null;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// src/ssrf-safe-fetch.ts
|
|
1486
1664
|
var cachedCapabilities = null;
|
|
1487
1665
|
function getSSRFCapabilities() {
|
|
1488
1666
|
if (cachedCapabilities) {
|
|
@@ -1500,9 +1678,9 @@ function detectCapabilities() {
|
|
|
1500
1678
|
networkIsolation: false,
|
|
1501
1679
|
protectionLevel: "full",
|
|
1502
1680
|
notes: [
|
|
1503
|
-
"
|
|
1504
|
-
"
|
|
1505
|
-
"
|
|
1681
|
+
"Hostnames are resolved once via the Node.js dns module",
|
|
1682
|
+
"Every resolved address must be a global unicast address",
|
|
1683
|
+
"The connection is made only to a vetted address"
|
|
1506
1684
|
]
|
|
1507
1685
|
};
|
|
1508
1686
|
}
|
|
@@ -1580,61 +1758,16 @@ function detectCapabilities() {
|
|
|
1580
1758
|
function resetSSRFCapabilitiesCache() {
|
|
1581
1759
|
cachedCapabilities = null;
|
|
1582
1760
|
}
|
|
1583
|
-
function parseIPv4(ip) {
|
|
1584
|
-
const parts = ip.split(".");
|
|
1585
|
-
if (parts.length !== 4) return null;
|
|
1586
|
-
const octets = [];
|
|
1587
|
-
for (const part of parts) {
|
|
1588
|
-
const num = parseInt(part, 10);
|
|
1589
|
-
if (isNaN(num) || num < 0 || num > 255) return null;
|
|
1590
|
-
octets.push(num);
|
|
1591
|
-
}
|
|
1592
|
-
return { octets };
|
|
1593
|
-
}
|
|
1594
|
-
function isInCIDR(ip, cidr) {
|
|
1595
|
-
const [rangeStr, maskStr] = cidr.split("/");
|
|
1596
|
-
const range = parseIPv4(rangeStr);
|
|
1597
|
-
if (!range) return false;
|
|
1598
|
-
const maskBits = parseInt(maskStr, 10);
|
|
1599
|
-
if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
|
|
1600
|
-
const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
|
|
1601
|
-
const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
|
|
1602
|
-
const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
|
|
1603
|
-
return (ipNum & mask) === (rangeNum & mask);
|
|
1604
|
-
}
|
|
1605
|
-
function isIPv6Loopback(ip) {
|
|
1606
|
-
const normalized = ip.toLowerCase().replace(/^::ffff:/, "");
|
|
1607
|
-
return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
|
|
1608
|
-
}
|
|
1609
|
-
function isIPv6LinkLocal(ip) {
|
|
1610
|
-
const normalized = ip.toLowerCase();
|
|
1611
|
-
return normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
|
1612
|
-
}
|
|
1613
1761
|
function isBlockedIP(ip) {
|
|
1614
|
-
const
|
|
1615
|
-
|
|
1616
|
-
const ipv4 = parseIPv4(effectiveIP);
|
|
1617
|
-
if (ipv4) {
|
|
1618
|
-
if (isInCIDR(ipv4, "10.0.0.0/8") || isInCIDR(ipv4, "172.16.0.0/12") || isInCIDR(ipv4, "192.168.0.0/16")) {
|
|
1619
|
-
return { blocked: true, reason: "private_ip" };
|
|
1620
|
-
}
|
|
1621
|
-
if (isInCIDR(ipv4, "127.0.0.0/8")) {
|
|
1622
|
-
return { blocked: true, reason: "loopback" };
|
|
1623
|
-
}
|
|
1624
|
-
if (isInCIDR(ipv4, "169.254.0.0/16")) {
|
|
1625
|
-
return { blocked: true, reason: "link_local" };
|
|
1626
|
-
}
|
|
1627
|
-
return { blocked: false };
|
|
1628
|
-
}
|
|
1629
|
-
if (isIPv6Loopback(ip)) {
|
|
1630
|
-
return { blocked: true, reason: "loopback" };
|
|
1631
|
-
}
|
|
1632
|
-
if (isIPv6LinkLocal(ip)) {
|
|
1633
|
-
return { blocked: true, reason: "link_local" };
|
|
1634
|
-
}
|
|
1635
|
-
return { blocked: false };
|
|
1762
|
+
const verdict = classifyAddress(ip);
|
|
1763
|
+
return verdict.allowed ? { blocked: false } : { blocked: true, reason: verdict.reason };
|
|
1636
1764
|
}
|
|
1637
1765
|
async function resolveHostname(hostname) {
|
|
1766
|
+
const literal = hostIpLiteral(hostname);
|
|
1767
|
+
if (literal !== null) {
|
|
1768
|
+
const nodeRuntime = typeof process !== "undefined" && Boolean(process.versions?.node);
|
|
1769
|
+
return { ok: true, ips: [literal], browser: !nodeRuntime };
|
|
1770
|
+
}
|
|
1638
1771
|
if (typeof process !== "undefined" && process.versions?.node) {
|
|
1639
1772
|
try {
|
|
1640
1773
|
const dns = await import('dns');
|
|
@@ -1665,7 +1798,10 @@ async function resolveHostname(hostname) {
|
|
|
1665
1798
|
message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
|
|
1666
1799
|
};
|
|
1667
1800
|
}
|
|
1668
|
-
return {
|
|
1801
|
+
return {
|
|
1802
|
+
ok: false,
|
|
1803
|
+
message: `DNS resolution returned no addresses for ${hostname}`
|
|
1804
|
+
};
|
|
1669
1805
|
} catch (err) {
|
|
1670
1806
|
return {
|
|
1671
1807
|
ok: false,
|
|
@@ -1675,6 +1811,88 @@ async function resolveHostname(hostname) {
|
|
|
1675
1811
|
}
|
|
1676
1812
|
return { ok: true, ips: [], browser: true };
|
|
1677
1813
|
}
|
|
1814
|
+
var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
1815
|
+
async function discardBody(response) {
|
|
1816
|
+
await response.body?.cancel();
|
|
1817
|
+
}
|
|
1818
|
+
async function fetchFromVettedAddresses(url, addresses, headers, signal) {
|
|
1819
|
+
const https = await import('https');
|
|
1820
|
+
const { isIP } = await import('net');
|
|
1821
|
+
const target = new URL(url);
|
|
1822
|
+
const host = hostIpLiteral(target.hostname) ?? target.hostname;
|
|
1823
|
+
if (addresses.length === 0 || isIP(host) !== 0 && !addresses.includes(host)) {
|
|
1824
|
+
throw new Error(`Refusing to connect to an address that was not vetted: ${host}`);
|
|
1825
|
+
}
|
|
1826
|
+
const entries = addresses.map((address) => ({
|
|
1827
|
+
address,
|
|
1828
|
+
family: address.includes(":") ? 6 : 4
|
|
1829
|
+
}));
|
|
1830
|
+
const lookup = (_hostname, options, callback) => {
|
|
1831
|
+
if (options.all) {
|
|
1832
|
+
callback(null, entries);
|
|
1833
|
+
} else {
|
|
1834
|
+
callback(null, entries[0].address, entries[0].family);
|
|
1835
|
+
}
|
|
1836
|
+
};
|
|
1837
|
+
return new Promise((resolve, reject) => {
|
|
1838
|
+
const req = https.request(
|
|
1839
|
+
target,
|
|
1840
|
+
{ method: "GET", headers, lookup, agent: false, signal },
|
|
1841
|
+
(res) => {
|
|
1842
|
+
try {
|
|
1843
|
+
const status = res.statusCode ?? 0;
|
|
1844
|
+
const responseHeaders = new Headers();
|
|
1845
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
1846
|
+
if (value === void 0) continue;
|
|
1847
|
+
if (Array.isArray(value)) {
|
|
1848
|
+
for (const item of value) responseHeaders.append(name, item);
|
|
1849
|
+
} else {
|
|
1850
|
+
responseHeaders.set(name, value);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (NULL_BODY_STATUSES.has(status)) {
|
|
1854
|
+
res.resume();
|
|
1855
|
+
resolve(new Response(null, { status, headers: responseHeaders }));
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
let settled = false;
|
|
1859
|
+
const body = new ReadableStream({
|
|
1860
|
+
start(controller) {
|
|
1861
|
+
res.on("data", (chunk) => {
|
|
1862
|
+
if (settled) return;
|
|
1863
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
1864
|
+
if ((controller.desiredSize ?? 0) <= 0) res.pause();
|
|
1865
|
+
});
|
|
1866
|
+
res.on("end", () => {
|
|
1867
|
+
if (settled) return;
|
|
1868
|
+
settled = true;
|
|
1869
|
+
controller.close();
|
|
1870
|
+
});
|
|
1871
|
+
res.on("error", (err) => {
|
|
1872
|
+
if (settled) return;
|
|
1873
|
+
settled = true;
|
|
1874
|
+
controller.error(err);
|
|
1875
|
+
});
|
|
1876
|
+
},
|
|
1877
|
+
pull() {
|
|
1878
|
+
res.resume();
|
|
1879
|
+
},
|
|
1880
|
+
cancel() {
|
|
1881
|
+
settled = true;
|
|
1882
|
+
res.destroy();
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
resolve(new Response(body, { status, headers: responseHeaders }));
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
res.destroy();
|
|
1888
|
+
reject(err);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
);
|
|
1892
|
+
req.on("error", reject);
|
|
1893
|
+
req.end();
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1678
1896
|
async function ssrfSafeFetch(url, options = {}) {
|
|
1679
1897
|
const {
|
|
1680
1898
|
timeoutMs = VERIFIER_LIMITS.fetchTimeoutMs,
|
|
@@ -1723,38 +1941,44 @@ async function ssrfSafeFetch(url, options = {}) {
|
|
|
1723
1941
|
blockedUrl: url
|
|
1724
1942
|
};
|
|
1725
1943
|
}
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
};
|
|
1736
|
-
}
|
|
1944
|
+
for (const ip of dnsResult.ips) {
|
|
1945
|
+
const blockResult = isBlockedIP(ip);
|
|
1946
|
+
if (blockResult.blocked) {
|
|
1947
|
+
return {
|
|
1948
|
+
ok: false,
|
|
1949
|
+
reason: blockResult.reason,
|
|
1950
|
+
message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
|
|
1951
|
+
blockedUrl: url
|
|
1952
|
+
};
|
|
1737
1953
|
}
|
|
1738
1954
|
}
|
|
1739
1955
|
let redirectCount = 0;
|
|
1740
1956
|
let currentUrl = url;
|
|
1957
|
+
let vettedAddresses = dnsResult.browser ? null : dnsResult.ips;
|
|
1741
1958
|
const originalOrigin = parsedUrl.origin;
|
|
1742
1959
|
while (true) {
|
|
1743
1960
|
try {
|
|
1744
1961
|
const controller = new AbortController();
|
|
1745
1962
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1746
|
-
const
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1963
|
+
const requestHeaders = {
|
|
1964
|
+
Accept: "application/json, text/plain",
|
|
1965
|
+
...headers
|
|
1966
|
+
};
|
|
1967
|
+
const response = vettedAddresses === null ? await fetch(currentUrl, {
|
|
1968
|
+
headers: requestHeaders,
|
|
1751
1969
|
signal: controller.signal,
|
|
1752
1970
|
redirect: "manual"
|
|
1753
1971
|
// Handle redirects manually for security
|
|
1754
|
-
})
|
|
1972
|
+
}) : await fetchFromVettedAddresses(
|
|
1973
|
+
currentUrl,
|
|
1974
|
+
vettedAddresses,
|
|
1975
|
+
requestHeaders,
|
|
1976
|
+
controller.signal
|
|
1977
|
+
);
|
|
1755
1978
|
clearTimeout(timeoutId);
|
|
1756
1979
|
if (response.status >= 300 && response.status < 400) {
|
|
1757
1980
|
const location = response.headers.get("location");
|
|
1981
|
+
await discardBody(response);
|
|
1758
1982
|
if (!location) {
|
|
1759
1983
|
return {
|
|
1760
1984
|
ok: false,
|
|
@@ -1823,24 +2047,24 @@ async function ssrfSafeFetch(url, options = {}) {
|
|
|
1823
2047
|
blockedUrl: redirectUrl.href
|
|
1824
2048
|
};
|
|
1825
2049
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
};
|
|
1836
|
-
}
|
|
2050
|
+
for (const ip of redirectDnsResult.ips) {
|
|
2051
|
+
const blockResult = isBlockedIP(ip);
|
|
2052
|
+
if (blockResult.blocked) {
|
|
2053
|
+
return {
|
|
2054
|
+
ok: false,
|
|
2055
|
+
reason: blockResult.reason,
|
|
2056
|
+
message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
|
|
2057
|
+
blockedUrl: redirectUrl.href
|
|
2058
|
+
};
|
|
1837
2059
|
}
|
|
1838
2060
|
}
|
|
1839
2061
|
currentUrl = redirectUrl.href;
|
|
2062
|
+
vettedAddresses = redirectDnsResult.browser ? null : redirectDnsResult.ips;
|
|
1840
2063
|
continue;
|
|
1841
2064
|
}
|
|
1842
2065
|
const contentLength = response.headers.get("content-length");
|
|
1843
2066
|
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
|
2067
|
+
await discardBody(response);
|
|
1844
2068
|
return {
|
|
1845
2069
|
ok: false,
|
|
1846
2070
|
reason: "response_too_large",
|
|
@@ -2071,6 +2295,25 @@ function mapSSRFError(reason, context) {
|
|
|
2071
2295
|
}
|
|
2072
2296
|
return { code, reason };
|
|
2073
2297
|
}
|
|
2298
|
+
var CANONICAL_ED25519_X = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
|
|
2299
|
+
function isPlainObject(value) {
|
|
2300
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2301
|
+
}
|
|
2302
|
+
function jwkEntryProblem(entry) {
|
|
2303
|
+
if (!isPlainObject(entry)) return "not an object";
|
|
2304
|
+
if (entry.kty !== "OKP") return "kty must be OKP";
|
|
2305
|
+
if (entry.crv !== "Ed25519") return "crv must be Ed25519";
|
|
2306
|
+
if (typeof entry.x !== "string" || !CANONICAL_ED25519_X.test(entry.x)) {
|
|
2307
|
+
return "x must be a canonical base64url 32-byte value";
|
|
2308
|
+
}
|
|
2309
|
+
if (Object.prototype.hasOwnProperty.call(entry, "d")) return "private key member d present";
|
|
2310
|
+
if (entry.kid !== void 0 && typeof entry.kid !== "string") return "kid must be a string";
|
|
2311
|
+
if (entry.use !== void 0 && entry.use !== "sig") return "use must be sig";
|
|
2312
|
+
if (entry.alg !== void 0 && entry.alg !== "EdDSA" && entry.alg !== "Ed25519") {
|
|
2313
|
+
return "alg must be EdDSA or Ed25519";
|
|
2314
|
+
}
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2074
2317
|
async function resolveJWKS(issuerUrl, options) {
|
|
2075
2318
|
const cacheTtlMs = options?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
2076
2319
|
const maxCacheEntries = options?.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES;
|
|
@@ -2160,9 +2403,9 @@ async function resolveJWKS(issuerUrl, options) {
|
|
|
2160
2403
|
blockedUrl: jwksResult.blockedUrl
|
|
2161
2404
|
};
|
|
2162
2405
|
}
|
|
2163
|
-
let
|
|
2406
|
+
let parsed;
|
|
2164
2407
|
try {
|
|
2165
|
-
|
|
2408
|
+
parsed = JSON.parse(jwksResult.body);
|
|
2166
2409
|
} catch {
|
|
2167
2410
|
return {
|
|
2168
2411
|
ok: false,
|
|
@@ -2170,20 +2413,37 @@ async function resolveJWKS(issuerUrl, options) {
|
|
|
2170
2413
|
message: "JWKS response is not valid JSON"
|
|
2171
2414
|
};
|
|
2172
2415
|
}
|
|
2173
|
-
|
|
2416
|
+
const rawKeys = isPlainObject(parsed) ? parsed.keys : void 0;
|
|
2417
|
+
if (!Array.isArray(rawKeys)) {
|
|
2174
2418
|
return {
|
|
2175
2419
|
ok: false,
|
|
2176
2420
|
code: "E_VERIFY_JWKS_INVALID",
|
|
2177
2421
|
message: "JWKS missing required keys array"
|
|
2178
2422
|
};
|
|
2179
2423
|
}
|
|
2180
|
-
if (
|
|
2424
|
+
if (rawKeys.length > VERIFIER_LIMITS.maxJwksKeys) {
|
|
2181
2425
|
return {
|
|
2182
2426
|
ok: false,
|
|
2183
2427
|
code: "E_VERIFY_JWKS_TOO_MANY_KEYS",
|
|
2184
|
-
message: `JWKS has too many keys: ${
|
|
2428
|
+
message: `JWKS has too many keys: ${rawKeys.length} > ${VERIFIER_LIMITS.maxJwksKeys}`
|
|
2185
2429
|
};
|
|
2186
2430
|
}
|
|
2431
|
+
const validKeys = [];
|
|
2432
|
+
const skipped = [];
|
|
2433
|
+
rawKeys.forEach((entry, index) => {
|
|
2434
|
+
const problem = jwkEntryProblem(entry);
|
|
2435
|
+
if (problem === null) {
|
|
2436
|
+
validKeys.push(entry);
|
|
2437
|
+
} else {
|
|
2438
|
+
skipped.push(`#${index}: ${problem}`);
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
if (skipped.length > 0) {
|
|
2442
|
+
console.warn(
|
|
2443
|
+
`[peac:jwks] Skipped ${skipped.length} invalid JWKS entr${skipped.length === 1 ? "y" : "ies"} for ${normalizedIssuer} (${skipped.join("; ")})`
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
const jwks = { keys: validKeys };
|
|
2187
2447
|
const kidReuseError = checkKidReuse(normalizedIssuer, jwks, now);
|
|
2188
2448
|
if (kidReuseError) {
|
|
2189
2449
|
return {
|
|
@@ -3222,9 +3482,13 @@ async function fetchIssuerJWKS(issuerOrigin) {
|
|
|
3222
3482
|
return {
|
|
3223
3483
|
jwks: result.jwks,
|
|
3224
3484
|
fromCache: result.fromCache,
|
|
3225
|
-
rawBytes: result.rawBytes
|
|
3485
|
+
rawBytes: result.rawBytes,
|
|
3486
|
+
revokedKeys: result.revokedKeys
|
|
3226
3487
|
};
|
|
3227
3488
|
}
|
|
3489
|
+
function findRevokedKey(kid, revokedKeys) {
|
|
3490
|
+
return revokedKeys?.find((rk) => rk.kid === kid);
|
|
3491
|
+
}
|
|
3228
3492
|
async function verifyReceiptCore(options) {
|
|
3229
3493
|
const {
|
|
3230
3494
|
receipt,
|
|
@@ -3394,6 +3658,19 @@ async function verifyReceiptCore(options) {
|
|
|
3394
3658
|
from_cache: jwksResult.fromCache,
|
|
3395
3659
|
keys_count: jwksResult.jwks.keys.length
|
|
3396
3660
|
});
|
|
3661
|
+
const revokedPinned = findRevokedKey(kid, jwksResult.revokedKeys);
|
|
3662
|
+
if (revokedPinned) {
|
|
3663
|
+
builder.fail("key.resolve", "E_REVOKED_KEY_USED", {
|
|
3664
|
+
kid,
|
|
3665
|
+
revoked_at: revokedPinned.revoked_at,
|
|
3666
|
+
...revokedPinned.reason && { reason: revokedPinned.reason }
|
|
3667
|
+
});
|
|
3668
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
3669
|
+
return {
|
|
3670
|
+
valid: false,
|
|
3671
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
3672
|
+
};
|
|
3673
|
+
}
|
|
3397
3674
|
const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
|
|
3398
3675
|
if (!jwk) {
|
|
3399
3676
|
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|
|
@@ -3455,6 +3732,19 @@ async function verifyReceiptCore(options) {
|
|
|
3455
3732
|
from_cache: jwksResult.fromCache,
|
|
3456
3733
|
keys_count: jwksResult.jwks.keys.length
|
|
3457
3734
|
});
|
|
3735
|
+
const revoked = findRevokedKey(kid, jwksResult.revokedKeys);
|
|
3736
|
+
if (revoked) {
|
|
3737
|
+
builder.fail("key.resolve", "E_REVOKED_KEY_USED", {
|
|
3738
|
+
kid,
|
|
3739
|
+
revoked_at: revoked.revoked_at,
|
|
3740
|
+
...revoked.reason && { reason: revoked.reason }
|
|
3741
|
+
});
|
|
3742
|
+
builder.failure("key_not_found", normalizedIssuer, kid);
|
|
3743
|
+
return {
|
|
3744
|
+
valid: false,
|
|
3745
|
+
report: includeMeta ? builder.addTimestamp().build() : builder.build()
|
|
3746
|
+
};
|
|
3747
|
+
}
|
|
3458
3748
|
const jwk = jwksResult.jwks.keys.find((k) => k.kid === kid);
|
|
3459
3749
|
if (!jwk) {
|
|
3460
3750
|
builder.fail("key.resolve", "E_VERIFY_KEY_NOT_FOUND", {
|