@blamejs/pki 0.3.24 → 0.3.26
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/CHANGELOG.md +25 -0
- package/README.md +2 -2
- package/index.js +8 -5
- package/lib/acme.js +8 -5
- package/lib/cmp-build.js +14 -1
- package/lib/cmp-verify.js +705 -0
- package/lib/constants.js +15 -0
- package/lib/est.js +32 -45
- package/lib/http-transport.js +95 -6
- package/lib/inspect.js +20 -0
- package/lib/lint.js +3 -26
- package/lib/path-validate.js +294 -19
- package/lib/schema-cms.js +45 -0
- package/lib/schema-pkcs12.js +5 -66
- package/lib/schema-pkix.js +142 -0
- package/lib/webcrypto.js +31 -3
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/constants.js
CHANGED
|
@@ -223,6 +223,21 @@ var LIMITS = {
|
|
|
223
223
|
// overridable (opts.maxDepth / opts.maxCandidatesConsidered).
|
|
224
224
|
PATH_BUILD_MAX_DEPTH: 20,
|
|
225
225
|
PATH_BUILD_MAX_CANDIDATES: 1000,
|
|
226
|
+
// AIA caIssuers network-fetch bounds (pki.path.build with opts.fetchAia). Fetching an issuer over the
|
|
227
|
+
// network from an untrusted certificate's authorityInfoAccess opens an SSRF / amplification surface (a
|
|
228
|
+
// hostile mesh where each fetched cert advertises a fresh caIssuers URL). PATH_AIA_MAX_FETCHES is the
|
|
229
|
+
// TOTAL network GET budget across a whole build() call (a breach throws path/aia-fetch-limit);
|
|
230
|
+
// PATH_AIA_MAX_PER_CERT caps how many caIssuers URIs are tried for a single certificate (an AIA MAY carry
|
|
231
|
+
// many). Both are small by default and operator-overridable (opts.maxAiaFetches / opts.maxAiaPerCert).
|
|
232
|
+
PATH_AIA_MAX_FETCHES: 10,
|
|
233
|
+
PATH_AIA_MAX_PER_CERT: 3,
|
|
234
|
+
// A caIssuers response is one certificate or a short chain -- never a 24 MiB bundle. Bounding the AIA
|
|
235
|
+
// fetch below the general HTTP ceiling (and capping the certificate COUNT a single response contributes)
|
|
236
|
+
// stops a hostile-but-TLS-trusted AIA endpoint from forcing tens of thousands of certificate parses per
|
|
237
|
+
// fetch (parse work bounded by COUNT, not only by bytes). Both operator-overridable (opts.maxResponseBytes
|
|
238
|
+
// tightens downward only; the count cap is a fixed defense).
|
|
239
|
+
PATH_AIA_MAX_RESPONSE_BYTES: BYTES.mib(1),
|
|
240
|
+
PATH_AIA_MAX_CERTS_PER_RESPONSE: 16,
|
|
226
241
|
// PKCS#12 container ceilings. A PFX carries lists at three altitudes
|
|
227
242
|
// (ContentInfos per AuthenticatedSafe, SafeBags per SafeContents,
|
|
228
243
|
// attributes per bag) and can chain fresh DER blobs inside OCTET STRINGs,
|
package/lib/est.js
CHANGED
|
@@ -56,7 +56,6 @@ var oid = require("./oid");
|
|
|
56
56
|
var constants = require("./constants");
|
|
57
57
|
var cms = require("./schema-cms");
|
|
58
58
|
var x509 = require("./schema-x509");
|
|
59
|
-
var crl = require("./schema-crl");
|
|
60
59
|
var pkcs8 = require("./schema-pkcs8");
|
|
61
60
|
var csr = require("./schema-csr");
|
|
62
61
|
var frameworkError = require("./framework-error");
|
|
@@ -66,8 +65,6 @@ var retryAfter = require("./http-retry-after");
|
|
|
66
65
|
|
|
67
66
|
var EstError = frameworkError.EstError;
|
|
68
67
|
function E(code, message, cause) { return new EstError(code, message, cause); }
|
|
69
|
-
|
|
70
|
-
var ID_DATA = oid.byName("data");
|
|
71
68
|
var ID_SIGNED_DATA = oid.byName("signedData");
|
|
72
69
|
var OID_CHALLENGE_PASSWORD = oid.byName("challengePassword");
|
|
73
70
|
var OID_DECRYPT_KEY_ID = oid.byName("decryptKeyID");
|
|
@@ -212,36 +209,9 @@ function splitMultipartMixed(body, contentType) {
|
|
|
212
209
|
* r.certificates; // -> [Buffer, ...] raw, unordered
|
|
213
210
|
*/
|
|
214
211
|
function parseCertsOnly(der) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (r.contentTypeName !== "signedData") throw E("est/not-certs-only", "an EST certs-only response must be a CMS SignedData (RFC 5272 sec. 4.1)");
|
|
219
|
-
if (r.encapContentInfo.eContentType !== ID_DATA || r.encapContentInfo.eContent !== null) {
|
|
220
|
-
throw E("est/not-certs-only", "a certs-only Simple PKI Response must carry id-data with no eContent (RFC 5272 sec. 4.1)");
|
|
221
|
-
}
|
|
222
|
-
if (r.signerInfos.length !== 0) throw E("est/not-certs-only", "a certs-only Simple PKI Response must have empty signerInfos (RFC 5272 sec. 4.1)");
|
|
223
|
-
if (!r.certificates || r.certificates.length === 0) throw E("est/no-certificates", "an EST certs-only response must contain at least one certificate (RFC 7030 sec. 4.1.3)");
|
|
224
|
-
for (var i = 0; i < r.certificates.length; i++) {
|
|
225
|
-
if (r.certificates[i].tagClass !== "universal") throw E("est/bad-certificate-choice", "EST exchanges plain X.509 certificates; a tagged CertificateChoices alternative is not permitted (RFC 7030)");
|
|
226
|
-
// A universal-SEQUENCE CertificateChoice must be a well-formed X.509
|
|
227
|
-
// Certificate, not merely any SEQUENCE. Parse it structurally (still
|
|
228
|
-
// returning the raw bytes below) so a malformed response fails closed.
|
|
229
|
-
try { x509.parse(r.certificates[i].bytes); }
|
|
230
|
-
catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-certificate", "a certs-only response carried a non-certificate in its certificates field (RFC 5272 sec. 4.1)", e); }
|
|
231
|
-
}
|
|
232
|
-
var crls = r.crls || [];
|
|
233
|
-
for (var j = 0; j < crls.length; j++) {
|
|
234
|
-
// A RevocationInfoChoice is a plain X.509 CertificateList or a [1] otherRevInfo;
|
|
235
|
-
// EST surfaces CRLs, so reject the tagged alternative and structurally validate
|
|
236
|
-
// each universal entry as a CertificateList (mirrors the certificate path).
|
|
237
|
-
if (crls[j].tagClass !== "universal") throw E("est/bad-crl", "an EST response CRL must be a plain X.509 CertificateList, not a tagged otherRevInfo alternative (RFC 5652 sec. 10.2.1)");
|
|
238
|
-
try { crl.parse(crls[j].bytes); }
|
|
239
|
-
catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-crl", "a response carried a non-CRL in its crls field", e); }
|
|
240
|
-
}
|
|
241
|
-
return {
|
|
242
|
-
certificates: r.certificates.map(function (c) { return c.bytes; }),
|
|
243
|
-
crls: crls.map(function (c) { return c.bytes; }),
|
|
244
|
-
};
|
|
212
|
+
// The certs-only Simple PKI Response shape is a CMS concern shared with AIA path building; the reader lives
|
|
213
|
+
// in schema-cms. The "est" prefix keeps the exact est/* codes (est/not-certs-only, est/no-certificates, ...).
|
|
214
|
+
return cms.parseCertsOnly(der, E, "est");
|
|
245
215
|
}
|
|
246
216
|
|
|
247
217
|
// Pick the issued certificate from a certs-only response by matching its public
|
|
@@ -727,9 +697,30 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
727
697
|
var redirects = 0;
|
|
728
698
|
var authTried = false;
|
|
729
699
|
var initialOrigin = url.origin; // the origin the caller intended to authenticate to
|
|
730
|
-
|
|
700
|
+
// The per-hop TLS: the origin-specific identity (mTLS cert/key + pinned SNI) is sent ONLY to the caller's
|
|
701
|
+
// configured origin; a cross-origin hop gets a narrowed copy with those stripped (checkServerIdentity kept).
|
|
702
|
+
// Derived FRESH from budgets.tls each hop (the ACME _tlsFor model) so a redirect back to the original origin
|
|
703
|
+
// restores its identity rather than permanently losing the SNI at the first cross-origin boundary.
|
|
704
|
+
function _tlsFor(u) {
|
|
705
|
+
var t = budgets.tls;
|
|
706
|
+
if (t && u.origin !== initialOrigin && (t.cert != null || t.key != null || t.servername != null)) {
|
|
707
|
+
t = Object.assign({}, t);
|
|
708
|
+
delete t.cert; delete t.key; delete t.servername;
|
|
709
|
+
}
|
|
710
|
+
return t;
|
|
711
|
+
}
|
|
712
|
+
// The per-hop Authorization: the HTTP Basic credential (established after a 401 on the caller's origin) is
|
|
713
|
+
// sent ONLY to that origin. A cross-origin hop travels unauthenticated, but a redirect back to the origin
|
|
714
|
+
// restores it -- so an authenticated flow that bounces off-origin and returns still completes (mirrors _tlsFor).
|
|
715
|
+
var authValue = null;
|
|
716
|
+
function _headersFor(u) {
|
|
717
|
+
if (!authValue || u.origin !== initialOrigin) return headers;
|
|
718
|
+
var hh = Object.assign({}, headers);
|
|
719
|
+
hh.authorization = authValue;
|
|
720
|
+
return hh;
|
|
721
|
+
}
|
|
731
722
|
function step() {
|
|
732
|
-
return transport({ method: method, url: url.href, headers:
|
|
723
|
+
return transport({ method: method, url: url.href, headers: _headersFor(url), body: body, tls: _tlsFor(url), timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
|
|
733
724
|
res = res || {};
|
|
734
725
|
// Measure an injected string body as UTF-8 -- the byte width the body is decoded/transfer-decoded at,
|
|
735
726
|
// and what the real socket transport counts -- so a non-ASCII body (multi-byte chars are ~half the
|
|
@@ -752,16 +743,10 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
752
743
|
body = null;
|
|
753
744
|
if (headers["content-type"]) { headers = Object.assign({}, headers); delete headers["content-type"]; }
|
|
754
745
|
}
|
|
755
|
-
var prevOrigin = url.origin;
|
|
756
746
|
url = _redirectTarget(url, h.location, method, opts);
|
|
757
|
-
// Credentials MUST NOT cross an origin boundary
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
// of private-key possession are never presented to a different server.
|
|
761
|
-
if (url.origin !== prevOrigin) {
|
|
762
|
-
if (headers.authorization) { headers = Object.assign({}, headers); delete headers.authorization; }
|
|
763
|
-
if (tls && (tls.cert || tls.key)) { tls = Object.assign({}, tls); delete tls.cert; delete tls.key; }
|
|
764
|
-
}
|
|
747
|
+
// Credentials MUST NOT cross an origin boundary: the HTTP Basic Authorization header and the
|
|
748
|
+
// ORIGIN-SPECIFIC TLS identity (mTLS cert/key + pinned SNI) are BOTH scoped per hop (_headersFor /
|
|
749
|
+
// _tlsFor), so each is absent off-origin but restored on a hop back to the caller's configured origin.
|
|
765
750
|
redirects += 1;
|
|
766
751
|
return step();
|
|
767
752
|
}
|
|
@@ -774,7 +759,9 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
774
759
|
var www = String(h["www-authenticate"] || "");
|
|
775
760
|
if (!_hasBasicChallenge(www)) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic is supported): " + www);
|
|
776
761
|
if (opts.username === undefined && opts.password === undefined) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
|
|
777
|
-
|
|
762
|
+
// Establish the credential as origin-scoped state (_headersFor attaches it only on the initial origin),
|
|
763
|
+
// never a mutation of the shared headers that would leak across a cross-origin redirect.
|
|
764
|
+
authValue = "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64");
|
|
778
765
|
authTried = true;
|
|
779
766
|
return step();
|
|
780
767
|
}
|
package/lib/http-transport.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
var nodeHttps = require("node:https");
|
|
40
40
|
var nodeNet = require("node:net");
|
|
41
41
|
var nodeTls = require("node:tls");
|
|
42
|
+
var nodeDns = require("node:dns");
|
|
42
43
|
var constants = require("./constants");
|
|
43
44
|
var guard = require("./guard-all");
|
|
44
45
|
var frameworkError = require("./framework-error");
|
|
@@ -97,11 +98,81 @@ function _systemCa() {
|
|
|
97
98
|
return out;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
// Classify a bare IP string (no brackets) as a private / loopback / link-local / reserved destination an
|
|
102
|
+
// untrusted URL must not reach: RFC 1918 + loopback + this-network + multicast/reserved + 169.254 (cloud
|
|
103
|
+
// metadata) + CGNAT for IPv4, and loopback / unspecified / IPv4-mapped / ULA (fc00::/7) + link-local (fe80::/10)
|
|
104
|
+
// for IPv6. This is the SAME range set the AIA literal pre-check applies (pki.path.build reuses it), enforced
|
|
105
|
+
// here at DNS-RESOLUTION time so a hostname pointing AT an internal address is caught too. A malformed IP fails
|
|
106
|
+
// CLOSED (net.isIP === 0 -> not a v4/v6 arm -> the caller treats a non-IP as un-judgeable, never as public).
|
|
107
|
+
function _isBlockedIp(ip) {
|
|
108
|
+
var fam = nodeNet.isIP(ip);
|
|
109
|
+
if (fam === 4) {
|
|
110
|
+
// net.isIP === 4 guarantees exactly four octets 0..255. Block the COMPLETE IANA special-purpose /
|
|
111
|
+
// non-global set (RFC 6890) so an untrusted destination can reach ONLY globally-routable public space.
|
|
112
|
+
var o = ip.split("."), a = +o[0], b = +o[1], c = +o[2];
|
|
113
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 || // this-network / RFC1918 10/8 / loopback / multicast 224/4 + reserved 240/4 + broadcast
|
|
114
|
+
(a === 100 && b >= 64 && b <= 127) || // 100.64/10 CGNAT
|
|
115
|
+
(a === 169 && b === 254) || // 169.254/16 link-local (cloud metadata)
|
|
116
|
+
(a === 172 && b >= 16 && b <= 31) || // 172.16/12
|
|
117
|
+
(a === 192 && b === 168) || // 192.168/16
|
|
118
|
+
(a === 192 && b === 0 && (c === 0 || c === 2)) || // 192.0.0/24 IETF protocol + 192.0.2/24 TEST-NET-1
|
|
119
|
+
(a === 192 && b === 88 && c === 99) || // 192.88.99/24 6to4 relay anycast (deprecated)
|
|
120
|
+
(a === 198 && (b === 18 || b === 19)) || // 198.18/15 benchmarking
|
|
121
|
+
(a === 198 && b === 51 && c === 100) || // 198.51.100/24 TEST-NET-2
|
|
122
|
+
(a === 203 && b === 0 && c === 113); // 203.0.113/24 TEST-NET-3
|
|
123
|
+
}
|
|
124
|
+
if (fam === 6) {
|
|
125
|
+
var l = ip.toLowerCase();
|
|
126
|
+
if (l.indexOf("::ffff:") === 0) return true; // IPv4-mapped -- may embed a private v4; block all (fail-closed)
|
|
127
|
+
var parts = l.split(":");
|
|
128
|
+
var h = parseInt(parts[0], 16); // first hextet ("" for a leading "::" -> NaN -> not in 2000::/3 -> blocked)
|
|
129
|
+
if (!(h >= 0x2000 && h <= 0x3fff)) return true; // outside global unicast 2000::/3: loopback/ULA/link-local/site-local/multicast(ff00::/8)/unspecified/unallocated
|
|
130
|
+
// Within 2000::/3, carve out the non-globally-routable special-purpose sub-ranges (IANA IPv6 Special-Purpose
|
|
131
|
+
// Address Registry) an attacker could route to an internal service: a first-hextet allow of the whole block
|
|
132
|
+
// would admit them. The second hextet is 0 when "::" compresses it (parts[1] empty).
|
|
133
|
+
var h2 = parts[1] ? parseInt(parts[1], 16) : 0;
|
|
134
|
+
if (h === 0x2002) return true; // 2002::/16 6to4 (embeds an IPv4 that may be private)
|
|
135
|
+
if (h === 0x2001 && h2 < 0x0200) return true; // 2001::/23 IETF protocol assignments (Teredo / benchmarking / ORCHID / AMT / ...)
|
|
136
|
+
if (h === 0x2001 && h2 === 0x0db8) return true; // 2001:db8::/32 documentation (RFC 3849)
|
|
137
|
+
if (h === 0x3fff && h2 < 0x1000) return true; // 3fff::/20 documentation (RFC 9637)
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A DNS-rebinding-safe SSRF filter, installed as node's `lookup` ONLY when a request opts into
|
|
144
|
+
// blockPrivateAddresses (the AIA fetch of an untrusted-cert URL). node connects to EXACTLY the address this
|
|
145
|
+
// returns -- no second resolution -- so checking the resolved address here PINS it, closing the resolve/connect
|
|
146
|
+
// TOCTOU. Any private / loopback / link-local result fails the lookup, which surfaces as transport/blocked-address.
|
|
147
|
+
function _blockedAddrErr(hostname, address) {
|
|
148
|
+
var e = new Error("refusing to connect to " + hostname + " -> " + address + " (private / loopback / link-local address blocked)");
|
|
149
|
+
e.pkiBlockedAddress = true;
|
|
150
|
+
return e;
|
|
151
|
+
}
|
|
152
|
+
// Built over an injectable resolver (defaults to nodeDns.lookup) so every branch -- a resolve error, the
|
|
153
|
+
// options.all array shape, a blocked result, a permitted result -- is unit-testable without a live DNS.
|
|
154
|
+
function _makeGuardedLookup(lookupFn) {
|
|
155
|
+
return function guardedLookup(hostname, options, callback) {
|
|
156
|
+
lookupFn(hostname, options || {}, function (err, address, family) {
|
|
157
|
+
if (err) return callback(err);
|
|
158
|
+
if (Array.isArray(address)) { // options.all -> [{ address, family }, ...]; reject if ANY resolved address is blocked
|
|
159
|
+
for (var i = 0; i < address.length; i++) if (_isBlockedIp(address[i].address)) return callback(_blockedAddrErr(hostname, address[i].address));
|
|
160
|
+
return callback(null, address);
|
|
161
|
+
}
|
|
162
|
+
if (_isBlockedIp(address)) return callback(_blockedAddrErr(hostname, address));
|
|
163
|
+
return callback(null, address, family);
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
var _guardedLookup = _makeGuardedLookup(nodeDns.lookup);
|
|
168
|
+
|
|
100
169
|
// Classify a node request/TLS error into the transport's fail-closed verdict: a
|
|
101
|
-
// protocol-version mismatch is the TLS floor; a
|
|
102
|
-
// failure is a server-authentication failure;
|
|
103
|
-
// error. Every arm threads the raw fault as
|
|
170
|
+
// blocked-address lookup rejection; a protocol-version mismatch is the TLS floor; a
|
|
171
|
+
// certificate / identity / handshake failure is a server-authentication failure;
|
|
172
|
+
// anything else is a generic transport error. Every arm threads the raw fault as
|
|
173
|
+
// `.cause`, so the diagnostic survives.
|
|
104
174
|
function _classifyError(e, C) {
|
|
175
|
+
if (e && e.pkiBlockedAddress) return C("blocked-address");
|
|
105
176
|
var s = String((e && e.code) || "") + " " + String((e && e.message) || "");
|
|
106
177
|
if (/PROTOCOL_VERSION|UNSUPPORTED_PROTOCOL|VERSION_TOO_LOW|WRONG_VERSION|NO_PROTOCOLS_AVAILABLE|INAPPROPRIATE_FALLBACK/i.test(s)) return C("tls-floor");
|
|
107
178
|
if (/CERT|SELF.?SIGNED|VERIFY|ALTNAME|HOSTNAME|DEPTH_ZERO|LOCAL_ISSUER|HANDSHAKE|\bSSL\b|\bTLS\b/i.test(s)) return C("server-auth-failed");
|
|
@@ -114,7 +185,7 @@ function _classifyError(e, C) {
|
|
|
114
185
|
* @since 0.3.16
|
|
115
186
|
* @status experimental
|
|
116
187
|
* @spec RFC 7030, RFC 8996
|
|
117
|
-
* @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770)
|
|
188
|
+
* @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770), ssrf (CWE-918)
|
|
118
189
|
* @related pki.est.cacerts, pki.est.simpleenroll
|
|
119
190
|
*
|
|
120
191
|
* Build a fail-closed `node:https` transport: `transport(request) -> Promise<{ status,
|
|
@@ -139,6 +210,7 @@ function _classifyError(e, C) {
|
|
|
139
210
|
* - `tls.minVersion` -- 'TLSv1.2' (default) or 'TLSv1.3'; never below the floor.
|
|
140
211
|
* - `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
|
|
141
212
|
* - `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
|
|
213
|
+
* - `blockPrivateAddresses` -- boolean; when true, an IP-literal host OR a hostname resolving to a private / loopback / link-local address is refused (`transport/blocked-address`), and a resolved address is pinned for the connection. For fetching an untrusted-certificate URL (AIA caIssuers); default false.
|
|
142
214
|
* @example
|
|
143
215
|
* var t = pki.transport.https({ tls: { anchors: [caPem] } });
|
|
144
216
|
* var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
|
|
@@ -240,11 +312,23 @@ function httpsTransport(defaults) {
|
|
|
240
312
|
return callerCsi(host, cert2);
|
|
241
313
|
};
|
|
242
314
|
}
|
|
315
|
+
// SSRF at resolution time: when a request opts in (the AIA fetch of an untrusted-cert URL), install a
|
|
316
|
+
// lookup that refuses -- and pins -- a private / loopback / link-local resolved address, so a hostname
|
|
317
|
+
// pointing at an internal service is blocked even though the literal-address check saw only a DNS name.
|
|
318
|
+
// A STRICT boolean true (a truthy string/object is treated as absent) keeps a malformed config fail-open-free.
|
|
319
|
+
var blockPrivate = (request.blockPrivateAddresses !== undefined ? request.blockPrivateAddresses : defaults.blockPrivateAddresses) === true;
|
|
320
|
+
if (blockPrivate) {
|
|
321
|
+
// Node does NOT invoke a custom `lookup` for an IP-LITERAL host (there is nothing to resolve), so the
|
|
322
|
+
// resolver alone would let a literal private / loopback / link-local destination through. Reject a blocked
|
|
323
|
+
// literal here, before installing the resolver -- so the option blocks a literal AND a resolved hostname.
|
|
324
|
+
if (_isBlockedIp(host)) throw E(C("blocked-address"), "refusing to connect to the private / loopback / link-local address literal " + host);
|
|
325
|
+
options.lookup = _guardedLookup;
|
|
326
|
+
}
|
|
243
327
|
|
|
244
328
|
return { options: options, timeout: timeout, maxBytes: maxBytes, body: body };
|
|
245
329
|
}
|
|
246
330
|
|
|
247
|
-
|
|
331
|
+
var _transportFn = function transport(request) {
|
|
248
332
|
request = request || {};
|
|
249
333
|
var prep;
|
|
250
334
|
try { prep = _prepare(request); }
|
|
@@ -318,6 +402,11 @@ function httpsTransport(defaults) {
|
|
|
318
402
|
} catch (e) { fail(C("transport-error"), "the request could not be initiated: " + ((e && e.message) || String(e)), e); }
|
|
319
403
|
});
|
|
320
404
|
};
|
|
405
|
+
// Advertise that this transport HONORS the blockPrivateAddresses request flag (it filters and pins a resolved
|
|
406
|
+
// address). A consumer of an UNTRUSTED URL (pki.path.build's AIA fetch) checks this marker before relying on the
|
|
407
|
+
// flag for SSRF protection -- an injected transport that does not set it is treated as unguarded (fail-closed).
|
|
408
|
+
_transportFn.blocksPrivateAddresses = true;
|
|
409
|
+
return _transportFn;
|
|
321
410
|
}
|
|
322
411
|
|
|
323
|
-
module.exports = { https: httpsTransport };
|
|
412
|
+
module.exports = { https: httpsTransport, isBlockedIp: _isBlockedIp, _makeGuardedLookup: _makeGuardedLookup, MAX_TIMEOUT: MAX_TIMEOUT };
|
package/lib/inspect.js
CHANGED
|
@@ -381,6 +381,26 @@ var EXT_RENDERERS = {
|
|
|
381
381
|
},
|
|
382
382
|
cRLDistributionPoints: _renderCrlDp,
|
|
383
383
|
freshestCRL: _renderCrlDp,
|
|
384
|
+
authorityInfoAccess: function (decoded, inner) {
|
|
385
|
+
// AccessDescription list: <accessMethod> - <accessLocation>. The method resolves to its name (caIssuers /
|
|
386
|
+
// ocsp); the accessLocation is a GeneralName (a URI in the common case). An unregistered method / an
|
|
387
|
+
// uncommon accessLocation tag falls back to the raw OID / bracketed tag rather than dropping the entry.
|
|
388
|
+
var LABEL = { caIssuers: "CA Issuers", ocsp: "OCSP" };
|
|
389
|
+
return (decoded || []).map(function (ad) {
|
|
390
|
+
var m = null;
|
|
391
|
+
try { m = oid.name(ad.accessMethod); } catch (_e) { /* allow:swallow-unverified display best-effort: an unregistered accessMethod OID falls back to the raw dotted OID below (inspection is best-effort, never a verdict) */ }
|
|
392
|
+
var loc = ad.accessLocation || {}, lv;
|
|
393
|
+
// The string choices (URI/DNS/email) are IA5String values already control-byte-rejected at decode by the
|
|
394
|
+
// CVE-2009-2408 guard, so they are safe to emit directly. The iPAddress choice is a RAW 4/16-byte Buffer --
|
|
395
|
+
// render it through _ipString (never raw), so a byte such as 0x0a cannot inject a line and spoof a field.
|
|
396
|
+
if (loc.tag === 6) lv = "URI:" + loc.value;
|
|
397
|
+
else if (loc.tag === 2) lv = "DNS:" + loc.value;
|
|
398
|
+
else if (loc.tag === 1) lv = "email:" + loc.value;
|
|
399
|
+
else if (loc.tag === 7) lv = "IP:" + _ipString(loc.value);
|
|
400
|
+
else lv = typeof loc.value === "string" ? loc.value : "[" + loc.tag + "]";
|
|
401
|
+
return inner + (LABEL[m] || m || ad.accessMethod) + " - " + lv;
|
|
402
|
+
}).join("\n");
|
|
403
|
+
},
|
|
384
404
|
nameConstraints: function (decoded, inner) {
|
|
385
405
|
var ncLines = [];
|
|
386
406
|
["permittedSubtrees:Permitted", "excludedSubtrees:Excluded"].forEach(function (pair) {
|
package/lib/lint.js
CHANGED
|
@@ -191,32 +191,9 @@ function _serialOctets(cert) {
|
|
|
191
191
|
return buf.length;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
// dNSName syntax
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
function _dnsNameProblem(s) {
|
|
198
|
-
if (typeof s !== "string" || !s.length) return "empty";
|
|
199
|
-
if (s.length > 253) return "exceeds 253 octets";
|
|
200
|
-
if (/\s/.test(s)) return "whitespace";
|
|
201
|
-
if (s.charAt(0) === "." || s.charAt(s.length - 1) === ".") return "leading/trailing dot";
|
|
202
|
-
if (s.indexOf("_") !== -1) return "underscore forbidden in dNSName";
|
|
203
|
-
var labels = s.split(".");
|
|
204
|
-
for (var i = 0; i < labels.length; i++) {
|
|
205
|
-
var label = labels[i];
|
|
206
|
-
if (label.length === 0) return "empty label";
|
|
207
|
-
if (label.length > 63) return "label exceeds 63 octets";
|
|
208
|
-
// A leftmost "*" wildcard label is permitted only when at least one more label follows
|
|
209
|
-
// (a bare "*" is not a domain name).
|
|
210
|
-
if (i === 0 && label === "*") {
|
|
211
|
-
if (labels.length < 2) return "bare wildcard";
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
// RFC 1034 preferred name syntax: an LDH label that neither begins nor ends with a
|
|
215
|
-
// hyphen. Rejects "-bad" / "bad-" and any non-letter/digit/hyphen character.
|
|
216
|
-
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(label)) return "invalid label syntax";
|
|
217
|
-
}
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
194
|
+
// dNSName syntax check -- the shared RFC 5280 sec. 4.2.1.6 / RFC 1034 preferred-name validator (pkix), reused
|
|
195
|
+
// so the linter and the identity comparators agree on what a well-formed dNSName is. Returns a reason or null.
|
|
196
|
+
function _dnsNameProblem(s) { return pkix.dnsNameProblem(s); }
|
|
220
197
|
|
|
221
198
|
// A genuine IPv4 or IPv6 literal -- a CN validated against an iPAddress SAN rather than a
|
|
222
199
|
// dNSName. Routed through the shared strict validator (no node:net, so the toolkit needs no
|