@blamejs/core 0.15.15 → 0.15.17

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/lib/mail-auth.js CHANGED
@@ -1102,6 +1102,11 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
1102
1102
  // throw on malformed v= / unrecognized np= or psd= values rather than
1103
1103
  // silently dropping — operators with a typo'd record otherwise see the
1104
1104
  // fallback policy applied without warning.
1105
+ // RFC 7489 §6.3 — p= (and DMARCbis sp=/np=) take exactly one of
1106
+ // none|quarantine|reject. p= is REQUIRED; a record without a valid p=
1107
+ // carries no usable policy (treat as if no record were published rather
1108
+ // than defaulting any unrecognized value to "deliver").
1109
+ var DMARC_VALID_P = { none: 1, quarantine: 1, reject: 1 };
1105
1110
  var DMARCBIS_VALID_NP = { none: 1, quarantine: 1, reject: 1 };
1106
1111
  var DMARCBIS_VALID_PSD = { y: 1, n: 1, u: 1 };
1107
1112
 
@@ -1115,8 +1120,22 @@ function _parseDmarcRecord(text) {
1115
1120
  var key = pairs[i][0];
1116
1121
  var val = pairs[i][1];
1117
1122
  if (key === "v") policy.v = val;
1118
- else if (key === "p") policy.p = val.toLowerCase();
1119
- else if (key === "sp") policy.sp = val.toLowerCase();
1123
+ else if (key === "p") {
1124
+ var pVal = val.toLowerCase();
1125
+ if (!Object.prototype.hasOwnProperty.call(DMARC_VALID_P, pVal)) {
1126
+ throw new MailAuthError("mail-auth/dmarcbis-bad-tag",
1127
+ "DMARC p= must be one of none|quarantine|reject, got " + JSON.stringify(val));
1128
+ }
1129
+ policy.p = pVal;
1130
+ }
1131
+ else if (key === "sp") {
1132
+ var spVal = val.toLowerCase();
1133
+ if (!Object.prototype.hasOwnProperty.call(DMARC_VALID_P, spVal)) {
1134
+ throw new MailAuthError("mail-auth/dmarcbis-bad-tag",
1135
+ "DMARC sp= must be one of none|quarantine|reject, got " + JSON.stringify(val));
1136
+ }
1137
+ policy.sp = spVal;
1138
+ }
1120
1139
  else if (key === "pct") policy.pct = parseInt(val, 10);
1121
1140
  else if (key === "adkim") policy.adkim = val.toLowerCase();
1122
1141
  else if (key === "aspf") policy.aspf = val.toLowerCase();
@@ -1141,6 +1160,16 @@ function _parseDmarcRecord(text) {
1141
1160
  throw new MailAuthError("mail-auth/dmarc-bad-version",
1142
1161
  "DMARC record version must be DMARC1, got " + JSON.stringify(policy.v));
1143
1162
  }
1163
+ // RFC 7489 §6.3 — p= is REQUIRED. A record syntactically valid in
1164
+ // every other respect but missing p= publishes no usable policy; the
1165
+ // receiver MUST treat it as if no record were found rather than
1166
+ // synthesizing a permissive default. Fail closed here so the caller
1167
+ // can route the missing-policy case (none/permerror) instead of
1168
+ // delivering an unauthenticated message.
1169
+ if (policy.p === null) {
1170
+ throw new MailAuthError("mail-auth/dmarc-missing-policy",
1171
+ "DMARC record has no required p= tag (RFC 7489 §6.3)");
1172
+ }
1144
1173
  return policy;
1145
1174
  }
1146
1175
 
@@ -1236,7 +1265,18 @@ async function dmarcEvaluate(opts) {
1236
1265
  }
1237
1266
  }
1238
1267
  } catch (e) {
1239
- return { result: "temperror", explanation: e.message,
1268
+ // RFC 7489 §6.6.3 a syntactically invalid / policy-less record is a
1269
+ // PERMANENT error (permerror); only transient DNS resolution failures
1270
+ // are temperror. The DMARC parser raises typed MailAuthError codes for
1271
+ // the permanent cases (bad version, unrecognized tag value, missing
1272
+ // required p=); anything else (DNS lookup) is transient. Either way the
1273
+ // disposition is fail-closed — neither path yields recommendedAction
1274
+ // "deliver".
1275
+ var permanent = e && typeof e.code === "string" &&
1276
+ (e.code === "mail-auth/dmarc-bad-version" ||
1277
+ e.code === "mail-auth/dmarcbis-bad-tag" ||
1278
+ e.code === "mail-auth/dmarc-missing-policy");
1279
+ return { result: permanent ? "permerror" : "temperror", explanation: e.message,
1240
1280
  policy: null, alignment: { spf: false, dkim: false },
1241
1281
  orgDomain: orgDomain };
1242
1282
  }
@@ -1312,13 +1352,23 @@ async function dmarcEvaluate(opts) {
1312
1352
  sampleRoll = bCrypto.randomInt(0, 100); // pct sample roll
1313
1353
  }
1314
1354
  var sampled = !pass && pct < 100 && sampleRoll >= pct;
1315
- var recommendedAction = pass ? "deliver" :
1316
- sampled
1317
- ? (policy.p === "reject" ? "quarantine" :
1318
- policy.p === "quarantine" ? "none" : "deliver")
1319
- : (policy.p === "reject" ? "reject" :
1320
- policy.p === "quarantine" ? "quarantine" :
1321
- "deliver");
1355
+ // Disposition is driven by the validated p= value. policy.p is one of
1356
+ // none|quarantine|reject (the parser fails closed on anything else, so a
1357
+ // missing/typo'd policy never reaches here). "none" is monitor-only →
1358
+ // deliver; reject/quarantine map to their disposition (sampled = the
1359
+ // next-less-strict step per §6.6.4). The explicit "none" arm — rather
1360
+ // than a catch-all "else deliver" — keeps an unexpected value from
1361
+ // silently delivering a failing message.
1362
+ var recommendedAction;
1363
+ if (pass) {
1364
+ recommendedAction = "deliver";
1365
+ } else if (policy.p === "none") {
1366
+ recommendedAction = "deliver";
1367
+ } else if (sampled) {
1368
+ recommendedAction = policy.p === "reject" ? "quarantine" : "none"; // p is reject|quarantine here
1369
+ } else {
1370
+ recommendedAction = policy.p === "reject" ? "reject" : "quarantine"; // p is reject|quarantine here
1371
+ }
1322
1372
 
1323
1373
  return {
1324
1374
  result: pass ? "pass" : "fail",
@@ -1544,10 +1594,15 @@ async function arcVerify(rfc822, opts) {
1544
1594
  var asX = asTags.x ? parseInt(asTags.x, 10) : null;
1545
1595
  var skewSec = Math.floor(arcClockSkewMs / 1000); // sec divisor
1546
1596
  var timeFault = null;
1547
- if (amsT && isFinite(amsT) && amsT - skewSec > nowSec) timeFault = "ams-t-future";
1548
- if (amsX && isFinite(amsX) && amsX + skewSec < nowSec) timeFault = "ams-x-expired";
1549
- if (asT && isFinite(asT) && asT - skewSec > nowSec) timeFault = "as-t-future";
1550
- if (asX && isFinite(asX) && asX + skewSec < nowSec) timeFault = "as-x-expired";
1597
+ // RFC 8617 §5.2 a PRESENT t=/x= that is unparseable (parseInt → NaN) must
1598
+ // FAIL CLOSED (a time fault), not silently skip the future/expiry check: a
1599
+ // bare `isFinite(amsT) &&` lets a malformed timestamp slip the MUST-reject
1600
+ // gate. Gate on the RAW tag's presence (amsTags.t/x) so an unparseable value
1601
+ // becomes a fault, while an absent tag is correctly ignored.
1602
+ if (amsTags.t && (!isFinite(amsT) || amsT - skewSec > nowSec)) timeFault = isFinite(amsT) ? "ams-t-future" : "ams-t-unparseable";
1603
+ if (amsTags.x && (!isFinite(amsX) || amsX + skewSec < nowSec)) timeFault = isFinite(amsX) ? "ams-x-expired" : "ams-x-unparseable";
1604
+ if (asTags.t && (!isFinite(asT) || asT - skewSec > nowSec)) timeFault = isFinite(asT) ? "as-t-future" : "as-t-unparseable";
1605
+ if (asTags.x && (!isFinite(asX) || asX + skewSec < nowSec)) timeFault = isFinite(asX) ? "as-x-expired" : "as-x-unparseable";
1551
1606
 
1552
1607
  // AMS — RFC 8617 §5.1.1. Same shape as a DKIM-Signature; reuses
1553
1608
  // the DKIM verifier by injecting a temporary message that has
package/lib/mail-bimi.js CHANGED
@@ -819,6 +819,12 @@ function _verifyCertChain(leaf, intermediates, anchors) {
819
819
  while (depth < MAX_DEPTH) {
820
820
  var notBefore = Date.parse(current.validFrom);
821
821
  var notAfter = Date.parse(current.validTo);
822
+ // Fail closed on a present-but-unparseable validity window: a NaN date
823
+ // would otherwise skip both window checks below and let the cert pass
824
+ // validation unchecked.
825
+ if (!isFinite(notBefore) || !isFinite(notAfter)) {
826
+ return { ok: false, reason: "cert validity dates unparseable" };
827
+ }
822
828
  if (isFinite(notBefore) && now < notBefore) {
823
829
  return { ok: false, reason: "cert not-yet-valid (notBefore=" + current.validFrom + ")" };
824
830
  }
@@ -829,6 +829,16 @@ function checkCert(opts) {
829
829
  var nowMs = Date.now();
830
830
  var notBeforeMs = Date.parse(cert.validFrom);
831
831
  var notAfterMs = Date.parse(cert.validTo);
832
+ // Fail closed on a present-but-unparseable validity field — gating
833
+ // each comparison behind isFinite() alone let a NaN notBefore /
834
+ // notAfter skip both checks and pass validity. RFC 5280 §4.1.2.5
835
+ // makes both dates mandatory; a date a peer cannot parse must be
836
+ // refused at preflight, not accepted and left to fail interop later.
837
+ if (!isFinite(notBeforeMs) || !isFinite(notAfterMs)) {
838
+ throw new MailCryptoError("mail-crypto/smime/bad-validity",
839
+ "cert has unparseable validity dates (validFrom=" + cert.validFrom +
840
+ ", validTo=" + cert.validTo + ")");
841
+ }
832
842
  if (isFinite(notBeforeMs) && nowMs < notBeforeMs) {
833
843
  throw new MailCryptoError("mail-crypto/smime/expired-cert",
834
844
  "cert is not yet valid (notBefore=" + cert.validFrom + ", now=" +
package/lib/mail-dkim.js CHANGED
@@ -482,6 +482,36 @@ function _parseDkimTagList(value) {
482
482
  return tags;
483
483
  }
484
484
 
485
+ // RFC 6376 §3.5 — x= / t= / l= are unsigned decimal integers ("1*DIGIT").
486
+ // parseInt() is too lenient here ("12abc" → 12, "0x10" → 0, " 12" → 12),
487
+ // so a present-but-malformed value must not be coerced into a finite
488
+ // number that then passes the downstream check. Returns the parsed integer,
489
+ // or null when the value is not a well-formed unsigned integer — the caller
490
+ // fails CLOSED on null. `maxDigits` bounds the digit count (a ReDoS-free
491
+ // length cap; x=/t= are NumericDate, bounded to 12 digits).
492
+ function _parseDkimUnsignedInt(raw, maxDigits) {
493
+ if (typeof raw !== "string") return null;
494
+ var len = raw.length;
495
+ // "1*DIGIT": non-empty, ASCII 0-9 only, length-capped. A char-code scan
496
+ // (no regex) keeps this linear and avoids a dynamic RegExp / a shared
497
+ // digit-pattern literal — maxDigits bounds x=/t=/l= to their NumericDate
498
+ // / octet-count headroom.
499
+ if (len === 0 || (maxDigits && len > maxDigits)) return null;
500
+ for (var i = 0; i < len; i++) {
501
+ var c = raw.charCodeAt(i);
502
+ if (c < 0x30 || c > 0x39) return null; // not an ASCII digit
503
+ }
504
+ var n = parseInt(raw, 10);
505
+ if (!isFinite(n)) return null;
506
+ return n;
507
+ }
508
+
509
+ // x= / t= are NumericDate (seconds-since-epoch); 12 digits is ample headroom
510
+ // past any realistic epoch and keeps the parse bounded.
511
+ function _parseDkimNumericDate(raw) {
512
+ return _parseDkimUnsignedInt(raw, 12);
513
+ }
514
+
485
515
  function _selectorTxtToKeyTags(txtRecords) {
486
516
  // DKIM key record is a TXT record at <selector>._domainkey.<domain>.
487
517
  // Format: "v=DKIM1; k=rsa; p=<base64>" (chunks may be split across
@@ -694,8 +724,16 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
694
724
  // the signature still validates against legitimate senders that
695
725
  // use l=, but flag in the result. The cap is octets of the
696
726
  // CANONICALIZED body (RFC 6376 §3.4.5), applied inside _bodyHashB64.
697
- var parsedL = parseInt(sigTags.l, 10);
698
- if (isFinite(parsedL) && parsedL >= 0) lcap = parsedL;
727
+ // l= is an unsigned decimal integer ("1*DIGIT" per §3.5); a
728
+ // present-but-unparseable value must FAIL CLOSED rather than fall
729
+ // through to lcap=undefined (hashing the whole body silently masks the
730
+ // malformed tag instead of refusing it).
731
+ var parsedL = _parseDkimUnsignedInt(sigTags.l, 18);
732
+ if (parsedL === null) {
733
+ return { result: "permerror",
734
+ errors: ["DKIM-Signature l= present but unparseable (RFC 6376 §3.5 — unsigned integer required)"] };
735
+ }
736
+ lcap = parsedL;
699
737
  }
700
738
 
701
739
  // 1. Body-hash check.
@@ -922,34 +960,49 @@ async function verify(rfc822, opts) {
922
960
  // attack). Both are in seconds-since-epoch per ABNF.
923
961
  var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
924
962
  var clockSkewSec = Math.floor(clockSkewMs / C.TIME.seconds(1));
963
+ // x= / t= are NumericDate (digits-only seconds-since-epoch per the
964
+ // §3.5 ABNF). A present-but-unparseable value must FAIL CLOSED — a NaN
965
+ // result silently skipping the expiry / future-date / ordering checks
966
+ // would let verification proceed to result:"pass" with NO expiry
967
+ // enforced (mirrors the SAML Conditions present-but-unparseable fix).
968
+ var xSec = null;
925
969
  if (sigTags.x !== undefined) {
926
- var expSec = parseInt(sigTags.x, 10);
927
- if (isFinite(expSec) && expSec + clockSkewSec < nowSec) {
970
+ xSec = _parseDkimNumericDate(sigTags.x);
971
+ if (xSec === null) {
972
+ results.push({ d: d || null, s: s || null, alg: alg || null,
973
+ result: "permerror",
974
+ errors: ["DKIM-Signature x= present but unparseable (RFC 6376 §3.5 — NumericDate required)"] });
975
+ continue;
976
+ }
977
+ if (xSec + clockSkewSec < nowSec) {
928
978
  results.push({ d: d || null, s: s || null, alg: alg || null,
929
979
  result: "permerror",
930
- errors: ["DKIM-Signature x=" + expSec + " has expired (RFC 6376 §3.5)"] });
980
+ errors: ["DKIM-Signature x=" + xSec + " has expired (RFC 6376 §3.5)"] });
931
981
  continue;
932
982
  }
933
983
  }
934
984
  if (sigTags.t !== undefined) {
935
- var tSec = parseInt(sigTags.t, 10);
985
+ var tSec = _parseDkimNumericDate(sigTags.t);
986
+ if (tSec === null) {
987
+ results.push({ d: d || null, s: s || null, alg: alg || null,
988
+ result: "permerror",
989
+ errors: ["DKIM-Signature t= present but unparseable (RFC 6376 §3.5 — NumericDate required)"] });
990
+ continue;
991
+ }
936
992
  // Allow up to 24h future-skew; beyond that, refuse — neither
937
993
  // operator clock drift nor delivery latency explains a future-
938
994
  // dated signing time of more than a day.
939
- if (isFinite(tSec) && tSec - (24 * 60 * 60) > nowSec) { // allow:raw-time-literal — 24h future-date sanity ceiling
995
+ if (tSec - (24 * 60 * 60) > nowSec) { // allow:raw-time-literal — 24h future-date sanity ceiling
940
996
  results.push({ d: d || null, s: s || null, alg: alg || null,
941
997
  result: "permerror",
942
998
  errors: ["DKIM-Signature t=" + tSec + " is more than 24h in the future (RFC 6376 §3.5 sanity)"] });
943
999
  continue;
944
1000
  }
945
- if (sigTags.x !== undefined) {
946
- var xSec = parseInt(sigTags.x, 10);
947
- if (isFinite(xSec) && isFinite(tSec) && xSec < tSec) {
948
- results.push({ d: d || null, s: s || null, alg: alg || null,
949
- result: "permerror",
950
- errors: ["DKIM-Signature x= must be after t= (RFC 6376 §3.5)"] });
951
- continue;
952
- }
1001
+ if (xSec !== null && xSec < tSec) {
1002
+ results.push({ d: d || null, s: s || null, alg: alg || null,
1003
+ result: "permerror",
1004
+ errors: ["DKIM-Signature x= must be after t= (RFC 6376 §3.5)"] });
1005
+ continue;
953
1006
  }
954
1007
  }
955
1008
  if (!d || !s) {
package/lib/mail.js CHANGED
@@ -825,6 +825,16 @@ function smtpTransport(opts) {
825
825
  _refuseCtlBytes("host", opts.host);
826
826
  _refuseCtlBytes("servername", opts.servername);
827
827
  var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
828
+ // Absolute transaction deadline — distinct from the per-socket IDLE
829
+ // timeout above. A slow-trickle MX that emits one byte just inside
830
+ // every idle window resets socket.setTimeout forever and never
831
+ // completes; this wall-clock bound fails the whole send regardless of
832
+ // trickle. Defaults well above timeoutMs so a normal multi-round-trip
833
+ // SMTP conversation (greeting → EHLO → STARTTLS → AUTH → MAIL/RCPT →
834
+ // DATA/BDAT) never trips it.
835
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxTransactionMs,
836
+ "smtp transport: opts.maxTransactionMs", MailError, "mail/smtp-misconfigured");
837
+ var maxTransactionMs = opts.maxTransactionMs || C.TIME.minutes(5);
828
838
  var tlsOpts = {
829
839
  rejectUnauthorized: rejectUnauthorized,
830
840
  minVersion: opts.minTlsVersion || "TLSv1.3",
@@ -875,6 +885,7 @@ function smtpTransport(opts) {
875
885
  useImplicitTLS: useImplicitTLS,
876
886
  ehloName: ehloName,
877
887
  timeoutMs: timeoutMs,
888
+ maxTransactionMs: maxTransactionMs,
878
889
  tlsOpts: tlsOpts,
879
890
  servername: servername,
880
891
  dkimSigner: opts.dkimSigner || null,
@@ -1043,6 +1054,7 @@ function _smtpSend(message, cfg) {
1043
1054
  var dataWireBytes = null; // Buffer view of dataMessage for BDAT slicing
1044
1055
  var useBdat = false; // Decided post-EHLO based on peerSupportsChunking + cfg.chunkingEnabled
1045
1056
  var bodyMode = "7BIT"; // "7BIT" / "8BITMIME" / "BINARYMIME"
1057
+ var txTimer = null; // Absolute transaction-deadline timer (cfg.maxTransactionMs)
1046
1058
 
1047
1059
  var fromAddr = _extractAddr(message.from);
1048
1060
  var toList = _toArray(message.to).map(_extractAddr);
@@ -1081,9 +1093,13 @@ function _smtpSend(message, cfg) {
1081
1093
  }
1082
1094
  }
1083
1095
 
1096
+ function clearTxTimer() {
1097
+ if (txTimer) { try { clearTimeout(txTimer); } catch (_e) { /* best-effort */ } txTimer = null; }
1098
+ }
1084
1099
  function fail(reason) {
1085
1100
  if (settled) return;
1086
1101
  settled = true;
1102
+ clearTxTimer();
1087
1103
  try { if (socket) socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1088
1104
  reject(new MailError("mail/smtp-failed",
1089
1105
  "SMTP send failed: " + reason, false));
@@ -1091,6 +1107,7 @@ function _smtpSend(message, cfg) {
1091
1107
  function done(ok, code) {
1092
1108
  if (settled) return;
1093
1109
  settled = true;
1110
+ clearTxTimer();
1094
1111
  try { socket.end(); } catch (_e) { /* socket may already be torn down */ }
1095
1112
  if (ok) resolve({ transport: "smtp", deliveredAt: Date.now(), code: code });
1096
1113
  else reject(new MailError("mail/smtp-rejected",
@@ -1149,6 +1166,16 @@ function _smtpSend(message, cfg) {
1149
1166
 
1150
1167
  function onData(data) {
1151
1168
  buffer += data;
1169
+ // Bound the framing accumulator. A hostile / broken MX that
1170
+ // streams bytes without ever sending CRLF would otherwise grow
1171
+ // `buffer` without limit and OOM the process. SMTP responses are
1172
+ // tiny per spec; the 256 KiB cap is generous headroom. Measure
1173
+ // bytes (not UTF-16 code units) so multibyte trickle can't slip a
1174
+ // larger payload past a char-length check.
1175
+ if (safeBuffer.byteLengthOf(buffer) > MAIL_RESPONSE_MAX_BYTES) {
1176
+ fail("response-too-large");
1177
+ return;
1178
+ }
1152
1179
  var lines = buffer.split("\r\n");
1153
1180
  buffer = lines.pop();
1154
1181
  for (var i = 0; i < lines.length; i++) {
@@ -1233,6 +1260,7 @@ function _smtpSend(message, cfg) {
1233
1260
  // would corrupt NUL-bearing octets in transit.
1234
1261
  if (requiresBinaryMime && !peerSupportsBinaryMime) {
1235
1262
  settled = true;
1263
+ clearTxTimer();
1236
1264
  try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1237
1265
  reject(new MailError("mail/binarymime-not-advertised",
1238
1266
  "message has 8-bit binary content but peer does not advertise BINARYMIME (RFC 3030 §3)",
@@ -1245,6 +1273,7 @@ function _smtpSend(message, cfg) {
1245
1273
  // cap"; -1 means "SIZE not advertised" (no precheck).
1246
1274
  if (cfg.respectPeerSize && peerSizeCap > 0 && messageWireSize > peerSizeCap) {
1247
1275
  settled = true;
1276
+ clearTxTimer();
1248
1277
  try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1249
1278
  reject(new MailError("mail/peer-size-exceeded",
1250
1279
  "message wire size " + messageWireSize + " bytes exceeds peer SIZE cap " +
@@ -1341,6 +1370,7 @@ function _smtpSend(message, cfg) {
1341
1370
  // the chunked-body failure mode.
1342
1371
  if (code !== 250) {
1343
1372
  settled = true;
1373
+ clearTxTimer();
1344
1374
  try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1345
1375
  reject(new MailError("mail/bdat-chunk-rejected",
1346
1376
  "BDAT chunk rejected (code " + code + ", offset " + bdatOffset + "/" +
@@ -1360,6 +1390,15 @@ function _smtpSend(message, cfg) {
1360
1390
  }
1361
1391
  }
1362
1392
 
1393
+ // Arm the absolute transaction deadline before opening the socket so
1394
+ // a peer that connects but then trickles (or never responds) is
1395
+ // bounded regardless of how it games the per-socket idle timer.
1396
+ // unref so a pending deadline never keeps the process alive on its own.
1397
+ txTimer = setTimeout(function () {
1398
+ fail("transaction-timeout");
1399
+ }, cfg.maxTransactionMs);
1400
+ if (txTimer && typeof txTimer.unref === "function") txTimer.unref();
1401
+
1363
1402
  try { connect(); }
1364
1403
  catch (e) { fail(e.message || String(e)); }
1365
1404
  });