@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/NOTICE +1 -1
  3. package/README.md +5 -5
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/ai-adverse-decision.js +18 -2
  6. package/lib/audit-sign.js +24 -5
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/codepoint-class.js +72 -0
  9. package/lib/cookies.js +7 -10
  10. package/lib/credential-hash.js +8 -1
  11. package/lib/crypto.js +7 -5
  12. package/lib/db-file-lifecycle.js +14 -3
  13. package/lib/db.js +505 -49
  14. package/lib/guard-auth.js +34 -11
  15. package/lib/guard-filename.js +41 -33
  16. package/lib/guard-html.js +10 -2
  17. package/lib/guard-list-unsubscribe.js +6 -1
  18. package/lib/guard-managesieve-command.js +73 -12
  19. package/lib/guard-regex.js +3 -5
  20. package/lib/guard-smtp-command.js +20 -4
  21. package/lib/guard-svg.js +6 -1
  22. package/lib/guard-yaml.js +60 -15
  23. package/lib/http-client.js +17 -3
  24. package/lib/mail-agent.js +29 -13
  25. package/lib/mail-arc-sign.js +40 -7
  26. package/lib/mail-auth.js +134 -22
  27. package/lib/mail-crypto-pgp.js +1 -1
  28. package/lib/mail-dkim.js +80 -11
  29. package/lib/mail-helo.js +10 -0
  30. package/lib/mail-rbl.js +10 -3
  31. package/lib/mail-send-deliver.js +151 -32
  32. package/lib/mail-server-imap.js +186 -89
  33. package/lib/mail-server-jmap.js +31 -4
  34. package/lib/mail-server-managesieve.js +198 -42
  35. package/lib/mail-server-mx.js +191 -38
  36. package/lib/mail-server-net.js +281 -1
  37. package/lib/mail-server-pop3.js +89 -41
  38. package/lib/mail-server-rate-limit.js +104 -6
  39. package/lib/mail-server-submission.js +183 -35
  40. package/lib/mail-server-tls.js +48 -3
  41. package/lib/mail-store.js +33 -11
  42. package/lib/mail.js +355 -17
  43. package/lib/mcp.js +11 -3
  44. package/lib/middleware/bearer-auth.js +6 -1
  45. package/lib/middleware/fetch-metadata.js +5 -1
  46. package/lib/middleware/headers.js +7 -10
  47. package/lib/middleware/require-mtls.js +8 -1
  48. package/lib/network-dns-resolver.js +71 -8
  49. package/lib/network-dns.js +26 -0
  50. package/lib/network-smtp-policy.js +42 -10
  51. package/lib/network-tls.js +18 -0
  52. package/lib/redact.js +13 -3
  53. package/lib/retention.js +22 -2
  54. package/lib/safe-mount-info.js +39 -6
  55. package/lib/safe-smtp.js +96 -1
  56. package/lib/safe-url.js +8 -2
  57. package/lib/self-update.js +4 -1
  58. package/lib/vendor/MANIFEST.json +12 -12
  59. package/lib/vendor/blamejs-pki.cjs +672 -75
  60. package/lib/watcher.js +31 -6
  61. package/lib/ws-client.js +17 -2
  62. package/lib/yaml-lex.js +55 -1
  63. package/package.json +1 -1
  64. 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
- for (var ci = 0; ci < v.length; ci += 1) {
108
- var cc = v.charCodeAt(ci);
109
- if (cc === 0x0D || cc === 0x0A || cc === 0x00) { // CR / LF / NUL forbidden in header value
110
- issues.push({
111
- kind: "header-value-control-byte", severity: "high", header: name,
112
- snippet: "header `" + name + "` value contains CR / LF / NUL " +
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
- if (allowList && allowList.length > 0 && !bCrypto().isCertRevoked(peerCert.raw, allowList)) {
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
- throw new ResolverError("resolver/nxdomain-or-error",
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
- var url = networkDns._getDohUrlForTest ? networkDns._getDohUrlForTest() : "https://cloudflare-dns.com/dns-query";
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
- var u = safeUrl.parse(getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
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
- var req = https.request(Object.assign({ // 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
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: { "accept": "application/dns-message" },
564
- }, networkTls().outboundPosture()), function (res) {
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
- if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
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;
@@ -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
- // node:dns has resolveTlsa() since Node 18.16.0.
247
- if (typeof dnsPromises.resolveTlsa !== "function") {
248
- throw new SmtpPolicyError("smtp/dane-unavailable",
249
- "node:dns.resolveTlsa is not available on this runtime");
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
- try { records = await dnsPromises.resolveTlsa(qname); }
253
- catch (e) {
254
- if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return [];
255
- throw new SmtpPolicyError("smtp/dane-lookup-failed",
256
- "TLSA lookup for " + qname + " failed: " + ((e && e.message) || String(e)));
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
@@ -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[patterns[p]])) {
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
- var spec = CLASSIFIER_PATTERNS[name] || extra[name];
548
- return spec;
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 + "'; expected one of " +
653
- Object.keys(COMPLIANCE_RETENTION_FLOOR_MS).join(", "));
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
  }
@@ -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)`. On non-Linux platforms (where /proc
191
- * doesn't exist) returns `opts.fallback` (default `null`); audit
192
- * emission per `safe-mount-info.refused` with code `read-failed`.
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 (default /proc/self/mountinfo)
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
- : DEFAULT_PATH;
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
- "/proc/self/mountinfo unreadable: " + ((e && e.message) || String(e)));
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
- for (var i = 0; i < buf.length; i += 1) {
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
- var allowed = Array.isArray(opts.allowedProtocols) && opts.allowedProtocols.length > 0
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
- var allowedSchemes = Array.isArray(opts.allowedSchemes) && opts.allowedSchemes.length > 0
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
 
@@ -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
- var allowedProtocols = Array.isArray(opts.allowedProtocols) && opts.allowedProtocols.length > 0
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",