@blamejs/core 0.18.53 → 0.18.54

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 (43) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/NOTICE +1 -1
  3. package/README.md +3 -3
  4. package/lib/ai-adverse-decision.js +18 -2
  5. package/lib/codepoint-class.js +72 -0
  6. package/lib/cookies.js +7 -10
  7. package/lib/credential-hash.js +8 -1
  8. package/lib/crypto.js +7 -5
  9. package/lib/guard-auth.js +34 -11
  10. package/lib/guard-filename.js +33 -32
  11. package/lib/guard-managesieve-command.js +49 -9
  12. package/lib/guard-regex.js +3 -5
  13. package/lib/guard-yaml.js +60 -15
  14. package/lib/mail-agent.js +23 -9
  15. package/lib/mail-arc-sign.js +40 -7
  16. package/lib/mail-auth.js +75 -20
  17. package/lib/mail-crypto-pgp.js +1 -1
  18. package/lib/mail-dkim.js +80 -11
  19. package/lib/mail-helo.js +10 -0
  20. package/lib/mail-rbl.js +10 -3
  21. package/lib/mail-send-deliver.js +151 -32
  22. package/lib/mail-server-imap.js +121 -55
  23. package/lib/mail-server-jmap.js +31 -4
  24. package/lib/mail-server-managesieve.js +168 -25
  25. package/lib/mail-server-mx.js +76 -4
  26. package/lib/mail-server-net.js +126 -0
  27. package/lib/mail-server-pop3.js +73 -22
  28. package/lib/mail-server-submission.js +21 -2
  29. package/lib/mail-store.js +33 -11
  30. package/lib/mail.js +355 -17
  31. package/lib/middleware/bearer-auth.js +6 -1
  32. package/lib/middleware/fetch-metadata.js +5 -1
  33. package/lib/middleware/headers.js +7 -10
  34. package/lib/network-dns-resolver.js +71 -8
  35. package/lib/network-dns.js +26 -0
  36. package/lib/network-smtp-policy.js +42 -10
  37. package/lib/redact.js +13 -3
  38. package/lib/retention.js +22 -2
  39. package/lib/vendor/MANIFEST.json +12 -12
  40. package/lib/vendor/blamejs-pki.cjs +278 -40
  41. package/lib/yaml-lex.js +55 -1
  42. package/package.json +1 -1
  43. package/sbom.cdx.json +6 -6
package/lib/mail-auth.js CHANGED
@@ -1926,10 +1926,15 @@ function _arcInstanceOf(value) {
1926
1926
  }
1927
1927
 
1928
1928
  async function arcVerify(rfc822, opts) {
1929
- if (typeof rfc822 !== "string" || rfc822.length === 0) {
1929
+ if ((!Buffer.isBuffer(rfc822) && typeof rfc822 !== "string") || rfc822.length === 0) {
1930
1930
  throw new MailAuthError("mail-auth/arc-bad-input",
1931
- "arc.verify: rfc822 must be a non-empty string");
1931
+ "arc.verify: rfc822 must be a non-empty Buffer or string");
1932
1932
  }
1933
+ // A seal covers the octets on the wire, so a caller holding them verifies
1934
+ // against them. Same boundary as DKIM, and the SAME function, so a string
1935
+ // cannot come to mean one thing here and another there: a Buffer is octets,
1936
+ // a string is text and its octets are its UTF-8 encoding.
1937
+ rfc822 = dkim._toWire(rfc822);
1933
1938
  opts = opts || {};
1934
1939
  // RFC 8617 §5.1.1 / RFC 6376 §3.4 — ARC-Message-Signature verification
1935
1940
  // reuses the DKIM verifier, whose header/body split REQUIRES CRLF CRLF and
@@ -2333,7 +2338,10 @@ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
2333
2338
  // unreachable from b.mail.dkim.verify.
2334
2339
  var verifyOpts = { dnsLookup: dnsLookup };
2335
2340
  verifyOpts[ARC_AMS_REUSE] = true;
2336
- var rv = await dkim.verify(synthetic, verifyOpts);
2341
+ // As OCTETS. `synthetic` is already the wire form, one code unit per octet,
2342
+ // and handing it over as a string would have the verifier read it as text and
2343
+ // re-encode it as UTF-8 — hashing bytes this message does not contain.
2344
+ var rv = await dkim.verify(dkim._wireBytes(synthetic), verifyOpts);
2337
2345
  if (!Array.isArray(rv) || rv.length === 0) {
2338
2346
  return { result: "permerror", errors: ["ams: dkim verifier returned no results"] };
2339
2347
  }
@@ -2401,7 +2409,11 @@ function _runVerify(signedString, sigB64, algorithm, keyB64, label) {
2401
2409
  var sigBuf = Buffer.from(sigB64, "base64");
2402
2410
  var verified;
2403
2411
  try {
2404
- verified = nodeCrypto.verify(nodeAlgo, Buffer.from(signedString, "utf8"), keyObj, sigBuf);
2412
+ // latin1: the signer sealed the canonicalized OCTETS (lib/mail-arc-sign.js),
2413
+ // and the message reaches here one code unit per octet. Re-encoding as UTF-8
2414
+ // would verify against bytes the signer never saw, for any message carrying
2415
+ // 8-bit content.
2416
+ verified = nodeCrypto.verify(nodeAlgo, Buffer.from(signedString, "latin1"), keyObj, sigBuf);
2405
2417
  } catch (e) {
2406
2418
  return { result: "permerror",
2407
2419
  errors: [label + ": verify threw: " + ((e && e.message) || String(e))] };
@@ -2429,10 +2441,19 @@ void C; // C is imported for future TIME constants in policy fetchers.
2429
2441
  // // trustedDomain: "mailgun.net" }
2430
2442
 
2431
2443
  async function arcEvaluate(rfc822, opts) {
2432
- if (typeof rfc822 !== "string" || rfc822.length === 0) {
2444
+ if ((!Buffer.isBuffer(rfc822) && typeof rfc822 !== "string") || rfc822.length === 0) {
2433
2445
  throw new MailAuthError("mail-auth/arc-bad-input",
2434
- "arc.evaluate: rfc822 must be a non-empty string");
2435
- }
2446
+ "arc.evaluate: rfc822 must be a non-empty Buffer or string");
2447
+ }
2448
+ // Two views, and the distinction matters. The header re-scan below reads the
2449
+ // message as a wire STRING; `arcVerify` resolves its own argument, and
2450
+ // `_toWire` is idempotent on a Buffer but NOT on a string — applied twice it
2451
+ // reads the latin1 wire form as text and re-encodes it as UTF-8, so the
2452
+ // octets checked stop being the octets signed. Passing the wire string on
2453
+ // made a Unicode message pass `arc.verify` and fail `arc.evaluate`, which are
2454
+ // supposed to answer the same question.
2455
+ rfc822 = dkim._toWire(rfc822);
2456
+ var rfc822Octets = dkim._wireBytes(rfc822);
2436
2457
  opts = opts || {};
2437
2458
  if (!Array.isArray(opts.trustedSealers)) {
2438
2459
  throw new MailAuthError("mail-auth/arc-bad-trusted-sealers",
@@ -2448,7 +2469,7 @@ async function arcEvaluate(rfc822, opts) {
2448
2469
  trusted[d.toLowerCase()] = true;
2449
2470
  }
2450
2471
 
2451
- var verdict = await arcVerify(rfc822, opts);
2472
+ var verdict = await arcVerify(rfc822Octets, opts);
2452
2473
  var out = {
2453
2474
  chainStatus: verdict.chainStatus,
2454
2475
  hopCount: verdict.hopCount,
@@ -2695,7 +2716,7 @@ function authResultsEmit(opts) {
2695
2716
  // message: rfc5322Bytes, // string or Buffer
2696
2717
  // authservId: "mx.example.com",
2697
2718
  // });
2698
- // // → { spf, dkim, from, dmarc, authResults }
2719
+ // // → { spf, dkim, from, dmarc, arc, authResults }
2699
2720
  // if (v.dmarc.recommendedAction === "reject") { /* refuse 550 5.7.1 */ }
2700
2721
  //
2701
2722
  // From-header discipline (RFC 9989 §5.3.1, RFC 7489 §6.6.1 before it):
@@ -2834,14 +2855,13 @@ async function inboundVerify(opts) {
2834
2855
  }
2835
2856
  var message = opts.message;
2836
2857
  if (Buffer.isBuffer(message)) {
2837
- // DKIM canonicalization re-encodes the string form as UTF-8
2838
- // (lib/mail-dkim.js hashes Buffer.from(canonicalized, "utf8")), so
2839
- // the byte→string decode must be utf8 for valid-UTF-8 content to
2840
- // round-trip exactly. Non-UTF-8 8-bit content cannot survive any
2841
- // decode + utf8 re-encode; such messages verify as DKIM fail and
2842
- // DMARC falls back to the SPF identity (RFC 9989 §4 one
2843
- // aligned authenticator is sufficient to pass).
2844
- message = message.toString("utf8");
2858
+ // One code unit per octet, which is what the DKIM verifier now hashes.
2859
+ // This used to decode as UTF-8, on the reasoning that the verifier
2860
+ // re-encoded as UTF-8 and so only valid-UTF-8 content could round-trip.
2861
+ // That was accurate and it meant a message carrying ordinary 8-bit content
2862
+ // verified as a DKIM fail, leaving DMARC to fall back to the SPF identity
2863
+ // and nothing to fall back to once the message had been forwarded.
2864
+ message = message.toString("latin1");
2845
2865
  }
2846
2866
  if (typeof message !== "string" || message.length === 0) {
2847
2867
  throw new MailAuthError("mail-auth/inbound-bad-message",
@@ -2893,10 +2913,40 @@ async function inboundVerify(opts) {
2893
2913
  if (opts.clockSkewMs !== undefined) dkimVerifyOpts.clockSkewMs = opts.clockSkewMs;
2894
2914
  if (opts.maxSignatures !== undefined) dkimVerifyOpts.maxSignatures = opts.maxSignatures;
2895
2915
  if (opts.minRsaBits !== undefined) dkimVerifyOpts.minRsaBits = opts.minRsaBits;
2896
- var dkimResults = await dkim.verify(message, dkimVerifyOpts);
2916
+ // As OCTETS. `message` is the wire form here, one code unit per octet, and
2917
+ // both verifiers read a string as text — handing one over would re-encode it
2918
+ // as UTF-8 and verify against bytes the sender never signed.
2919
+ var messageOctets = dkim._wireBytes(message);
2920
+ var dkimResults = await dkim.verify(messageOctets, dkimVerifyOpts);
2921
+
2922
+ // ARC (RFC 8617) — the chain a forwarder or list left behind, carrying the
2923
+ // authentication verdict from before it rewrote the message. A receiver
2924
+ // without it sees forwarded mail fail DMARC on the forwarder's identity with
2925
+ // no way to recover the original result, which is the case ARC exists for.
2926
+ //
2927
+ // The chain is EVIDENCE, not a gate: a malformed one is reported as a fail
2928
+ // verdict and the pipeline continues, because refusing mail over a defect in
2929
+ // some intermediary's headers would be the receiver punishing the wrong
2930
+ // party. Deciding what a passing chain buys a sender is local policy, which
2931
+ // is what b.mail.arc.evaluate + trustedSealers is for.
2932
+ var arcResult;
2933
+ try {
2934
+ arcResult = await arcVerify(messageOctets, { dnsLookup: opts.dnsLookup });
2935
+ } catch (e) {
2936
+ arcResult = { chainStatus: "fail", hopCount: 0, hops: [],
2937
+ reason: "arc-verify-error: " + ((e && e.message) || String(e)) };
2938
+ }
2897
2939
 
2898
2940
  // From header + DMARC policy/alignment.
2899
- var from = _extractFromHeaders(_splitHeaderBlock(message).headers);
2941
+ // The From header is read as TEXT, while DKIM and ARC above read the same
2942
+ // message as OCTETS. Both are needed and they are not the same view: a
2943
+ // signature covers the bytes on the wire, and an address is characters —
2944
+ // an RFC 6531 local part written in UTF-8 aligns against a DMARC record
2945
+ // only once it is decoded as UTF-8, and reading it octet-per-code-unit
2946
+ // would compare mojibake. Only the header block is decoded, so the body's
2947
+ // size does not pay for it.
2948
+ var headerOctets = _splitHeaderBlock(message).headers;
2949
+ var from = _extractFromHeaders(Buffer.from(headerOctets, "latin1").toString("utf8"));
2900
2950
  var dmarc;
2901
2951
  if (from.count === 1 && from.address && from.domain) {
2902
2952
  dmarc = await dmarcEvaluate({
@@ -2956,10 +3006,15 @@ async function inboundVerify(opts) {
2956
3006
  var dmarcEntry = { method: "dmarc", result: dmarc.result };
2957
3007
  if (from.address) dmarcEntry.from = from.address;
2958
3008
  arResults.push(dmarcEntry);
3009
+ // RFC 8601 §2.7.6 — the arc method. "none" is reported like any other
3010
+ // verdict: a receiver reading the header can tell a chain that was checked
3011
+ // and found absent from a receiver that does not check.
3012
+ arResults.push({ method: "arc", result: arcResult.chainStatus });
2959
3013
  authResults = authResultsEmit({ authservId: opts.authservId, results: arResults });
2960
3014
  }
2961
3015
 
2962
- return { spf: spf, dkim: dkimResults, from: from, dmarc: dmarc, authResults: authResults };
3016
+ return { spf: spf, dkim: dkimResults, from: from, dmarc: dmarc,
3017
+ arc: arcResult, authResults: authResults };
2963
3018
  }
2964
3019
 
2965
3020
  // ---- DMARC aggregate (RUA) report parser (RFC 9990 §3 / draft-ietf-dmarc-aggregate-reporting) ----
@@ -81,7 +81,7 @@
81
81
  * Surface:
82
82
  * var sigBundle = b.mail.crypto.pgp.sign({
83
83
  * message: "rfc822 body bytes",
84
- * privateKeyPem: "-----BEGIN PRIVATE KEY----- ...",
84
+ * privateKeyPem: "<PEM-encoded PKCS#8 private key>",
85
85
  * passphrase: undefined | "...", // optional
86
86
  * audit: opts.audit, // optional b.audit handle
87
87
  * });
package/lib/mail-dkim.js CHANGED
@@ -179,6 +179,51 @@ function _parseHeaders(rawHeaders) {
179
179
  return out;
180
180
  }
181
181
 
182
+ // ---- The octet boundary ----
183
+ //
184
+ // RFC 6376 §3.4 signs a canonicalized OCTET stream, and a JavaScript string
185
+ // cannot hold one: a message decoded as UTF-8 loses every sequence that is not
186
+ // valid UTF-8 to U+FFFD, and no string re-encodes back to those bytes. Signing
187
+ // hashed `utf8(decode_utf8(original))`, equal to the original only when the
188
+ // original was already valid UTF-8 — 128 of the 256 single-octet values do not
189
+ // survive it.
190
+ //
191
+ // So the message is carried as latin1 from here down. latin1 is a bijection
192
+ // between bytes and code units 0x00-0xFF, which makes every string operation
193
+ // below an operation on the octets themselves. That is not an approximation of a
194
+ // byte implementation, it IS one: §3.4 canonicalization touches only SP, HTAB,
195
+ // CR and LF, none of which can appear inside a multi-byte UTF-8 sequence, so a
196
+ // byte scan and this character scan agree on every input.
197
+ //
198
+ // A string argument keeps its present meaning — the octets are that string's
199
+ // UTF-8 encoding — so it is encoded once here and rejoins the same path. One
200
+ // path, and the caller's existing signatures are unchanged.
201
+ // NOT IDEMPOTENT ON A STRING. Applied twice to a string, the second pass reads
202
+ // the latin1 wire form as text and re-encodes it as UTF-8, so the octets are no
203
+ // longer the message's. It IS idempotent on a Buffer. So a function that
204
+ // resolves a message and then hands it to another function that resolves it
205
+ // again must pass OCTETS — `_wireBytes(_toWire(x))` — not the wire string.
206
+ // `arc.evaluate` passed the string to `arc.verify` and the two then disagreed
207
+ // about whether the same Unicode message's chain passed.
208
+ //
209
+ // Refuses anything else rather than coercing it. `String(x)` on an object
210
+ // yields "[object Object]", which is a message this would go on to sign — and
211
+ // the caller gets a valid signature over eleven characters instead of an error.
212
+ function _toWire(rfc822) {
213
+ if (Buffer.isBuffer(rfc822)) return rfc822.toString("latin1");
214
+ if (typeof rfc822 !== "string") {
215
+ throw new DkimError("dkim/bad-input",
216
+ "message must be a Buffer or a string — got " +
217
+ (rfc822 === null ? "null" : typeof rfc822));
218
+ }
219
+ return Buffer.from(rfc822, "utf8").toString("latin1");
220
+ }
221
+
222
+ // The octets of a string that is already in the latin1 wire form above.
223
+ function _wireBytes(wire) {
224
+ return Buffer.from(wire, "latin1");
225
+ }
226
+
182
227
  // ---- Hashing + signing ----
183
228
 
184
229
  function _bodyHashB64(body, algorithm, canonBody, lcap) {
@@ -192,25 +237,29 @@ function _bodyHashB64(body, algorithm, canonBody, lcap) {
192
237
  // octet stream to lcap (slicing the raw body before canonicalizing diverges
193
238
  // whenever relaxed canon changes the byte count within the first lcap
194
239
  // octets — WSP-run collapse, trailing-WSP strip, CRLF normalization).
240
+ var buf = _wireBytes(canonicalized);
195
241
  if (typeof lcap === "number" && isFinite(lcap) && lcap >= 0) {
196
- var buf = Buffer.from(canonicalized, "utf8");
197
242
  hash.update(lcap < buf.length ? buf.subarray(0, lcap) : buf);
198
243
  } else {
199
- hash.update(canonicalized);
244
+ hash.update(buf);
200
245
  }
201
246
  return hash.digest("base64");
202
247
  }
203
248
 
204
249
  function _signString(strToSign, privateKey, algorithm) {
250
+ // §3.7's data hash is assembled from canonicalized header octets, so it takes
251
+ // the same treatment as the body: a header field carrying unencoded 8-bit
252
+ // content is signed as the octets it holds.
253
+ var toSign = _wireBytes(strToSign);
205
254
  if (algorithm === "rsa-sha256") {
206
255
  return nodeCrypto.createSign("RSA-SHA256")
207
- .update(strToSign).sign(privateKey).toString("base64");
256
+ .update(toSign).sign(privateKey).toString("base64");
208
257
  }
209
258
  if (algorithm === "ed25519-sha256") {
210
259
  // Ed25519 in node:crypto signs the raw message (it hashes
211
260
  // internally as part of EdDSA). Per RFC 8463 the verifier still
212
261
  // sees `a=ed25519-sha256` because the body hash is sha256.
213
- return nodeCrypto.sign(null, Buffer.from(strToSign, "utf8"), privateKey)
262
+ return nodeCrypto.sign(null, toSign, privateKey)
214
263
  .toString("base64");
215
264
  }
216
265
  throw new DkimError("dkim/bad-algorithm",
@@ -335,11 +384,15 @@ function create(opts) {
335
384
  }
336
385
 
337
386
  function sign(rfc822) {
338
- if (typeof rfc822 !== "string" || rfc822.length === 0) {
387
+ var gaveBuffer = Buffer.isBuffer(rfc822);
388
+ if ((!gaveBuffer && typeof rfc822 !== "string") || rfc822.length === 0) {
339
389
  throw new DkimError("dkim/bad-input",
340
- "sign() requires the rfc822 wire format as a non-empty string");
390
+ "sign() requires the rfc822 wire format as a non-empty Buffer or string");
341
391
  }
342
392
  var t0 = Date.now();
393
+ // A Buffer's own octets, or a string's UTF-8 encoding, one code unit per
394
+ // octet from here down.
395
+ rfc822 = _toWire(rfc822);
343
396
  var split = _splitHeadersBody(rfc822);
344
397
  var parsedHeaders = _parseHeaders(split.headers);
345
398
 
@@ -455,7 +508,17 @@ function create(opts) {
455
508
  durationMs: Date.now() - t0,
456
509
  });
457
510
 
458
- return dkimHeaderLine + rfc822;
511
+ // Returned in the shape it arrived in. A caller who handed over octets is
512
+ // relaying what comes back, and handing them a string would put the
513
+ // corruption this signs around straight back into the message.
514
+ var out = dkimHeaderLine + rfc822;
515
+ // A string caller gets their own string back, not the latin1 wire form of
516
+ // it: the octets are that string's UTF-8 encoding, so decoding them as
517
+ // UTF-8 reproduces exactly what they passed, with the ASCII signature
518
+ // header in front. Returning the wire form instead handed back a mojibake
519
+ // string that no longer re-encoded to the bytes just signed, so the
520
+ // message failed its own verify.
521
+ return gaveBuffer ? _wireBytes(out) : _wireBytes(out).toString("utf8");
459
522
  }
460
523
 
461
524
  return {
@@ -808,7 +871,7 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
808
871
  // accept legacy l= senders opts in via verify({ acceptBodyLengthLimit: true }).
809
872
  if (lcap !== undefined && !verifyOpts.acceptBodyLengthLimit) {
810
873
  var fullCanon = canonBody === "simple" ? _canonBodySimple(body) : _canonBodyRelaxed(body);
811
- if (lcap < Buffer.byteLength(fullCanon, "utf8")) {
874
+ if (lcap < Buffer.byteLength(fullCanon, "latin1")) {
812
875
  return { result: "fail",
813
876
  errors: ["DKIM-Signature l= leaves appended body content unsigned " +
814
877
  "(RFC 6376 §8.2 append-after-signature)"] };
@@ -924,7 +987,7 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
924
987
  var verified;
925
988
  try {
926
989
  verified = nodeCrypto.verify(nodeAlgo,
927
- Buffer.from(canonicalizedHeaders, "utf8"), keyObj, sigBuf);
990
+ _wireBytes(canonicalizedHeaders), keyObj, sigBuf);
928
991
  } catch (e) {
929
992
  return { result: "permerror",
930
993
  errors: ["DKIM verify threw: " + ((e && e.message) || String(e))] };
@@ -943,10 +1006,14 @@ var DKIM_CLOCK_SKEW_MS_MAX = C.TIME.hours(24);
943
1006
  var DKIM_CLOCK_SKEW_MS_DEFAULT = C.TIME.minutes(5);
944
1007
 
945
1008
  async function verify(rfc822, opts) {
946
- if (typeof rfc822 !== "string" || rfc822.length === 0) {
1009
+ if ((!Buffer.isBuffer(rfc822) && typeof rfc822 !== "string") || rfc822.length === 0) {
947
1010
  throw new DkimError("dkim/bad-input",
948
- "verify(): rfc822 must be a non-empty string");
1011
+ "verify(): rfc822 must be a non-empty Buffer or string");
949
1012
  }
1013
+ // The same octet boundary as sign(): a receiver holding wire bytes verifies
1014
+ // against those bytes rather than against a UTF-8 decode of them, which is
1015
+ // what a sender's signature actually covers.
1016
+ rfc822 = _toWire(rfc822);
950
1017
  opts = opts || {};
951
1018
  validateOpts(opts, ["dnsLookup", "audit", "clockSkewMs", "maxSignatures",
952
1019
  "minRsaBits", "acceptBodyLengthLimit"], "mail.dkim.verify");
@@ -1392,6 +1459,8 @@ module.exports = {
1392
1459
  _canonBodyRelaxedForTest: _canonBodyRelaxed,
1393
1460
  _canonBodySimpleForTest: _canonBodySimple,
1394
1461
  _stripBTagValue: _stripBTagValue, // RFC 6376 §3.5 — tag-aware b= zeroing; shared by the ARC seal verifier (internal cross-module helper)
1462
+ _toWire: _toWire, // Buffer|string → the message one code unit per octet; shared with the ARC verifier so both modules answer "which bytes is this message" the same way (internal cross-module helper)
1463
+ _wireBytes: _wireBytes, // the octets of a string already in that wire form (internal cross-module helper)
1395
1464
  _stripBTagValueForTest: _stripBTagValue,
1396
1465
  // The header-block parser that produces the { name, value } pairs fed to the
1397
1466
  // canonicalizers. Exposed so a golden-vector test can pin its byte-exact
package/lib/mail-helo.js CHANGED
@@ -370,6 +370,16 @@ async function _runFcrdns(ip, resolver) {
370
370
  if (!rev) {
371
371
  return result; // unparseable IP — caller already rejected
372
372
  }
373
+ // The resolver must be able to answer the question before the answer means
374
+ // anything. A missing method is not a DNS condition, and letting it fall into
375
+ // the catch below turned a broken call into a clean "no reverse name" for
376
+ // every address the check ever ran on — `passed` stayed false, which looks
377
+ // exactly like an address with no PTR record.
378
+ if (typeof resolver.queryPtr !== "function") {
379
+ throw new MailHeloError("mail-helo/resolver-missing-queryptr",
380
+ "fcrdns requires resolver.queryPtr(name); the supplied resolver has none, " +
381
+ "so reverse DNS cannot be checked and a pass cannot be claimed");
382
+ }
373
383
  try {
374
384
  var ptr = await resolver.queryPtr(rev);
375
385
  if (ptr && ptr.rrs) {
package/lib/mail-rbl.js CHANGED
@@ -253,9 +253,16 @@ function create(opts) {
253
253
  } catch (e) {
254
254
  // NXDOMAIN is the expected "not listed" response, not an error
255
255
  // condition. RFC 5782 §2.1.1 — absence of any A record means
256
- // "not in list". Resolver surfaces this as resolver/nxdomain-or-
257
- // error which we treat as the neutral verdict.
258
- if (e && e.code === "resolver/nxdomain-or-error") {
256
+ // "not in list".
257
+ //
258
+ // ONLY NXDOMAIN. The resolver used to report every non-zero RCODE under
259
+ // one code, so this branch also caught SERVFAIL and REFUSED and returned
260
+ // the same clean verdict — a blocklist lookup that failed read exactly
261
+ // like a host that is not on the list. Anyone able to break the query
262
+ // could therefore clear themselves, which is the wrong direction for a
263
+ // blocklist to fail in. A failure now falls through to `rv.error` below,
264
+ // where the caller can see it.
265
+ if (e && e.code === "resolver/nxdomain") {
259
266
  // Neutral — not listed; not an error.
260
267
  return rv;
261
268
  }