@blamejs/core 0.18.53 → 0.18.55
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 +228 -0
- package/NOTICE +1 -1
- package/README.md +5 -5
- package/lib/agent-audit.js +27 -2
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +41 -33
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +73 -12
- package/lib/guard-regex.js +3 -5
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/guard-yaml.js +60 -15
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +29 -13
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +134 -22
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +186 -89
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +198 -42
- package/lib/mail-server-mx.js +191 -38
- package/lib/mail-server-net.js +281 -1
- package/lib/mail-server-pop3.js +89 -41
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +183 -35
- package/lib/mail-server-tls.js +48 -3
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/mcp.js +11 -3
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/network-tls.js +18 -0
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +672 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
* });
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
var codepointClass = require("../codepoint-class");
|
|
38
39
|
var lazyRequire = require("../lazy-require");
|
|
39
40
|
var safeBuffer = require("../safe-buffer");
|
|
40
41
|
|
|
@@ -104,16 +105,12 @@ function _detectIssues(headers, opts) {
|
|
|
104
105
|
});
|
|
105
106
|
continue;
|
|
106
107
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
"— header-injection defense in depth",
|
|
114
|
-
});
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
108
|
+
if (codepointClass.firstLineInjectionCharOffset(v) !== -1) {
|
|
109
|
+
issues.push({
|
|
110
|
+
kind: "header-value-control-byte", severity: "high", header: name,
|
|
111
|
+
snippet: "header `" + name + "` value contains CR / LF / NUL " +
|
|
112
|
+
"— header-injection defense in depth",
|
|
113
|
+
});
|
|
117
114
|
}
|
|
118
115
|
}
|
|
119
116
|
}
|
|
@@ -272,7 +272,14 @@ function create(opts) {
|
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
|
-
|
|
275
|
+
// Gated on "was an allowlist supplied", NOT on "is it non-empty". The
|
|
276
|
+
// derivation above already spells the two apart — `null` when the option is
|
|
277
|
+
// omitted, an array when it is given — and re-testing the length here
|
|
278
|
+
// collapsed them, so a pin built from configuration that returned zero
|
|
279
|
+
// entries admitted every client certificate instead of none. An allowlist
|
|
280
|
+
// that disappears when empty is a firewall rule set that opens when the
|
|
281
|
+
// last rule is deleted, and here that rule set is the front door.
|
|
282
|
+
if (allowList && !bCrypto().isCertRevoked(peerCert.raw, allowList)) {
|
|
276
283
|
return _refuse(req, res, "fingerprint-not-allowed", {
|
|
277
284
|
fingerprint: fp.colon,
|
|
278
285
|
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
|
@@ -402,7 +402,21 @@ function create(opts) {
|
|
|
402
402
|
|
|
403
403
|
if (parsed.rcode !== 0) {
|
|
404
404
|
// RFC 1035 §4.1.1 — non-zero RCODE. Surface and refuse caching.
|
|
405
|
-
|
|
405
|
+
//
|
|
406
|
+
// NXDOMAIN and everything else are DIFFERENT ANSWERS and no longer share
|
|
407
|
+
// a code. NXDOMAIN (RCODE 3) means the name does not exist, which for a
|
|
408
|
+
// policy fetch or a blocklist is the ordinary case: no MTA-STS record, not
|
|
409
|
+
// on the list. SERVFAIL and REFUSED mean the question was not answered,
|
|
410
|
+
// and reading those as absence is a downgrade an attacker can arrange —
|
|
411
|
+
// break the lookup and the policy that would have refused you disappears.
|
|
412
|
+
//
|
|
413
|
+
// One code called `nxdomain-or-error` forced both readings on every
|
|
414
|
+
// caller, and both callers picked absence: `safeResolveTxt` could not
|
|
415
|
+
// match it at all so the commonest form of absence threw, and `b.mail.rbl`
|
|
416
|
+
// matched it and returned "not listed" for a lookup that failed.
|
|
417
|
+
var absent = parsed.rcode === 3; // RFC 1035 §4.1.1 NXDOMAIN
|
|
418
|
+
throw new ResolverError(
|
|
419
|
+
absent ? "resolver/nxdomain" : "resolver/query-failed",
|
|
406
420
|
"query: upstream RCODE=" + parsed.rcode + " for " + name + "/" + qtype);
|
|
407
421
|
}
|
|
408
422
|
|
|
@@ -501,6 +515,12 @@ function create(opts) {
|
|
|
501
515
|
queryCname: _typed("CNAME"),
|
|
502
516
|
queryMx: _typed("MX"),
|
|
503
517
|
queryNs: _typed("NS"),
|
|
518
|
+
// PTR was the one common type with no helper, though it has been in
|
|
519
|
+
// QTYPE_BY_NAME all along. `b.mail.helo` called `resolver.queryPtr` for its
|
|
520
|
+
// forward-confirmed reverse DNS check, got `undefined`, and the resulting
|
|
521
|
+
// TypeError landed in a catch written for NXDOMAIN — so the check reported
|
|
522
|
+
// a clean "no reverse name" for every address instead of a broken call.
|
|
523
|
+
queryPtr: _typed("PTR"),
|
|
504
524
|
queryTxt: _typed("TXT"),
|
|
505
525
|
querySrv: _typed("SRV"),
|
|
506
526
|
queryTlsa: _typed("TLSA"),
|
|
@@ -533,12 +553,31 @@ function _defaultTransport(timeoutMs) {
|
|
|
533
553
|
async function _wireLookup(name, qtype, timeoutMs) {
|
|
534
554
|
var ms = typeof timeoutMs === "number" && isFinite(timeoutMs) && timeoutMs > 0
|
|
535
555
|
? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
536
|
-
|
|
556
|
+
// Where the deployment's DoH actually points. This used to be a hardcoded
|
|
557
|
+
// public provider, so an operator who had configured DoH to their own
|
|
558
|
+
// resolver still had every resolver.query() leave for the public one — and
|
|
559
|
+
// any statement they made about that resolver (that it validates DNSSEC, that
|
|
560
|
+
// it serves a split-horizon zone) described a resolver the query never
|
|
561
|
+
// reached. b.mail.send.deliver's `policy.dnssecValidated` is exactly such a
|
|
562
|
+
// statement. The endpoint is read per lookup rather than captured at create,
|
|
563
|
+
// so a later useDnsOverHttps reaches the next query instead of a handle's
|
|
564
|
+
// whole lifetime being pinned to boot-time configuration.
|
|
565
|
+
var endpoint = networkDns.activeDohEndpoint();
|
|
566
|
+
var url = (endpoint && endpoint.url) || "https://cloudflare-dns.com/dns-query";
|
|
537
567
|
// Encode a wire-format query for the target qtype.
|
|
538
568
|
var qbuf = _encodeWireQuery(name, qtype);
|
|
539
569
|
var b64 = bCrypto.toBase64Url(qbuf);
|
|
540
570
|
var getUrl = url + (url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
541
|
-
|
|
571
|
+
// RFC 8484 §4.1 — GET carries the query in the URL, POST in the body. An
|
|
572
|
+
// endpoint may accept only POST, and `useDnsOverHttps({ method })` says so;
|
|
573
|
+
// honouring the operator's URL while ignoring their method leaves that
|
|
574
|
+
// endpoint unusable. With no method configured the choice falls to URL
|
|
575
|
+
// length, using the threshold b.network.dns owns rather than a second copy
|
|
576
|
+
// of the number.
|
|
577
|
+
var maxGet = (endpoint && endpoint.getUrlMaxBytes) || 2048; // RFC 9112 §3-conservative GET ceiling
|
|
578
|
+
var usePost = (endpoint && endpoint.method === "POST") ||
|
|
579
|
+
(!(endpoint && endpoint.method) && getUrl.length > maxGet);
|
|
580
|
+
var u = safeUrl.parse(usePost ? url : getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
542
581
|
return new Promise(function (resolve, reject) {
|
|
543
582
|
var settled = false;
|
|
544
583
|
function _fail(err) {
|
|
@@ -555,13 +594,23 @@ async function _wireLookup(name, qtype, timeoutMs) {
|
|
|
555
594
|
// Raw DoH wire-format request — bypasses b.httpClient envelope
|
|
556
595
|
// because we need the raw binary response bytes for safeDns to
|
|
557
596
|
// parse (httpClient assumes JSON/text shapes).
|
|
558
|
-
|
|
597
|
+
// The operator's CA travels with the endpoint. A private DoH resolver is
|
|
598
|
+
// usually issued by a private CA, so honouring the URL without the trust
|
|
599
|
+
// anchor that validates it would swap one unreachable endpoint for another.
|
|
600
|
+
var headers = { "accept": "application/dns-message" };
|
|
601
|
+
if (usePost) {
|
|
602
|
+
headers["content-type"] = "application/dns-message";
|
|
603
|
+
headers["content-length"] = String(qbuf.length);
|
|
604
|
+
}
|
|
605
|
+
var reqOpts = Object.assign({
|
|
559
606
|
hostname: u.hostname,
|
|
560
607
|
port: u.port || 443, // HTTPS port
|
|
561
608
|
path: u.pathname + u.search,
|
|
562
|
-
method: "GET",
|
|
563
|
-
headers:
|
|
564
|
-
}, networkTls().outboundPosture())
|
|
609
|
+
method: usePost ? "POST" : "GET",
|
|
610
|
+
headers: headers,
|
|
611
|
+
}, networkTls().outboundPosture());
|
|
612
|
+
if (endpoint && endpoint.ca) reqOpts.ca = endpoint.ca;
|
|
613
|
+
var req = https.request(reqOpts, function (res) { // allow:raw-outbound-http-framework-internal — DoH wire-format response bytes; b.httpClient envelopes assume text/JSON, and httpClient → ssrfGuard → DNS → DoH would form a cycle
|
|
565
614
|
var collector = safeBuffer.boundedChunkCollector({
|
|
566
615
|
maxBytes: C.BYTES.kib(64),
|
|
567
616
|
errorClass: ResolverError,
|
|
@@ -599,6 +648,8 @@ async function _wireLookup(name, qtype, timeoutMs) {
|
|
|
599
648
|
_fail(new ResolverError("resolver/upstream-failed",
|
|
600
649
|
"DoH request failed: " + e.message));
|
|
601
650
|
});
|
|
651
|
+
// POST carries the wire-format query as the body; GET carried it in the URL.
|
|
652
|
+
if (usePost) req.write(qbuf);
|
|
602
653
|
req.end();
|
|
603
654
|
});
|
|
604
655
|
}
|
|
@@ -709,7 +760,19 @@ async function safeResolveTxt(qname, opts) {
|
|
|
709
760
|
try {
|
|
710
761
|
return await resolveTxt(qname, opts.dnsLookup);
|
|
711
762
|
} catch (e) {
|
|
712
|
-
|
|
763
|
+
// `resolver/nxdomain` belongs here and was the omission: it is how THIS
|
|
764
|
+
// framework's own resolver reports a name that does not exist, which is the
|
|
765
|
+
// commonest way a policy record is absent. Only ENOTFOUND / ENODATA were
|
|
766
|
+
// listed — the codes node's stub resolver uses — so the documented
|
|
767
|
+
// "absence is not an error" convention held for an operator-supplied
|
|
768
|
+
// `dnsLookup` and not for the resolver the module ships with.
|
|
769
|
+
//
|
|
770
|
+
// `resolver/query-failed` deliberately does NOT belong here. A lookup that
|
|
771
|
+
// was not answered is not a record that is not published, and treating it
|
|
772
|
+
// as one lets an attacker who can break the query delete the policy that
|
|
773
|
+
// would have refused them.
|
|
774
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA" ||
|
|
775
|
+
e.code === "resolver/nxdomain")) return null;
|
|
713
776
|
var msg = "TXT lookup for " + qname + " failed: " + ((e && e.message) || String(e));
|
|
714
777
|
if (typeof opts.errorFactory === "function") throw opts.errorFactory(opts.code, msg);
|
|
715
778
|
throw e;
|
package/lib/network-dns.js
CHANGED
|
@@ -437,6 +437,31 @@ function useDnsOverHttps(opts) {
|
|
|
437
437
|
observability().safeEvent("network.dns.doh.set", 1, { url: url, method: method || "auto" });
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
// activeDohEndpoint() — where DoH queries go right now, or null when the
|
|
441
|
+
// deployment is not on DoH (the operator chose DoT or the system resolver).
|
|
442
|
+
//
|
|
443
|
+
// b.network.dns.resolver runs its own wire-format request, because it needs the
|
|
444
|
+
// raw response bytes for arbitrary record types and this module's DoH path
|
|
445
|
+
// returns decoded addresses. Reading the endpoint from here is what keeps the
|
|
446
|
+
// two agreeing: the resolver previously hardcoded a public provider, so a
|
|
447
|
+
// deployment that had configured DoH to its own resolver — for split horizon,
|
|
448
|
+
// for privacy, or because that resolver validates DNSSEC — had every
|
|
449
|
+
// resolver.query() go to the public one instead, silently.
|
|
450
|
+
// `getUrlMaxBytes` travels with it because the method is not always configured:
|
|
451
|
+
// RFC 8484 §4.1 lets a client switch to POST when the GET URL would be too
|
|
452
|
+
// long, and that threshold is this module's to choose. Returning it keeps the
|
|
453
|
+
// resolver's decision identical to the one made here rather than a second
|
|
454
|
+
// number that can drift.
|
|
455
|
+
function activeDohEndpoint() {
|
|
456
|
+
if (!STATE.doh) return null;
|
|
457
|
+
return {
|
|
458
|
+
url: STATE.doh.url,
|
|
459
|
+
method: STATE.doh.method || null,
|
|
460
|
+
ca: STATE.doh.ca || null,
|
|
461
|
+
getUrlMaxBytes: DOH_GET_URL_MAX_BYTES,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
440
465
|
function useDnsOverTls(opts) {
|
|
441
466
|
opts = opts || {};
|
|
442
467
|
validateOpts(opts, ["host", "port", "servername", "ca"], "dns.useDnsOverTls");
|
|
@@ -2259,6 +2284,7 @@ module.exports = {
|
|
|
2259
2284
|
setLookupTimeoutMs: setLookupTimeoutMs,
|
|
2260
2285
|
setCacheTtlMs: setCacheTtlMs,
|
|
2261
2286
|
useDnsOverHttps: useDnsOverHttps,
|
|
2287
|
+
activeDohEndpoint: activeDohEndpoint,
|
|
2262
2288
|
useDnsOverTls: useDnsOverTls,
|
|
2263
2289
|
useSystemResolver: useSystemResolver,
|
|
2264
2290
|
useDesignatedResolvers: useDesignatedResolvers,
|
|
@@ -243,17 +243,49 @@ async function daneTlsa(domain, port, opts) {
|
|
|
243
243
|
opts = opts || {};
|
|
244
244
|
var p = typeof port === "number" ? port : 25; // IANA SMTP port
|
|
245
245
|
var qname = "_" + p + "._tcp." + domain.toLowerCase();
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
246
|
+
|
|
247
|
+
// `opts.resolver` is the caller's own resolver — the one whose DNSSEC posture
|
|
248
|
+
// `opts.dnssecValidated` is an assertion ABOUT. Falling back to node:dns here
|
|
249
|
+
// when one was supplied would mean the assertion described resolver A while
|
|
250
|
+
// the records arrived from resolver B, so a non-validating system resolver
|
|
251
|
+
// could hand over spoofed TLSA data that DANE then treats as authenticated.
|
|
252
|
+
// A supplied resolver that cannot answer TLSA is refused rather than bypassed.
|
|
251
253
|
var records;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
if (opts.resolver) {
|
|
255
|
+
if (typeof opts.resolver.queryTlsa !== "function") {
|
|
256
|
+
throw new SmtpPolicyError("smtp/dane-resolver-no-tlsa",
|
|
257
|
+
"dane.tlsa: opts.resolver was supplied but has no queryTlsa(name); the " +
|
|
258
|
+
"records must come from the resolver whose DNSSEC posture was asserted, " +
|
|
259
|
+
"so falling back to the system resolver is refused");
|
|
260
|
+
}
|
|
261
|
+
var rv;
|
|
262
|
+
try { rv = await opts.resolver.queryTlsa(qname); }
|
|
263
|
+
catch (e0) {
|
|
264
|
+
if (e0 && e0.code === "resolver/nxdomain") return []; // no TLSA published
|
|
265
|
+
throw new SmtpPolicyError("smtp/dane-lookup-failed",
|
|
266
|
+
"TLSA lookup for " + qname + " failed: " + ((e0 && e0.message) || String(e0)));
|
|
267
|
+
}
|
|
268
|
+
// b.network.dns decodes TLSA into { usage, selector, matchingType, certData };
|
|
269
|
+
// node:dns names the same fields certUsage / selector / match / data. Mapped
|
|
270
|
+
// here so the normalisation below has one shape to read.
|
|
271
|
+
records = ((rv && rv.rrs) || []).filter(function (r) {
|
|
272
|
+
return r && r.decoded && typeof r.decoded.usage === "number";
|
|
273
|
+
}).map(function (r) {
|
|
274
|
+
return { certUsage: r.decoded.usage, selector: r.decoded.selector,
|
|
275
|
+
match: r.decoded.matchingType, data: r.decoded.certData };
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
// node:dns has resolveTlsa() since Node 18.16.0.
|
|
279
|
+
if (typeof dnsPromises.resolveTlsa !== "function") {
|
|
280
|
+
throw new SmtpPolicyError("smtp/dane-unavailable",
|
|
281
|
+
"node:dns.resolveTlsa is not available on this runtime");
|
|
282
|
+
}
|
|
283
|
+
try { records = await dnsPromises.resolveTlsa(qname); }
|
|
284
|
+
catch (e) {
|
|
285
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return [];
|
|
286
|
+
throw new SmtpPolicyError("smtp/dane-lookup-failed",
|
|
287
|
+
"TLSA lookup for " + qname + " failed: " + ((e && e.message) || String(e)));
|
|
288
|
+
}
|
|
257
289
|
}
|
|
258
290
|
// RFC 7672 §1.3 — TLSA records that are NOT DNSSEC-validated MUST
|
|
259
291
|
// NOT be used. node:dns.resolveTlsa does not surface the AD bit
|
package/lib/network-tls.js
CHANGED
|
@@ -549,6 +549,23 @@ function _stripUnreachableCertCompression(merged, caller) {
|
|
|
549
549
|
return merged;
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
// The key-agreement preference alone, as the string node:tls reads under
|
|
553
|
+
// `ecdhCurve`. `applyToContext` is the whole-context form and also merges the
|
|
554
|
+
// framework trust store, minimum version and certificate compression; a caller
|
|
555
|
+
// that only wants the group policy — a listener building its own server context
|
|
556
|
+
// — takes this instead, so it does not inherit a `ca` list it never asked for.
|
|
557
|
+
//
|
|
558
|
+
// An override is honoured, and a malformed one is REFUSED rather than replaced:
|
|
559
|
+
// quietly substituting the default would start a listener on groups the
|
|
560
|
+
// operator did not choose, with nothing said. Absent takes the framework
|
|
561
|
+
// default (the ML-KEM hybrids with a classical X25519 fallback).
|
|
562
|
+
function keyAgreementGroups(override, where) {
|
|
563
|
+
if (override !== undefined && override !== null) {
|
|
564
|
+
return _groupPreferenceString(override, where || "tls.keyAgreementGroups");
|
|
565
|
+
}
|
|
566
|
+
return STATE.tlsKeyShares.length > 0 ? STATE.tlsKeyShares.join(":") : null;
|
|
567
|
+
}
|
|
568
|
+
|
|
552
569
|
function applyToContext(opts) {
|
|
553
570
|
opts = opts || {};
|
|
554
571
|
validateOpts(opts, ["base"], "tls.applyToContext");
|
|
@@ -4138,6 +4155,7 @@ module.exports = {
|
|
|
4138
4155
|
captureBaselineFingerprints: captureBaselineFingerprints,
|
|
4139
4156
|
detectBaselineDrift: detectBaselineDrift,
|
|
4140
4157
|
applyToContext: applyToContext,
|
|
4158
|
+
keyAgreementGroups: keyAgreementGroups,
|
|
4141
4159
|
buildOptions: buildOptions,
|
|
4142
4160
|
getCaPems: getCaPems,
|
|
4143
4161
|
ocsp: ocsp,
|
package/lib/redact.js
CHANGED
|
@@ -527,8 +527,14 @@ function classifyDefaults(opts) {
|
|
|
527
527
|
"redact.classifyDefaults: patterns[" + p + "] must be a string, got " +
|
|
528
528
|
typeof patterns[p]);
|
|
529
529
|
}
|
|
530
|
+
// Own-key on BOTH tables. `extra` is operator-supplied, so reading it
|
|
531
|
+
// plainly resolved "constructor" / "toString" / "valueOf" to inherited
|
|
532
|
+
// functions: the name passed as known, and the scanner then met a function
|
|
533
|
+
// where a pattern spec belongs and threw an untyped error at scan time.
|
|
534
|
+
// A DLP classifier must fail at boot on a name it cannot honour, never at
|
|
535
|
+
// the moment it is asked to look.
|
|
530
536
|
if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_PATTERNS, patterns[p]) &&
|
|
531
|
-
!(opts.extra && opts.extra
|
|
537
|
+
!(opts.extra && Object.prototype.hasOwnProperty.call(opts.extra, patterns[p]))) {
|
|
532
538
|
throw new DlpError("redact-dlp/unknown-pattern",
|
|
533
539
|
"redact.classifyDefaults: unknown pattern '" + patterns[p] +
|
|
534
540
|
"'. Known: " + Object.keys(CLASSIFIER_PATTERNS).join(", "));
|
|
@@ -544,8 +550,12 @@ function classifyDefaults(opts) {
|
|
|
544
550
|
var extra = opts.extra || {};
|
|
545
551
|
|
|
546
552
|
function _resolve(name) {
|
|
547
|
-
|
|
548
|
-
|
|
553
|
+
// Own-key, matching the validation above: the names reaching here are
|
|
554
|
+
// already vetted, and reading through the prototype anyway would let a
|
|
555
|
+
// future caller that skips the vetting resolve a function as a spec.
|
|
556
|
+
if (Object.prototype.hasOwnProperty.call(CLASSIFIER_PATTERNS, name)) return CLASSIFIER_PATTERNS[name];
|
|
557
|
+
if (Object.prototype.hasOwnProperty.call(extra, name)) return extra[name];
|
|
558
|
+
return undefined;
|
|
549
559
|
}
|
|
550
560
|
|
|
551
561
|
return function classify(input) {
|
package/lib/retention.js
CHANGED
|
@@ -55,6 +55,9 @@ var { defineClass } = require("./framework-error");
|
|
|
55
55
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
56
56
|
var cryptoField = require("./crypto-field");
|
|
57
57
|
var legalHold = lazyRequire(function () { return require("./legal-hold"); });
|
|
58
|
+
// Lazy — compliance.js requires this module for its posture cascade, so the
|
|
59
|
+
// dependency is mutual and neither may load the other at module scope.
|
|
60
|
+
var compliance = lazyRequire(function () { return require("./compliance"); });
|
|
58
61
|
|
|
59
62
|
var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
|
|
60
63
|
var _err = RetentionError.factory;
|
|
@@ -648,10 +651,27 @@ function complianceFloor(posture, candidateTtlMs) {
|
|
|
648
651
|
var floor = Object.prototype.hasOwnProperty.call(COMPLIANCE_RETENTION_FLOOR_MS, posture)
|
|
649
652
|
? COMPLIANCE_RETENTION_FLOOR_MS[posture] : undefined;
|
|
650
653
|
if (floor === undefined) {
|
|
654
|
+
// "This regime sets no retention minimum" and "this is not a regime" are
|
|
655
|
+
// different facts, and the floor table only ever answered the first. Most
|
|
656
|
+
// postures the framework knows impose no minimum — GDPR Art. 5(1)(e) is
|
|
657
|
+
// storage LIMITATION, the opposite of a floor — so treating absence from
|
|
658
|
+
// this table as a typo reported 158 of the 169 postures b.compliance.set
|
|
659
|
+
// accepts as misspellings, and an operator who set one could not compute a
|
|
660
|
+
// TTL at all. The posture vocabulary lives in b.compliance; this table
|
|
661
|
+
// holds only the subset carrying a regulator-mandated minimum.
|
|
662
|
+
if (compliance().KNOWN_POSTURES.indexOf(posture) !== -1) return _atLeast(0, candidateTtlMs);
|
|
651
663
|
throw new RetentionError("retention/unknown-posture",
|
|
652
|
-
"complianceFloor: unknown posture '" + posture + "';
|
|
653
|
-
|
|
664
|
+
"complianceFloor: unknown posture '" + posture + "'; it is not in " +
|
|
665
|
+
"b.compliance.KNOWN_POSTURES. Postures carrying a retention minimum are " +
|
|
666
|
+
Object.keys(COMPLIANCE_RETENTION_FLOOR_MS).join(", ") + "; the rest resolve to 0");
|
|
654
667
|
}
|
|
668
|
+
return _atLeast(floor, candidateTtlMs);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// The floor wins unless the operator's candidate is longer. A candidate that
|
|
672
|
+
// is absent or not a positive finite number is no candidate at all, and the
|
|
673
|
+
// floor stands alone.
|
|
674
|
+
function _atLeast(floor, candidateTtlMs) {
|
|
655
675
|
if (typeof candidateTtlMs !== "number" || !isFinite(candidateTtlMs) || candidateTtlMs <= 0) {
|
|
656
676
|
return floor;
|
|
657
677
|
}
|
package/lib/safe-mount-info.js
CHANGED
|
@@ -81,6 +81,22 @@ var SafeMountInfoError = defineClass("SafeMountInfoError", { alwaysPermanent: tr
|
|
|
81
81
|
|
|
82
82
|
var DEFAULT_PATH = "/proc/self/mountinfo";
|
|
83
83
|
|
|
84
|
+
// The default path is a Linux path, and reaching for it anywhere else is not
|
|
85
|
+
// merely useless. A leading slash is DRIVE-relative on Windows, so the read
|
|
86
|
+
// lands on C:\proc\self\mountinfo — a location an unprivileged local user can
|
|
87
|
+
// create, because the default ACL on C:\ grants Authenticated Users the right
|
|
88
|
+
// to add subdirectories. Whoever plants that file authors the entries
|
|
89
|
+
// bestMatch() and isBindMount() answer from, which is to say they choose what
|
|
90
|
+
// the framework believes about which filesystem a path is on.
|
|
91
|
+
//
|
|
92
|
+
// So off Linux there is no default: a caller who genuinely has a mountinfo
|
|
93
|
+
// file elsewhere still passes opts.path, and everyone else is refused rather
|
|
94
|
+
// than answered from a file anyone could have written. Platform is a parameter
|
|
95
|
+
// so the Windows branch is reachable from a Linux CI host.
|
|
96
|
+
function _defaultPathFor(platform) {
|
|
97
|
+
return platform === "linux" ? DEFAULT_PATH : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
/**
|
|
85
101
|
* @primitive b.safeMountInfo.parse
|
|
86
102
|
* @signature b.safeMountInfo.parse(text, opts?)
|
|
@@ -187,12 +203,20 @@ function parse(text, opts) {
|
|
|
187
203
|
* @related b.safeMountInfo.parse, b.safeMountInfo.bestMatch
|
|
188
204
|
*
|
|
189
205
|
* Read + parse `/proc/self/mountinfo` in one call. Returns the same
|
|
190
|
-
* array shape as `parse(text)`.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
206
|
+
* array shape as `parse(text)`.
|
|
207
|
+
*
|
|
208
|
+
* The default path is used on Linux only. Elsewhere there is no default and
|
|
209
|
+
* `opts.fallback` (default `null`) is returned without reading anything, with
|
|
210
|
+
* an audit emission of `safe-mount-info.refused` carrying code
|
|
211
|
+
* `no-default-path`. `/proc` is not simply absent off Linux — a leading slash
|
|
212
|
+
* is drive-relative on Windows, so the default names `C:\proc\self\mountinfo`,
|
|
213
|
+
* which an unprivileged local user can create; reading it would let whoever
|
|
214
|
+
* wrote it decide which filesystem a path appears to be on. A caller holding a
|
|
215
|
+
* mountinfo file elsewhere passes `opts.path`, which is honored on every
|
|
216
|
+
* platform and reports a read failure as `read-failed`.
|
|
193
217
|
*
|
|
194
218
|
* @opts
|
|
195
|
-
* path: string, // override path
|
|
219
|
+
* path: string, // override path; on Linux defaults to /proc/self/mountinfo, elsewhere there is no default
|
|
196
220
|
* fallback: any, // returned on read failure (default null)
|
|
197
221
|
* audit: object, // optional b.audit handle for refusal events
|
|
198
222
|
* strict: boolean, // forwarded to parse()
|
|
@@ -211,12 +235,20 @@ function read(opts) {
|
|
|
211
235
|
"safeMountInfo.read");
|
|
212
236
|
var path = typeof opts.path === "string" && opts.path.length > 0
|
|
213
237
|
? opts.path
|
|
214
|
-
:
|
|
238
|
+
: _defaultPathFor(process.platform);
|
|
239
|
+
if (!path) {
|
|
240
|
+
_refuseEmit(opts, "safe-mount-info/no-default-path",
|
|
241
|
+
"/proc/self/mountinfo is a Linux path and this host is " + process.platform +
|
|
242
|
+
" — pass opts.path to name a mountinfo file explicitly.");
|
|
243
|
+
return ("fallback" in opts) ? opts.fallback : null;
|
|
244
|
+
}
|
|
215
245
|
var text;
|
|
216
246
|
try { text = nodeFs.readFileSync(path, "utf8"); }
|
|
217
247
|
catch (e) {
|
|
248
|
+
// Name the file that was actually read. A caller who passed opts.path and
|
|
249
|
+
// is told "/proc/self/mountinfo unreadable" is looking at the wrong file.
|
|
218
250
|
_refuseEmit(opts, "safe-mount-info/read-failed",
|
|
219
|
-
"
|
|
251
|
+
path + " unreadable: " + ((e && e.message) || String(e)));
|
|
220
252
|
return ("fallback" in opts) ? opts.fallback : null;
|
|
221
253
|
}
|
|
222
254
|
return parse(text, opts);
|
|
@@ -305,4 +337,5 @@ module.exports = {
|
|
|
305
337
|
isBindMount: isBindMount,
|
|
306
338
|
SafeMountInfoError: SafeMountInfoError,
|
|
307
339
|
DEFAULT_PATH: DEFAULT_PATH,
|
|
340
|
+
_defaultPathForForTest: _defaultPathFor,
|
|
308
341
|
};
|
package/lib/safe-smtp.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
var { defineClass } = require("./framework-error");
|
|
42
|
+
var guardSmtpCommand = require("./guard-smtp-command");
|
|
42
43
|
|
|
43
44
|
var SafeSmtpError = defineClass("SafeSmtpError", { alwaysPermanent: true });
|
|
44
45
|
|
|
@@ -110,7 +111,14 @@ function dotUnstuff(buf) {
|
|
|
110
111
|
}
|
|
111
112
|
var out = Buffer.alloc(buf.length);
|
|
112
113
|
var oi = 0;
|
|
113
|
-
|
|
114
|
+
// Offset 0 IS a line start. RFC 5321 §4.5.2 has the sender stuff a leading
|
|
115
|
+
// dot on any line of the body and grants the first line no exemption, but
|
|
116
|
+
// this only ever looked for a dot AFTER a CRLF — so a body beginning
|
|
117
|
+
// `..signature` was delivered with the extra dot still on it, and dotStuff,
|
|
118
|
+
// which does stuff offset 0, had a pair that would not undo it.
|
|
119
|
+
var start = 0;
|
|
120
|
+
if (buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x2e) start = 1;
|
|
121
|
+
for (var i = start; i < buf.length; i += 1) {
|
|
114
122
|
out[oi++] = buf[i];
|
|
115
123
|
// After \r\n, if the next byte is `.` followed by another non-CR
|
|
116
124
|
// byte (i.e., not the terminator itself), strip the stuffing dot.
|
|
@@ -179,9 +187,96 @@ function dotStuff(buf) {
|
|
|
179
187
|
return out.subarray(0, oi);
|
|
180
188
|
}
|
|
181
189
|
|
|
190
|
+
/**
|
|
191
|
+
* @primitive b.safeSmtp.createBodyScanner
|
|
192
|
+
* @signature b.safeSmtp.createBodyScanner()
|
|
193
|
+
* @since 0.18.55
|
|
194
|
+
* @status stable
|
|
195
|
+
* @related b.safeSmtp.findDotTerminator, b.guardSmtpCommand.detectBodySmuggling
|
|
196
|
+
*
|
|
197
|
+
* Watch a DATA body for its terminator and for the smuggling shape as it
|
|
198
|
+
* ARRIVES, in work proportional to each chunk rather than to everything
|
|
199
|
+
* received so far.
|
|
200
|
+
*
|
|
201
|
+
* A listener that answers "has the body ended yet?" by re-deriving the whole
|
|
202
|
+
* accumulated buffer on every chunk does O(n) work per chunk and O(n²) over the
|
|
203
|
+
* message. The byte cap still holds — the collector refuses a chunk that would
|
|
204
|
+
* cross it — but processor cost is not what a byte cap bounds, so a message
|
|
205
|
+
* inside the limit could still cost minutes. Measured before this existed:
|
|
206
|
+
* 1 MiB accepted in 143 ms and 8 MiB in 4949 ms, a growth of 4.67x for each
|
|
207
|
+
* doubling where linear would hold at 2.
|
|
208
|
+
*
|
|
209
|
+
* Each `push` scans only the new bytes plus a four-byte overlap from the
|
|
210
|
+
* previous chunk. Four is exact, not generous: the longest shape either screen
|
|
211
|
+
* matches spans five bytes (`\r\n.\r\n`, and the smuggling scan's widest
|
|
212
|
+
* lookahead), and a pattern of length L needs L-1 bytes of overlap to be caught
|
|
213
|
+
* across a boundary.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* var scan = b.safeSmtp.createBodyScanner();
|
|
217
|
+
* var seen = scan.push(chunk);
|
|
218
|
+
* if (seen.smuggling) refuse();
|
|
219
|
+
* if (seen.terminatorAt !== -1) finish(collector.result(), seen.terminatorAt);
|
|
220
|
+
*/
|
|
221
|
+
function createBodyScanner() {
|
|
222
|
+
var OVERLAP = 4; // longest matched shape (5) minus one
|
|
223
|
+
var consumed = 0; // bytes pushed before the current chunk
|
|
224
|
+
var tail = Buffer.alloc(0); // last OVERLAP bytes of what came before
|
|
225
|
+
var sawSmuggling = false;
|
|
226
|
+
var terminatorAt = -1;
|
|
227
|
+
// The byte immediately before `tail`, which the next window will not contain.
|
|
228
|
+
// Classifying a leading LF needs it: overlap gives every other offset its
|
|
229
|
+
// predecessor, but never offset zero, and widening the overlap only changes
|
|
230
|
+
// which byte is stranded there.
|
|
231
|
+
var beforeTail = -1;
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
push: function (chunk) {
|
|
235
|
+
if (!Buffer.isBuffer(chunk)) {
|
|
236
|
+
throw new SafeSmtpError("safe-smtp/bad-input",
|
|
237
|
+
"createBodyScanner.push: chunk must be a Buffer");
|
|
238
|
+
}
|
|
239
|
+
// Once a verdict is reached it stands: the terminator does not move and a
|
|
240
|
+
// smuggling shape does not un-happen. Later chunks are still counted so
|
|
241
|
+
// the reported offset stays absolute.
|
|
242
|
+
var window = tail.length === 0 ? chunk : Buffer.concat([tail, chunk]);
|
|
243
|
+
var windowStart = consumed - tail.length;
|
|
244
|
+
|
|
245
|
+
// `windowStart === 0`, NOT `consumed === 0`: with the overlap carried in,
|
|
246
|
+
// a window can still begin at body offset 0 after bytes have been
|
|
247
|
+
// consumed. One-byte chunks are the case — the first push is a lone `.`
|
|
248
|
+
// (too short to match), and the second window is `.\n` starting at 0 with
|
|
249
|
+
// one byte already consumed. Testing `consumed` there skipped the
|
|
250
|
+
// dot-at-body-start shape entirely.
|
|
251
|
+
if (!sawSmuggling &&
|
|
252
|
+
guardSmtpCommand.detectBodySmuggling(window, windowStart === 0,
|
|
253
|
+
beforeTail === 0x0d)) {
|
|
254
|
+
sawSmuggling = true;
|
|
255
|
+
}
|
|
256
|
+
if (terminatorAt === -1) {
|
|
257
|
+
var at = findDotTerminator(window);
|
|
258
|
+
if (at !== -1) terminatorAt = windowStart + at;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
consumed += chunk.length;
|
|
262
|
+
// Capture the byte the next window will drop off its front, before the
|
|
263
|
+
// window itself goes out of scope. When the window is no longer than the
|
|
264
|
+
// overlap nothing is dropped, and the next window still starts at body
|
|
265
|
+
// offset 0, where there is no preceding byte to carry.
|
|
266
|
+
beforeTail = window.length > OVERLAP ? window[window.length - OVERLAP - 1] : -1;
|
|
267
|
+
tail = window.length <= OVERLAP ? window : window.subarray(window.length - OVERLAP);
|
|
268
|
+
return { smuggling: sawSmuggling, terminatorAt: terminatorAt };
|
|
269
|
+
},
|
|
270
|
+
// What the scanner has been shown, so a caller can check its own accounting
|
|
271
|
+
// against this one rather than keeping a second counter.
|
|
272
|
+
bytesSeen: function () { return consumed; },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
182
276
|
module.exports = {
|
|
183
277
|
findDotTerminator: findDotTerminator,
|
|
184
278
|
dotUnstuff: dotUnstuff,
|
|
185
279
|
dotStuff: dotStuff,
|
|
280
|
+
createBodyScanner: createBodyScanner,
|
|
186
281
|
SafeSmtpError: SafeSmtpError,
|
|
187
282
|
};
|
package/lib/safe-url.js
CHANGED
|
@@ -312,7 +312,11 @@ function _normalizePctPath(path) {
|
|
|
312
312
|
*/
|
|
313
313
|
function parse(url, opts) {
|
|
314
314
|
opts = opts || {};
|
|
315
|
-
|
|
315
|
+
// The documented default applies when the option is OMITTED. An explicitly
|
|
316
|
+
// empty list is a caller saying no protocol is acceptable, and widening that
|
|
317
|
+
// back to https: answers "none" with "one" — an allowlist that disappears
|
|
318
|
+
// when empty is a firewall rule set that opens when the last rule is deleted.
|
|
319
|
+
var allowed = Array.isArray(opts.allowedProtocols)
|
|
316
320
|
? opts.allowedProtocols
|
|
317
321
|
: ALLOW_HTTP_TLS;
|
|
318
322
|
var errClass = opts.errorClass;
|
|
@@ -531,7 +535,9 @@ function format(url) {
|
|
|
531
535
|
*/
|
|
532
536
|
function canonicalize(input, opts) {
|
|
533
537
|
opts = opts || {};
|
|
534
|
-
|
|
538
|
+
// Same reading as parse above: the default is for the OMITTED case. An empty
|
|
539
|
+
// list asked for nothing and used to receive all four schemes.
|
|
540
|
+
var allowedSchemes = Array.isArray(opts.allowedSchemes)
|
|
535
541
|
? opts.allowedSchemes
|
|
536
542
|
: ALLOW_ANY;
|
|
537
543
|
|
package/lib/self-update.js
CHANGED
|
@@ -231,7 +231,10 @@ function _validatePollOpts(opts) {
|
|
|
231
231
|
throw new SelfUpdateError("selfupdate/bad-releases-url",
|
|
232
232
|
"selfUpdate.poll: opts.releasesUrl is not parseable as a URL");
|
|
233
233
|
}
|
|
234
|
-
|
|
234
|
+
// The documented default is for the OMITTED case. An explicitly empty
|
|
235
|
+
// list says no protocol may be dialled, and widening it back to https:
|
|
236
|
+
// answers "none" with "one".
|
|
237
|
+
var allowedProtocols = Array.isArray(opts.allowedProtocols)
|
|
235
238
|
? opts.allowedProtocols.slice() : ["https:"];
|
|
236
239
|
if (allowedProtocols.indexOf(parsedProto) === -1) {
|
|
237
240
|
throw new SelfUpdateError("selfupdate/bad-releases-url",
|