@blamejs/core 0.10.6 → 0.10.8

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.
@@ -36,6 +36,7 @@
36
36
  * External-database integration for app data — Postgres / MySQL / SQLite / MongoDB connection pooling, retry, circuit breaker, classification routing, residency enforcement, and audit hooks.
37
37
  */
38
38
  var retryHelper = require("./retry");
39
+ var bCrypto = require("./crypto");
39
40
  var C = require("./constants");
40
41
  var dbRoleContext = require("./db-role-context");
41
42
  var externalDbMigrate = require("./external-db-migrate");
@@ -754,8 +755,7 @@ async function transaction(fn, opts) {
754
755
  if (isTransient && attempt <= maxRetries) {
755
756
  _emitMetric("externaldb.transaction.retry", 1,
756
757
  { backend: b.name, code: txErr.code, attempt: String(attempt) });
757
- var nodeCrypto = require("node:crypto");
758
- var jitter = nodeCrypto.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
758
+ var jitter = bCrypto.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
759
759
  await safeAsync.sleep(attempt * 5 + jitter); // allow:raw-time-literal — sub-second backoff
760
760
  continue;
761
761
  }
package/lib/guard-dsn.js CHANGED
@@ -279,12 +279,15 @@ function compliancePosture(posture) {
279
279
  }
280
280
 
281
281
  function _splitBlocks(text) {
282
- // RFC 3464 §2.1: blank line separates per-message from
283
- // per-recipient blocks; blank lines also separate consecutive
284
- // per-recipient blocks.
285
- // Normalize CRLF + bare-CR to LF for split.
282
+ // RFC 3464 §2.1.1: block separator is `CRLF CRLF` only — a "blank
283
+ // line" in message-syntax terms. `\n\s*\n` admits `\v` / `\f` /
284
+ // mixed whitespace which a hostile sender can use to bend the
285
+ // block boundary (folded fields drift between per-message and
286
+ // per-recipient blocks). Normalize CR(LF)? → LF, then split on
287
+ // strict `\n\n` (an LF, an empty line, an LF) — anything else is
288
+ // either intra-block CFWS or an intra-field continuation.
286
289
  var normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // allow:regex-no-length-cap — input length already capped
287
- return normalized.split(/\n\s*\n/); // allow:regex-no-length-cap — input length already capped
290
+ return normalized.split("\n\n");
288
291
  }
289
292
 
290
293
  function _parseFieldBlock(block, maxHeaderLine) {
@@ -224,22 +224,26 @@ function validate(headerValue, opts) {
224
224
  }
225
225
  // RFC 2919 §2 requires AT LEAST one `.` (label + namespace);
226
226
  // strict/balanced ALSO require the namespace to be a FQDN, which
227
- // means a minimum of 3 labels total (label + ns-label + ns-tld)
228
- // OR a 2-label list-id where the namespace is `localhost`.
227
+ // means a minimum of 3 labels total (label + ns-label + ns-tld) OR
228
+ // a 2-label list-id where the namespace ends in a reserved-local
229
+ // TLD: `localhost` (RFC 6761 §6.3), `local` (RFC 6762 mDNS), or
230
+ // `lan` (IETF draft-chapin-rfc2606bis). All three are non-routable
231
+ // single-network labels and the FQDN floor doesn't apply.
232
+ var lastLabel = parts[parts.length - 1].toLowerCase();
233
+ var isLocalScopeTld = lastLabel === "localhost" || lastLabel === "local" || lastLabel === "lan";
229
234
  if (caps.requireFqdn) {
230
- var lastLabel = parts[parts.length - 1].toLowerCase();
231
- if (parts.length < 3 && lastLabel !== "localhost") { // allow:raw-byte-literal FQDN requires 3 labels for non-localhost
232
- return _refuse("list-id has < 3 labels for non-localhost namespace (FQDN required under '" +
235
+ if (parts.length < 3 && !isLocalScopeTld) { // allow:raw-byte-literal — FQDN requires ≥ 3 labels for non-local-scope namespace
236
+ return _refuse("list-id has < 3 labels for non-local-scope namespace (FQDN required under '" +
233
237
  (opts.profile || DEFAULT_PROFILE) + "')");
234
238
  }
235
239
  }
236
240
 
237
- // RFC 2919 §3: `localhost` namespace SHOULD carry 32-hex
238
- // randomness in the label.
239
- var isLocalhost = parts[parts.length - 1].toLowerCase() === "localhost";
240
- if (isLocalhost) {
241
+ // RFC 2919 §3: `localhost`-class namespaces SHOULD carry 32-hex
242
+ // randomness in the label so cross-host listserv operators can't
243
+ // collide. Applies to all three reserved-local TLDs.
244
+ if (isLocalScopeTld) {
241
245
  if (caps.requireRandomForLocalhost && !RANDOM_HEX_RE.test(listId)) { // allow:regex-no-length-cap — listId length-bounded above
242
- return _refuse("localhost namespace requires 32-hex random component per RFC 2919 §3 SHOULD");
246
+ return _refuse("local-scope namespace requires 32-hex random component per RFC 2919 §3 SHOULD");
243
247
  }
244
248
  }
245
249
 
@@ -134,6 +134,59 @@ var DANGEROUS_SCHEMES = Object.freeze({
134
134
  "blob:": true,
135
135
  });
136
136
 
137
+ // IP-literal + reserved-hostname refusal for HTTPS one-click URIs.
138
+ // One-click receivers POST to the URI without further operator gate;
139
+ // an attacker-supplied List-Unsubscribe URI pointing at `127.0.0.1`
140
+ // / `169.254.169.254` (cloud metadata) / `[::1]` / `localhost` lets
141
+ // the mailbox provider's auto-fetcher target the operator's own
142
+ // infrastructure — classic SSRF. The check is wholly host-name-shape
143
+ // based (no DNS resolution); DNS-rebinding defense is left to the
144
+ // fetcher (which should pin the IP across resolution + request).
145
+ var IPV4_LITERAL_RE = /^\d+\.\d+\.\d+\.\d+$/; // allow:regex-no-length-cap — anchored shape, hostname length bounded by URL parser
146
+ var IPV6_LITERAL_RE = /^\[[0-9A-Fa-f:.]+\]$/; // allow:regex-no-length-cap — anchored shape, hostname length bounded by URL parser
147
+ var RESERVED_LOCAL_HOSTS = Object.freeze({
148
+ "localhost": true,
149
+ "localhost.localdomain": true,
150
+ "ip6-localhost": true,
151
+ "ip6-loopback": true,
152
+ });
153
+
154
+ function _isRefusedAutoFetchHost(hostname, allowedHosts) {
155
+ if (typeof hostname !== "string" || hostname.length === 0) return "missing-host";
156
+ // Normalize the trailing root-zone dot BEFORE comparison — RFC 1034
157
+ // §3.1: `foo.` is the absolute form of `foo` (both resolve to the
158
+ // same target). A naive byte-equality check against `localhost`
159
+ // would let an attacker bypass the gate by appending the dot. Same
160
+ // for any reserved-local suffix family. Multiple trailing dots are
161
+ // not valid DNS but we strip them anyway to keep the gate robust
162
+ // against any URL parser that leaves them in `hostname`.
163
+ var lower = hostname.toLowerCase();
164
+ while (lower.length > 0 && lower.charAt(lower.length - 1) === ".") {
165
+ lower = lower.slice(0, -1);
166
+ }
167
+ if (lower.length === 0) return "missing-host";
168
+ if (IPV4_LITERAL_RE.test(lower) || IPV6_LITERAL_RE.test(lower)) return "ip-literal";
169
+ if (RESERVED_LOCAL_HOSTS[lower]) return "reserved-local-host";
170
+ // Hostname suffix refusal — RFC 6761 reserved / mDNS / single-network.
171
+ if (lower === "local" || lower.endsWith(".local")) return "reserved-local-suffix";
172
+ if (lower === "lan" || lower.endsWith(".lan")) return "reserved-local-suffix";
173
+ if (lower === "internal" || lower.endsWith(".internal")) return "reserved-local-suffix";
174
+ // Optional operator allowlist — when supplied, hostname (or any
175
+ // ancestor domain) MUST be present.
176
+ if (Array.isArray(allowedHosts) && allowedHosts.length > 0) {
177
+ var matched = false;
178
+ for (var i = 0; i < allowedHosts.length; i += 1) {
179
+ var allowed = String(allowedHosts[i]).toLowerCase();
180
+ if (lower === allowed || lower.endsWith("." + allowed)) {
181
+ matched = true;
182
+ break;
183
+ }
184
+ }
185
+ if (!matched) return "not-on-allowlist";
186
+ }
187
+ return null;
188
+ }
189
+
137
190
  /**
138
191
  * @primitive b.guardListUnsubscribe.validate
139
192
  * @signature b.guardListUnsubscribe.validate(headers, opts?)
@@ -225,12 +278,25 @@ function validate(headers, opts) {
225
278
  { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
226
279
  }
227
280
  if (scheme === "https:") {
281
+ var parsed;
228
282
  try {
229
- safeUrl.parse(u, { allowedProtocols: safeUrl.ALLOW_HTTPS });
283
+ parsed = safeUrl.parse(u, { allowedProtocols: safeUrl.ALLOW_HTTPS });
230
284
  } catch (e) {
231
285
  return _verdict("refuse", "HTTPS URI '" + _trunc(u) + "' failed safeUrl parse: " + (e && e.message || String(e)),
232
286
  { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
233
287
  }
288
+ // SSRF defense — refuse IP-literal hosts, loopback names,
289
+ // reserved-local TLDs, and (when operator supplied
290
+ // `allowedHosts`) anything outside the allowlist. The mailbox
291
+ // provider's auto-fetcher POSTs without our involvement; the
292
+ // header is the only place this can be stopped.
293
+ var refusedHostReason = _isRefusedAutoFetchHost(parsed.hostname, opts.allowedHosts);
294
+ if (refusedHostReason) {
295
+ return _verdict("refuse",
296
+ "HTTPS URI '" + _trunc(u) + "' host '" + parsed.hostname +
297
+ "' refused (" + refusedHostReason + "; auto-fetch SSRF defense)",
298
+ { uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
299
+ }
234
300
  hasHttpsUri = true;
235
301
  } else if (scheme === "mailto:") {
236
302
  hasMailtoUri = true;
@@ -74,6 +74,13 @@ var COMPLIANCE_POSTURES = Object.freeze({
74
74
  // CVE-2021-42574 RTLO class in mail header context).
75
75
  var BIDI_RE = /[؜‎‏‪-‮⁦-⁩]/;
76
76
 
77
+ // RFC 5322 §3.2.3 dot-atom-text — used at strict profile to validate
78
+ // the id-left and id-right shape inside the bracketed Message-Id.
79
+ // `atext` = ALPHA / DIGIT / "!#$%&'*+-/=?^_`{|}~"; `dot-atom-text` is
80
+ // 1*atext *("." 1*atext). Length-bounded by the maxBytes cap above so
81
+ // the regex CPU is amortised; pattern is single-pass linear.
82
+ var DOT_ATOM_TEXT_RE = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+(?:\.[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
83
+
77
84
  /**
78
85
  * @primitive b.guardMessageId.validate
79
86
  * @signature b.guardMessageId.validate(value, opts?)
@@ -154,6 +161,25 @@ function validate(value, opts) {
154
161
  throw new GuardMessageIdError("message-id/nested-brackets",
155
162
  "guardMessageId.validate: nested angle brackets refused");
156
163
  }
164
+ // RFC 5322 §3.6.4: id-left and id-right MUST conform to
165
+ // dot-atom-text shape (§3.2.3). A second `@` inside id-left or
166
+ // id-right falls out of dot-atom-text and is refused here. The
167
+ // last `@` is the local/domain separator — `lastIndexOf` rather
168
+ // than `indexOf` handles `a@b@c` correctly: id-left would be
169
+ // `a@b` which fails dot-atom-text on the `@` character.
170
+ var atLast = inner.lastIndexOf("@");
171
+ var idLeft = inner.slice(0, atLast);
172
+ var idRight = inner.slice(atLast + 1);
173
+ if (!DOT_ATOM_TEXT_RE.test(idLeft)) { // allow:regex-no-length-cap — idLeft length-bounded by maxBytes above
174
+ throw new GuardMessageIdError("message-id/id-left-shape",
175
+ "guardMessageId.validate: id-left '" + idLeft +
176
+ "' not dot-atom-text shape (RFC 5322 §3.2.3 / §3.6.4)");
177
+ }
178
+ if (!DOT_ATOM_TEXT_RE.test(idRight)) { // allow:regex-no-length-cap — idRight length-bounded by maxBytes above
179
+ throw new GuardMessageIdError("message-id/id-right-shape",
180
+ "guardMessageId.validate: id-right '" + idRight +
181
+ "' not dot-atom-text shape (RFC 5322 §3.2.3 / §3.6.4)");
182
+ }
157
183
  }
158
184
 
159
185
  return value;
@@ -128,10 +128,19 @@ function _bodyHashB64(body, algorithm) {
128
128
  return nodeCrypto.createHash(hashAlgo).update(canonical).digest("base64");
129
129
  }
130
130
 
131
+ // RFC 8617 §5 — ARC chains MUST NOT exceed 50 hops. The verifier
132
+ // caps in `mail-auth.js` (`ARC_MAX_HOPS`); the signer's prior-hop
133
+ // extractor needs the same ceiling so a message arriving with a
134
+ // hostile chain (51+ instances) doesn't expand the per-hop walk to
135
+ // unbounded work before the signer's own validation catches up.
136
+ var ARC_MAX_HOPS_FOR_EXTRACT = 50; // allow:raw-byte-literal — RFC 8617 §5 chain bound
137
+
131
138
  function _arcExtractPriorHops(parsedHeaders) {
132
139
  // Walk parsedHeaders; for each ARC-Authentication-Results /
133
140
  // ARC-Message-Signature / ARC-Seal entry, extract instance via i=N
134
- // and group by hop.
141
+ // and group by hop. The `i=` value is bounded against the RFC's
142
+ // 50-hop ceiling before being used as a map key, so an attacker-
143
+ // chosen `i=999999` can't allocate a sparse map.
135
144
  var hopMap = {};
136
145
  for (var i = 0; i < parsedHeaders.length; i += 1) {
137
146
  var h = parsedHeaders[i];
@@ -142,11 +151,22 @@ function _arcExtractPriorHops(parsedHeaders) {
142
151
  var iMatch = h.value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — ARC header bounded by RFC 5322 §2.1.1
143
152
  if (!iMatch) continue;
144
153
  var instance = parseInt(iMatch[1], 10);
154
+ if (!isFinite(instance) || instance < 1 || instance > ARC_MAX_HOPS_FOR_EXTRACT) {
155
+ // Out-of-spec instance number — refuse to consider it. Upstream
156
+ // `sign` will see `priorHops.length !== opts.instance - 1` and
157
+ // refuse the message.
158
+ continue;
159
+ }
145
160
  if (!hopMap[instance]) hopMap[instance] = { instance: instance };
146
161
  hopMap[instance][lcName] = h.value;
147
162
  }
148
163
  var hops = [];
149
164
  var keys = Object.keys(hopMap).sort(function (a, b) { return Number(a) - Number(b); });
165
+ if (keys.length > ARC_MAX_HOPS_FOR_EXTRACT) {
166
+ throw new MailAuthError("arc-sign/chain-too-long",
167
+ "_arcExtractPriorHops: chain has " + keys.length +
168
+ " hops, exceeds RFC 8617 §5 ceiling of " + ARC_MAX_HOPS_FOR_EXTRACT);
169
+ }
150
170
  for (var k = 0; k < keys.length; k += 1) hops.push(hopMap[keys[k]]);
151
171
  return hops;
152
172
  }
package/lib/mail-auth.js CHANGED
@@ -37,6 +37,7 @@ var net = require("node:net");
37
37
  var nodeCrypto = require("node:crypto");
38
38
  var lazyRequire = require("./lazy-require");
39
39
  var validateOpts = require("./validate-opts");
40
+ var bCrypto = require("./crypto");
40
41
  var C = require("./constants");
41
42
  var dkim = require("./mail-dkim");
42
43
  var safeXml = require("./parsers/safe-xml");
@@ -722,7 +723,7 @@ async function dmarcEvaluate(opts) {
722
723
  var u32 = (hash[0] << 24 >>> 0) + (hash[1] << 16) + (hash[2] << 8) + hash[3]; // allow:raw-byte-literal — uint32 bit assembly
723
724
  sampleRoll = u32 % 100; // allow:raw-byte-literal — pct sample roll
724
725
  } else {
725
- sampleRoll = nodeCrypto.randomInt(0, 100); // allow:raw-byte-literal — pct sample roll
726
+ sampleRoll = bCrypto.randomInt(0, 100); // allow:raw-byte-literal — pct sample roll
726
727
  }
727
728
  var sampled = !pass && pct < 100 && sampleRoll >= pct;
728
729
  var recommendedAction = pass ? "deliver" :
package/lib/mail-dkim.js CHANGED
@@ -525,6 +525,11 @@ var DKIM_KEY_CACHE_MAX_ENTRIES = 1024;
525
525
  // receivers cap at 5–8. Operators that legitimately accept more
526
526
  // override via verify({ maxSignatures }).
527
527
  var DKIM_MAX_SIGNATURES_PER_MESSAGE = 8; // allow:raw-byte-literal — receiver-fan-out DoS bound
528
+ // Operator-supplied `maxSignatures` opt is range-checked against this
529
+ // ceiling. RFC 6376 §6.1 sets no upper bound; 16 is generous headroom
530
+ // for legitimate relay chains with hop signatures while keeping the
531
+ // verify-fan-out within a CPU-DoS envelope.
532
+ var DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING = 16; // allow:raw-byte-literal — operator-opt range ceiling
528
533
 
529
534
  function _cacheGet(qname) {
530
535
  var ent = DKIM_KEY_CACHE.get(qname);
@@ -836,6 +841,7 @@ async function verify(rfc822, opts) {
836
841
  opts = opts || {};
837
842
  validateOpts(opts, ["dnsLookup", "audit", "clockSkewMs", "maxSignatures",
838
843
  "minRsaBits"], "mail.dkim.verify");
844
+ var auditOn = opts.audit !== false;
839
845
 
840
846
  // Bounded clock skew: refuse non-numeric / negative / infinite /
841
847
  // beyond-ceiling. Throwing on bad config-time input per the
@@ -855,10 +861,27 @@ async function verify(rfc822, opts) {
855
861
  clockSkewMs = Math.floor(opts.clockSkewMs);
856
862
  }
857
863
 
858
- var maxSignatures = (typeof opts.maxSignatures === "number" &&
859
- isFinite(opts.maxSignatures) && opts.maxSignatures >= 1)
860
- ? Math.floor(opts.maxSignatures)
861
- : DKIM_MAX_SIGNATURES_PER_MESSAGE;
864
+ // RFC 6376 §6.1 verifier MUST handle multiple signatures but the
865
+ // RFC sets no count cap. An unbounded count is a CPU-DoS surface
866
+ // (each sig forces a DNS fetch + cryptographic verify). Range 1-16
867
+ // — mainstream receivers (Gmail/Yahoo/MS 2024 bulk-sender guidance)
868
+ // cite 2-3 valid signatures per message as the operational ceiling;
869
+ // 16 is generous headroom for relay chains with hop signatures. The
870
+ // operator opt is range-checked at config time — values < 1 or > 16
871
+ // throw rather than silently clamp so an over-large config doesn't
872
+ // re-introduce the DoS surface.
873
+ var maxSignatures = DKIM_MAX_SIGNATURES_PER_MESSAGE;
874
+ if (opts.maxSignatures !== undefined) {
875
+ if (typeof opts.maxSignatures !== "number" ||
876
+ !isFinite(opts.maxSignatures) ||
877
+ opts.maxSignatures < 1 ||
878
+ opts.maxSignatures > DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING) {
879
+ throw new DkimError("dkim/bad-max-signatures",
880
+ "verify: maxSignatures must be an integer in [1, " +
881
+ DKIM_MAX_SIGNATURES_PER_MESSAGE_CEILING + "] (got " + opts.maxSignatures + ")");
882
+ }
883
+ maxSignatures = Math.floor(opts.maxSignatures);
884
+ }
862
885
  var verifyOpts = { minRsaBits: opts.minRsaBits };
863
886
 
864
887
  var split = _splitHeadersBody(rfc822);
@@ -867,12 +890,30 @@ async function verify(rfc822, opts) {
867
890
  if (sigHeaders.length === 0) {
868
891
  return [{ result: "none", errors: ["no DKIM-Signature headers"] }];
869
892
  }
870
- // RFC 6376 §6.1 verifier MUST handle multiple signatures but the
871
- // RFC sets no count cap. An unbounded count is a CPU-DoS surface
872
- // (each sig forces a DNS fetch + cryptographic verify). Cap and
873
- // surface the truncation in the result for operator visibility.
893
+ // When the message carries more signatures than the cap allows,
894
+ // surface a `policy` verdict before any cryptographic work runs.
895
+ // The prior `slice(0, maxSignatures)` shape silently truncated; an
896
+ // operator-visible refusal lets postmasters see DoS attempts in
897
+ // their authentication-results stream.
874
898
  if (sigHeaders.length > maxSignatures) {
875
- sigHeaders = sigHeaders.slice(0, maxSignatures);
899
+ if (auditOn) {
900
+ try {
901
+ audit().safeEmit({
902
+ action: "dkim.verify.signature_count_cap",
903
+ outcome: "denied",
904
+ actor: null,
905
+ metadata: {
906
+ sigCount: sigHeaders.length,
907
+ maxSignatures: maxSignatures,
908
+ severity: "warning",
909
+ },
910
+ });
911
+ } catch (_e) { /* drop-silent */ }
912
+ }
913
+ return [{ result: "policy",
914
+ errors: ["DKIM-Signature count " + sigHeaders.length +
915
+ " exceeds maxSignatures=" + maxSignatures +
916
+ " (RFC 6376 §6.1; verifier DoS cap)"] }];
876
917
  }
877
918
 
878
919
  var results = [];
@@ -144,6 +144,51 @@ var pkgVersion = require("../package.json").version;
144
144
  var ERR_CLAMP = 200; // allow:raw-byte-literal — protocol-reply error-message clamp
145
145
  var LINE_PREVIEW = 80; // allow:raw-byte-literal — audit-line preview clamp
146
146
 
147
+ // RFC 9051 §6.3.12 + RFC 5322 §3.3 date-time parser for IMAP APPEND.
148
+ // Format: `DD-Mon-YYYY HH:MM:SS ±HHMM` where Mon is the 3-letter
149
+ // English month abbreviation (case-insensitive on parse, but the IMAP
150
+ // spec emits canonical mixed-case `Jan`/`Feb`/...). Returns the
151
+ // millisecond epoch, or null on any parse failure — the caller emits
152
+ // `BAD` rather than silently using `Date.now()`.
153
+ var IMAP_MONTHS = Object.freeze({
154
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, // allow:raw-byte-literal — month-index table (0-5)
155
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, // allow:raw-byte-literal — month-index table (6-11)
156
+ });
157
+ var IMAP_DT_RE = /^\s*(\d{1,2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+([+-])(\d{2})(\d{2})\s*$/;
158
+ function _parseImapDateTime(s) {
159
+ if (typeof s !== "string") return null;
160
+ var m = s.match(IMAP_DT_RE); // allow:regex-no-length-cap — input bounded by IMAP literal cap
161
+ if (!m) return null;
162
+ var day = parseInt(m[1], 10);
163
+ var month = IMAP_MONTHS[m[2].toLowerCase()];
164
+ if (month === undefined) return null;
165
+ var year = parseInt(m[3], 10);
166
+ var hour = parseInt(m[4], 10);
167
+ var min = parseInt(m[5], 10);
168
+ var sec = parseInt(m[6], 10);
169
+ var sign = m[7] === "-" ? -1 : 1;
170
+ var tzH = parseInt(m[8], 10);
171
+ var tzM = parseInt(m[9], 10);
172
+ if (day < 1 || day > 31 || hour > 23 || min > 59 || sec > 59 || tzH > 23 || tzM > 59) return null;
173
+ var utcMs = Date.UTC(year, month, day, hour, min, sec);
174
+ if (!isFinite(utcMs)) return null;
175
+ // RFC 5322 §3.3 — date-time MUST be a real calendar date. `Date.UTC`
176
+ // silently normalises impossible inputs (`Feb 31 2026` → `Mar 3 2026`);
177
+ // round-trip via the calendar fields and refuse any drift so a
178
+ // hostile client can't smuggle a different internalDate than the
179
+ // wire suggests.
180
+ var probe = new Date(utcMs);
181
+ if (probe.getUTCFullYear() !== year ||
182
+ probe.getUTCMonth() !== month ||
183
+ probe.getUTCDate() !== day ||
184
+ probe.getUTCHours() !== hour ||
185
+ probe.getUTCMinutes() !== min ||
186
+ probe.getUTCSeconds() !== sec) {
187
+ return null;
188
+ }
189
+ return utcMs - sign * (tzH * C.TIME.hours(1) + tzM * C.TIME.minutes(1));
190
+ }
191
+
147
192
  // Mailbox name validator. RFC 9051 §5.1 — UTF-8 hierarchy. Refuse
148
193
  // path-traversal (`..`), NUL, C0 controls, leading/trailing slash,
149
194
  // oversize.
@@ -707,15 +752,39 @@ function create(opts) {
707
752
 
708
753
  function _parseLoginArgs(args) {
709
754
  if (typeof args !== "string") return null;
710
- // Quoted or atom — simple parser sufficient for happy path.
755
+ // Quoted or atom — RFC 9051 §5.1 quoted ABNF. Inside a quoted
756
+ // string `\"` and `\\` are escape sequences for `"` and `\`
757
+ // respectively; any other `\<chr>` is invalid. The earlier shape
758
+ // terminated the quoted string at the first `"`, so a hostile
759
+ // client passing `LOGIN "alice\"@example.com" "pw"` would have
760
+ // its username truncated at `alice` and the rest of the line
761
+ // reparsed as the password / literal — wrong identity bound to
762
+ // the AUTH state.
711
763
  var rest = args.trim();
712
764
  function _take() {
713
765
  if (rest[0] === "\"") {
714
- var end = rest.indexOf("\"", 1);
715
- if (end === -1) return null;
716
- var v = rest.slice(1, end);
717
- rest = rest.slice(end + 1).trim();
718
- return v;
766
+ // Walk the quoted-string body, accumulating into `out` while
767
+ // honoring the `\"` / `\\` escape pairs. A bare `\` followed
768
+ // by any other character is refused (parse fails → null).
769
+ var out = "";
770
+ var i = 1;
771
+ while (i < rest.length) {
772
+ var ch = rest.charAt(i);
773
+ if (ch === "\\") {
774
+ var esc = rest.charAt(i + 1);
775
+ if (esc !== "\"" && esc !== "\\") return null;
776
+ out += esc;
777
+ i += 2;
778
+ continue;
779
+ }
780
+ if (ch === "\"") {
781
+ rest = rest.slice(i + 1).trim();
782
+ return out;
783
+ }
784
+ out += ch;
785
+ i += 1;
786
+ }
787
+ return null; // unterminated quoted string
719
788
  }
720
789
  var sp = rest.indexOf(" ");
721
790
  var v2 = sp === -1 ? rest : rest.slice(0, sp);
@@ -857,6 +926,22 @@ function create(opts) {
857
926
  }
858
927
  var name = _unquote(match[1]);
859
928
  var flags = match[2] ? match[2].split(/\s+/).filter(Boolean) : [];
929
+ // RFC 9051 §6.3.12 — optional date-time argument sets INTERNALDATE
930
+ // on the appended message. Earlier shape captured the token but
931
+ // never threaded it; backends now receive it as `internalDate`
932
+ // (ms-since-epoch) and the mail-store applies it instead of the
933
+ // append-time clock. Refused as syntax error when the date-time
934
+ // can't be parsed (rather than silently using the clock).
935
+ var dateTimeArg = match[3] ? _unquote(match[3]) : null;
936
+ var internalDate = null;
937
+ if (dateTimeArg) {
938
+ internalDate = _parseImapDateTime(dateTimeArg);
939
+ if (internalDate === null) {
940
+ _writeTagged(socket, tag, "BAD APPEND date-time '" + dateTimeArg +
941
+ "' not in RFC 9051 §6.3.12 / RFC 5322 §3.3 date-time grammar");
942
+ return;
943
+ }
944
+ }
860
945
  if (!_validateMailboxName(name, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
861
946
  _writeTagged(socket, tag, "BAD Mailbox name refused");
862
947
  return;
@@ -891,10 +976,12 @@ function create(opts) {
891
976
  err.limit = q.capBytes;
892
977
  throw err;
893
978
  }
894
- return mailStore.appendMessage(name, literalBody, { actor: state.actor, flags: flags });
979
+ return mailStore.appendMessage(name, literalBody, {
980
+ actor: state.actor, flags: flags, internalDate: internalDate });
895
981
  });
896
982
  }
897
- return mailStore.appendMessage(name, literalBody, { actor: state.actor, flags: flags });
983
+ return mailStore.appendMessage(name, literalBody, {
984
+ actor: state.actor, flags: flags, internalDate: internalDate });
898
985
  })
899
986
  .then(function (info) {
900
987
  _emit("mail.server.imap.append",
@@ -947,7 +1034,10 @@ function create(opts) {
947
1034
 
948
1035
  function _handleFetch(state, socket, tag, args, useUid) {
949
1036
  if (!state.selectedMailbox) {
950
- _writeTagged(socket, tag, "NO No mailbox selected");
1037
+ // RFC 9051 §6.4.5 FETCH outside of Selected state is a
1038
+ // protocol-context violation, not a server-policy refusal.
1039
+ // BAD signals the client to fix its dialog rather than retry.
1040
+ _writeTagged(socket, tag, "BAD FETCH only valid in Selected state (RFC 9051 §6.4.5)");
951
1041
  return;
952
1042
  }
953
1043
  if (typeof mailStore.fetchRange !== "function") {
@@ -988,7 +1078,11 @@ function create(opts) {
988
1078
 
989
1079
  function _handleStore(state, socket, tag, args, useUid) {
990
1080
  if (!state.selectedMailbox) {
991
- _writeTagged(socket, tag, "NO No mailbox selected");
1081
+ // RFC 9051 §6.4.6 STORE outside of Selected state is a
1082
+ // protocol-context violation. BAD (not NO) is the correct
1083
+ // response per the IMAP grammar; UID STORE has the same rule
1084
+ // since the verb is just a `UID` prefix on STORE.
1085
+ _writeTagged(socket, tag, "BAD STORE only valid in Selected state (RFC 9051 §6.4.6)");
992
1086
  return;
993
1087
  }
994
1088
  if (state.selectedReadOnly) {