@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.
- package/CHANGELOG.md +118 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +33 -32
- package/lib/guard-managesieve-command.js +49 -9
- package/lib/guard-regex.js +3 -5
- package/lib/guard-yaml.js +60 -15
- package/lib/mail-agent.js +23 -9
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +75 -20
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +121 -55
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +168 -25
- package/lib/mail-server-mx.js +76 -4
- package/lib/mail-server-net.js +126 -0
- package/lib/mail-server-pop3.js +73 -22
- package/lib/mail-server-submission.js +21 -2
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +278 -40
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/guard-filename.js
CHANGED
|
@@ -253,15 +253,13 @@ function _foldSuperscriptDigits(s) {
|
|
|
253
253
|
// A `..` segment: the whole name, or bounded by path separators on both sides.
|
|
254
254
|
// Bounded, not merely present — `..foo` and `a..b` are ordinary names.
|
|
255
255
|
function _hasTraversalSegment(name) {
|
|
256
|
-
|
|
257
|
-
if (name.charAt(i) !== "." || name.charAt(i + 1) !== ".") continue;
|
|
256
|
+
return codepointClass.hasPairWhere(name, ".", ".", function (i) {
|
|
258
257
|
var beforeOk = i === 0 ||
|
|
259
258
|
PATH_SEPARATORS.indexOf(name.charAt(i - 1)) !== -1;
|
|
260
259
|
var afterOk = i + 2 === name.length ||
|
|
261
260
|
PATH_SEPARATORS.indexOf(name.charAt(i + 2)) !== -1;
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
return false;
|
|
261
|
+
return beforeOk && afterOk;
|
|
262
|
+
});
|
|
265
263
|
}
|
|
266
264
|
|
|
267
265
|
function _hasAnyFolded(name, needles) {
|
|
@@ -280,17 +278,13 @@ function _hasUncPrefix(name) {
|
|
|
280
278
|
|
|
281
279
|
// An NTFS alternate-data-stream suffix: a colon followed by a run with no
|
|
282
280
|
// further colon and no separator in it, at the very end of the name.
|
|
283
|
-
// One wording for every path that refuses a stream-suffixed name
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// HONOURS the option, so it never reaches this text.
|
|
291
|
-
var ADS_SCOPE_NOTE = "always refused here; adsPolicy \"allow\" applies only " +
|
|
292
|
-
"to verifyExtractionPath";
|
|
293
|
-
var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream) — " + ADS_SCOPE_NOTE;
|
|
281
|
+
// One wording for every path that refuses a stream-suffixed name — sanitize in
|
|
282
|
+
// either mode, validate, gate, verifyExtractionPath — so a caller meets the same
|
|
283
|
+
// answer whichever door they arrive at, and the way out is named where they are
|
|
284
|
+
// standing rather than somewhere else in the documentation.
|
|
285
|
+
var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream); set " +
|
|
286
|
+
"adsPolicy \"allow\" when the target filesystem is not NTFS and a colon is " +
|
|
287
|
+
"an ordinary filename character there";
|
|
294
288
|
var ADS_MESSAGE = "filename contains " + ADS_SNIPPET;
|
|
295
289
|
|
|
296
290
|
function _hasAdsSuffix(name) {
|
|
@@ -474,9 +468,11 @@ function _detectIssues(input, opts) {
|
|
|
474
468
|
|
|
475
469
|
// 6. NTFS alternate data streams — `name:stream`. Unconditional: a write to
|
|
476
470
|
// `name:stream` lands on a hidden stream of the base file rather than the
|
|
477
|
-
// file the caller named
|
|
471
|
+
// file the caller named — on Windows. On the Linux targets `adsPolicy`
|
|
472
|
+
// exists for, a colon is an ordinary filename character, so the operator
|
|
473
|
+
// holds the switch and the finding is suppressed when they set it.
|
|
478
474
|
{
|
|
479
|
-
if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
475
|
+
if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
480
476
|
// Only flag when there's a `:` followed by stream-name characters
|
|
481
477
|
// and we're NOT at the start (relative path indicator).
|
|
482
478
|
issues.push({
|
|
@@ -673,17 +669,21 @@ function _sanitize(input, opts) {
|
|
|
673
669
|
name = "_" + name;
|
|
674
670
|
}
|
|
675
671
|
|
|
676
|
-
// ADS detection.
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
672
|
+
// ADS detection. On Windows a `name:stream` write lands on a hidden stream of
|
|
673
|
+
// the base file rather than on a file anyone can see, so this is refused by
|
|
674
|
+
// default in every profile.
|
|
675
|
+
//
|
|
676
|
+
// It is an opt-out rather than an absolute, because the check is lexical and
|
|
677
|
+
// a colon is an ordinary filename character on the Linux targets these
|
|
678
|
+
// policies exist for: `12:30 notes.txt` has the same shape as an attack and
|
|
679
|
+
// is a timestamped note (#623). Only the operator knows which filesystem the
|
|
680
|
+
// name will be written to, which is why they get the switch.
|
|
682
681
|
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
682
|
+
// An earlier attempt refused regardless and put the scope in the message,
|
|
683
|
+
// which reads as a considered boundary right up until a caller has set every
|
|
684
|
+
// documented opt-out and still cannot store the file. An option that is
|
|
685
|
+
// accepted and documented as opting out must opt out.
|
|
686
|
+
if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name)) {
|
|
687
687
|
throw _err("filename.ntfs-ads", ADS_MESSAGE);
|
|
688
688
|
}
|
|
689
689
|
|
|
@@ -735,9 +735,10 @@ function _sanitize(input, opts) {
|
|
|
735
735
|
* // than refusing is safe
|
|
736
736
|
* reservedCharPolicy: "reject"|"strip"|"allow",
|
|
737
737
|
* reservedNamePolicy: "reject"|"audit"|"allow",
|
|
738
|
-
* adsPolicy: "reject"|"allow", //
|
|
739
|
-
* // is
|
|
740
|
-
* //
|
|
738
|
+
* adsPolicy: "reject"|"allow", // "allow" when the target
|
|
739
|
+
* // filesystem is not NTFS
|
|
740
|
+
* // and a colon is ordinary
|
|
741
|
+
* // there (Linux, macOS)
|
|
741
742
|
* leadingTrailingPolicy: "reject"|"strip"|"allow",
|
|
742
743
|
* shellExecExtPolicy: "reject"|"audit"|"allow",
|
|
743
744
|
* pathSeparatorsPolicy: "reject"|"audit"|"allow",
|
|
@@ -817,7 +818,7 @@ function _sanitizeStripMode(input, opts) {
|
|
|
817
818
|
if (_hasUncPrefix(name)) {
|
|
818
819
|
throw _err("filename.unc", "UNC path syntax");
|
|
819
820
|
}
|
|
820
|
-
if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
821
|
+
if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
|
|
821
822
|
throw _err("filename.ntfs-ads", ADS_MESSAGE);
|
|
822
823
|
}
|
|
823
824
|
if (Buffer.byteLength(name, "utf8") > opts.maxBytes) {
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
* under strict (RFC 4954 §4 class), validates per-verb shape.
|
|
103
103
|
*/
|
|
104
104
|
|
|
105
|
+
var C = require("./constants");
|
|
105
106
|
var { defineClass } = require("./framework-error");
|
|
106
107
|
var gateContract = require("./gate-contract");
|
|
107
108
|
var codepointClass = require("./codepoint-class");
|
|
@@ -112,6 +113,12 @@ var GuardManageSieveCommandError = defineClass("GuardManageSieveCommandError",
|
|
|
112
113
|
|
|
113
114
|
var DEFAULT_PROFILE = "strict";
|
|
114
115
|
|
|
116
|
+
// A SASL token — the AUTHENTICATE initial response and every later response in
|
|
117
|
+
// a multi-step exchange. Bounded far below the script cap because it is a
|
|
118
|
+
// base64 blob of credentials, not a script body, and because the client
|
|
119
|
+
// declaring its size is by definition not yet authenticated.
|
|
120
|
+
var MAX_SASL_TOKEN_BYTES = C.BYTES.kib(4);
|
|
121
|
+
|
|
115
122
|
var PROFILES = Object.freeze({
|
|
116
123
|
strict: {
|
|
117
124
|
maxLineBytes: 8192, // 8 KiB per-line cap (strict)
|
|
@@ -339,13 +346,15 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
|
|
|
339
346
|
throw new GuardManageSieveCommandError("guard-managesieve-command/literal-plus-refused",
|
|
340
347
|
"guardManageSieveCommand.validate: LITERAL+ refused under profile '" + profileName + "'");
|
|
341
348
|
}
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
349
|
+
// A SASL token, not a script body, so it is bounded well below the
|
|
350
|
+
// script cap. The same number bounds the client's LATER responses in a
|
|
351
|
+
// multi-step exchange (b.mail.server.managesieve reads it from
|
|
352
|
+
// MAX_SASL_TOKEN_BYTES): one exchange, one bound, whichever round the
|
|
353
|
+
// token arrives on and whichever representation it uses.
|
|
354
|
+
if (n > MAX_SASL_TOKEN_BYTES) {
|
|
346
355
|
throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
|
|
347
356
|
"guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
|
|
348
|
-
n + " bytes exceeds
|
|
357
|
+
n + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
|
|
349
358
|
}
|
|
350
359
|
literalBytes = n;
|
|
351
360
|
literalPlus = isPlus;
|
|
@@ -478,10 +487,34 @@ function _validateRenamescript(rest, caps) {
|
|
|
478
487
|
return { verb: "RENAMESCRIPT", args: [first.value, second.value] };
|
|
479
488
|
}
|
|
480
489
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
490
|
+
/**
|
|
491
|
+
* @primitive b.guardManageSieveCommand.parseQuotedString
|
|
492
|
+
* @signature b.guardManageSieveCommand.parseQuotedString(s)
|
|
493
|
+
* @since 0.18.54
|
|
494
|
+
* @status stable
|
|
495
|
+
* @related b.guardManageSieveCommand.validate, b.mail.server.managesieve.create
|
|
496
|
+
*
|
|
497
|
+
* Read a leading RFC 5804 §1.2 quoted string off `s` and return
|
|
498
|
+
* `{ value, rest }`, where `value` is the unescaped content and `rest` is
|
|
499
|
+
* what follows with leading whitespace removed. Returns `null` when `s` does
|
|
500
|
+
* not begin with a double quote.
|
|
501
|
+
*
|
|
502
|
+
* The production honours the `\"` and `\\` escapes, and refuses NUL, CR and
|
|
503
|
+
* LF inside the quotes: those end a line-oriented protocol record, so a
|
|
504
|
+
* string carrying one would split the command it appears in.
|
|
505
|
+
*
|
|
506
|
+
* Exposed because a `string` also arrives outside a command, as the client's
|
|
507
|
+
* reply to a SASL challenge. That line never reaches `validate`, and a second
|
|
508
|
+
* hand-rolled unquoting there would be free to lose the escape handling and
|
|
509
|
+
* the control-byte refusal this one has. `b.mail.server.managesieve` reads
|
|
510
|
+
* its SASL responses through this.
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* b.guardManageSieveCommand.parseQuotedString('"PLAIN" {12+}');
|
|
514
|
+
* // → { value: "PLAIN", rest: "{12+}" }
|
|
515
|
+
* b.guardManageSieveCommand.parseQuotedString("PLAIN");
|
|
516
|
+
* // → null
|
|
517
|
+
*/
|
|
485
518
|
function _parseQuotedString(s) {
|
|
486
519
|
if (s.length === 0 || s.charCodeAt(0) !== 0x22) return null; // DQUOTE
|
|
487
520
|
var out = "";
|
|
@@ -572,5 +605,12 @@ module.exports = gateContract.defineParser({
|
|
|
572
605
|
extra: {
|
|
573
606
|
KNOWN_VERBS: KNOWN_VERBS,
|
|
574
607
|
ZERO_ARG_VERBS: ZERO_ARG_VERBS,
|
|
608
|
+
MAX_SASL_TOKEN_BYTES: MAX_SASL_TOKEN_BYTES,
|
|
609
|
+
// The RFC 5804 §1.2 "string" production. The listener needs it for the
|
|
610
|
+
// client's reply to a SASL challenge, which arrives as a bare string on its
|
|
611
|
+
// own line rather than as a command argument — so it never reaches
|
|
612
|
+
// validate(), and hand-rolling a second dquote-strip there would drop this
|
|
613
|
+
// one's escape handling and its refusal of NUL / CR / LF inside the quotes.
|
|
614
|
+
parseQuotedString: _parseQuotedString,
|
|
575
615
|
},
|
|
576
616
|
});
|
package/lib/guard-regex.js
CHANGED
|
@@ -454,8 +454,7 @@ function _scanBraces(src, at) {
|
|
|
454
454
|
// running the construct it screens over operator-supplied text, which is the
|
|
455
455
|
// shape this module exists to keep away from.
|
|
456
456
|
function _turnsFoldingOn(src) {
|
|
457
|
-
|
|
458
|
-
if (src.charAt(i) !== "(" || src.charAt(i + 1) !== "?") continue;
|
|
457
|
+
return codepointClass.hasPairWhere(src, "(", "?", function (i) {
|
|
459
458
|
var at = i + 2;
|
|
460
459
|
var enablesFold = false;
|
|
461
460
|
while (at < src.length && _isFlagLetter(src.charAt(at))) {
|
|
@@ -466,9 +465,8 @@ function _turnsFoldingOn(src) {
|
|
|
466
465
|
at += 1;
|
|
467
466
|
while (at < src.length && _isFlagLetter(src.charAt(at))) at += 1;
|
|
468
467
|
}
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
return false;
|
|
468
|
+
return enablesFold && src.charAt(at) === ":";
|
|
469
|
+
});
|
|
472
470
|
}
|
|
473
471
|
|
|
474
472
|
function _parsePattern(src, flags, budget) {
|
package/lib/guard-yaml.js
CHANGED
|
@@ -179,17 +179,33 @@ function _hasLeadingZeroOctal(text) {
|
|
|
179
179
|
|
|
180
180
|
// A merge key with an anchor reference — `<<` then optional whitespace, `:`,
|
|
181
181
|
// optional whitespace, `*`.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
// A merge key is `<<: *anchor` — a mapping key, so a structural question, and
|
|
183
|
+
// asked of the lexer rather than of the source.
|
|
184
|
+
//
|
|
185
|
+
// The test is whether a NODE can begin at the `<<`, not whether what follows is
|
|
186
|
+
// well-formed: `<<:*d` with no space after the colon is not a mapping entry and
|
|
187
|
+
// is reported anyway, because that is the shape being smuggled past a parser
|
|
188
|
+
// variant that reads it as a merge. A node cannot begin inside a comment, a
|
|
189
|
+
// quoted body, a block scalar's body, or a plain scalar's continuation line, so
|
|
190
|
+
// one question covers all of them.
|
|
191
|
+
//
|
|
192
|
+
// Enumerating those regions instead was tried and is the wrong shape. Without
|
|
193
|
+
// any of this, `<<: *base` written inside a block scalar — a line of someone's
|
|
194
|
+
// shell script — was reported as a merge key; excluding block bodies and quoted
|
|
195
|
+
// bodies then still reported one written in a comment, on a continuation line,
|
|
196
|
+
// and after a document marker. Same root as the duplicate-key screen (#642): a
|
|
197
|
+
// structural rule reading raw source, and a fix that lists regions is the same
|
|
198
|
+
// mistake with a longer list.
|
|
199
|
+
function _hasMergeKeyAlias(text, nodeStarts) {
|
|
200
|
+
return codepointClass.hasPairWhere(text, "<", "<", function (i) {
|
|
201
|
+
if (nodeStarts && !nodeStarts[i]) return false;
|
|
185
202
|
var j = i + 2;
|
|
186
203
|
while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
|
|
187
|
-
if (text.charAt(j) !== ":")
|
|
204
|
+
if (text.charAt(j) !== ":") return false;
|
|
188
205
|
j += 1;
|
|
189
206
|
while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
return false;
|
|
207
|
+
return text.charAt(j) === "*";
|
|
208
|
+
});
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
// ---- Profile presets ----
|
|
@@ -368,11 +384,18 @@ function _detectIssues(input, opts) {
|
|
|
368
384
|
// refused. The mask is index-aligned with the source and the same length, so
|
|
369
385
|
// every location and line number reported below is still the source's.
|
|
370
386
|
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
// source.
|
|
375
|
-
|
|
387
|
+
// Every STRUCTURAL screen uses it: the three sigil scans, the duplicate-key
|
|
388
|
+
// scan, and the merge key. The value-shaped detectors (the Norway problem,
|
|
389
|
+
// leading zeros) are asking about scalar CONTENT, which is exactly what the
|
|
390
|
+
// mask removes, so those keep reading the source.
|
|
391
|
+
//
|
|
392
|
+
// Merge keys were on the wrong side of that line, listed with the
|
|
393
|
+
// value-shaped ones: `<<:` is a mapping key, and reading it from the source
|
|
394
|
+
// reported one written inside a block scalar. The test is not what the rule
|
|
395
|
+
// is looking for but what kind of question it asks — "is this a mapping
|
|
396
|
+
// entry?" is structure however scalar-shaped the token looks.
|
|
397
|
+
var lexed = yamlLex.lexLines(input);
|
|
398
|
+
var masked = lexed.masked;
|
|
376
399
|
|
|
377
400
|
// 1. Tag-injection scan.
|
|
378
401
|
var tagHits = _scanTags(masked);
|
|
@@ -488,7 +511,8 @@ function _detectIssues(input, opts) {
|
|
|
488
511
|
}
|
|
489
512
|
|
|
490
513
|
// 6. Merge-key chain depth.
|
|
491
|
-
if (opts.mergeKeyPolicy !== "allow" &&
|
|
514
|
+
if (opts.mergeKeyPolicy !== "allow" &&
|
|
515
|
+
_hasMergeKeyAlias(input, lexed.nodeStarts)) {
|
|
492
516
|
issues.push({
|
|
493
517
|
kind: "merge-key",
|
|
494
518
|
severity: opts.mergeKeyPolicy === "reject" ? "high" : "warn",
|
|
@@ -537,12 +561,25 @@ function _detectIssues(input, opts) {
|
|
|
537
561
|
// FIRST colon that is followed by whitespace or ends the line, which is where
|
|
538
562
|
// a value begins — a colon inside the key (a timestamp, a URL) does not end
|
|
539
563
|
// it unless whitespace follows.
|
|
540
|
-
|
|
564
|
+
// `masked` is the same line with every non-structural region blanked by
|
|
565
|
+
// yaml-lex. It decides WHICH colons are mapping separators; the raw line
|
|
566
|
+
// supplies the key text, because the mask blanks that too.
|
|
567
|
+
//
|
|
568
|
+
// Without it, a colon inside a block scalar's body or a quoted value read as a
|
|
569
|
+
// mapping entry, and two such lines inside one scalar were reported as a
|
|
570
|
+
// duplicate key (#642). That is the same root as #631/#632 — a screen deciding
|
|
571
|
+
// structure by reading the source — which survived here because only the sigil
|
|
572
|
+
// scans were moved onto the mask.
|
|
573
|
+
function _mappingEntryAt(line, masked) {
|
|
541
574
|
var indent = 0;
|
|
542
575
|
while (indent < line.length && _isSpace(line.charCodeAt(indent))) indent += 1;
|
|
543
576
|
if (indent === line.length) return null;
|
|
544
577
|
for (var i = indent; i < line.length; i += 1) {
|
|
545
578
|
if (line.charAt(i) !== ":") continue;
|
|
579
|
+
// Structural only: the mask keeps a mapping separator and blanks a colon
|
|
580
|
+
// that is part of a scalar. Absent a mask, every colon counts, which is the
|
|
581
|
+
// pre-#642 behaviour and is what the non-masked callers still want.
|
|
582
|
+
if (masked !== undefined && masked.charAt(i) !== ":") continue;
|
|
546
583
|
var after = line.charCodeAt(i + 1);
|
|
547
584
|
if (i + 1 < line.length && !_isSpace(after)) continue;
|
|
548
585
|
if (i === indent) return null; // no key before the colon
|
|
@@ -581,6 +618,10 @@ var _splitLines = codepointClass.splitLines;
|
|
|
581
618
|
function _detectDuplicateKeysYaml(text) {
|
|
582
619
|
var dups = Object.create(null);
|
|
583
620
|
var lines = _splitLines(text);
|
|
621
|
+
// Whether a line carries a mapping entry is a STRUCTURAL question, so it is
|
|
622
|
+
// asked of the lexer's mask rather than of the source. Split the same way, so
|
|
623
|
+
// line i of one is line i of the other.
|
|
624
|
+
var maskedLines = _splitLines(yamlLex.maskNonStructural(text));
|
|
584
625
|
var indentScopes = Object.create(null);
|
|
585
626
|
// The sequence items currently open, outermost first, each with the column
|
|
586
627
|
// its first key sat in. Every key of an item at its top level is filed under
|
|
@@ -632,7 +673,11 @@ function _detectDuplicateKeysYaml(text) {
|
|
|
632
673
|
// keyIndent is set by this item's first key, wherever it is written.
|
|
633
674
|
itemStack.push({ dash: dashAt, keyIndent: -1 });
|
|
634
675
|
}
|
|
635
|
-
|
|
676
|
+
// Passed straight through, with no `|| ""` fallback: an empty mask line
|
|
677
|
+
// blanks every colon, so a missing one would exempt the line from the screen
|
|
678
|
+
// altogether. `undefined` means "no mask" instead, which counts every colon
|
|
679
|
+
// — over-reporting rather than under-reporting if the two ever desynchronise.
|
|
680
|
+
var entry = _mappingEntryAt(line, maskedLines[i]);
|
|
636
681
|
if (!entry) continue;
|
|
637
682
|
var indent = entry.indent;
|
|
638
683
|
var key = entry.key.trim();
|
package/lib/mail-agent.js
CHANGED
|
@@ -539,19 +539,33 @@ async function _expunge(ctx, args) {
|
|
|
539
539
|
"agent.expunge: { folder, objectIds, [candidateTtlMs] } required");
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
-
// Look up the regulator-mandated retention floor for the operator's
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
// complianceFloor
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
542
|
+
// Look up the regulator-mandated retention floor for the operator's active
|
|
543
|
+
// posture. For expunge semantics the floor IS the minimum TTL — messages
|
|
544
|
+
// younger than it MUST NOT be hard-deleted, even on operator request.
|
|
545
|
+
//
|
|
546
|
+
// Through b.retention.complianceFloor, not the floor table it wraps. The
|
|
547
|
+
// direct read fell back to zero for anything the table did not contain, so a
|
|
548
|
+
// misspelled posture, a capitalised one ("HIPAA"), or one the table simply
|
|
549
|
+
// does not carry all permitted an unbounded hard delete with no error — a
|
|
550
|
+
// typo refused where retention windows are computed and accepted at the one
|
|
551
|
+
// call that destroys mail permanently. It also read through the prototype:
|
|
552
|
+
// posture "constructor" returned a function, which is truthy, so the `|| 0`
|
|
553
|
+
// fallback kept it and every age comparison against it was nonsense.
|
|
554
|
+
//
|
|
555
|
+
// No posture at all is a legitimate configuration and keeps the zero floor.
|
|
556
|
+
// A posture that was supplied and is not understood now throws.
|
|
550
557
|
var retentionModule = require("./retention"); // allow:inline-require — lazy-load until first expunge call
|
|
551
558
|
var posture = (ctx && ctx.posture) || (args && args.posture) || null;
|
|
552
559
|
var floorMs = 0;
|
|
553
560
|
if (typeof posture === "string" && posture.length > 0) {
|
|
554
|
-
|
|
561
|
+
try {
|
|
562
|
+
floorMs = retentionModule.complianceFloor(posture);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
throw new MailAgentError("mail-agent/unknown-posture",
|
|
565
|
+
"expunge: posture '" + posture + "' is not a posture the framework knows, " +
|
|
566
|
+
"so no retention floor can be established and the hard delete is refused " +
|
|
567
|
+
"rather than run unbounded: " + ((e && e.message) || String(e)));
|
|
568
|
+
}
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
// Read message metadata BEFORE invoking hardExpunge so the per-id
|
package/lib/mail-arc-sign.js
CHANGED
|
@@ -128,7 +128,12 @@ function _bodyHashB64(body, algorithm) {
|
|
|
128
128
|
var canonical = _canonRelaxedBody(body);
|
|
129
129
|
// RFC 6376 §3.4.4 — empty body canon is `\r\n` (one CRLF). Hash
|
|
130
130
|
// includes that CRLF.
|
|
131
|
-
|
|
131
|
+
//
|
|
132
|
+
// latin1, for the reason DKIM uses it: ARC seals a message the same way DKIM
|
|
133
|
+
// signs one, over the OCTETS on the wire. `update(string)` defaults to UTF-8,
|
|
134
|
+
// which re-encodes every high byte and seals a body that is not the one the
|
|
135
|
+
// message carries.
|
|
136
|
+
return nodeCrypto.createHash(hashAlgo).update(canonical, "latin1").digest("base64");
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
// RFC 8617 §5 — ARC chains MUST NOT exceed 50 hops. The verifier
|
|
@@ -182,8 +187,27 @@ function sign(opts) {
|
|
|
182
187
|
"headersToSign", "timestamp", "audit", "excludeAarFromAms",
|
|
183
188
|
], "mail.arc.sign");
|
|
184
189
|
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
// A seal covers the OCTETS on the wire, so a caller holding them seals them
|
|
191
|
+
// rather than a UTF-8 decode of them. One code unit per octet from here down.
|
|
192
|
+
//
|
|
193
|
+
// Through `dkim._toWire`, which is the SAME function the DKIM signer uses.
|
|
194
|
+
// Converting the Buffer here by hand and leaving a string untouched was the
|
|
195
|
+
// first version, and it left every non-ASCII string message signed over the
|
|
196
|
+
// low byte of each code unit: the hashing and signing paths below read a
|
|
197
|
+
// string as latin1 octets, so a message that is not already ASCII has to be
|
|
198
|
+
// put into that form before it reaches them, not after. Two functions
|
|
199
|
+
// answering "which bytes is this message" is how they come to disagree.
|
|
200
|
+
var gaveBuffer = Buffer.isBuffer(opts.rfc822);
|
|
201
|
+
if (gaveBuffer) {
|
|
202
|
+
if (opts.rfc822.length === 0) {
|
|
203
|
+
throw new MailAuthError("arc-sign/bad-input",
|
|
204
|
+
"sign: rfc822 must be a non-empty Buffer or string");
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
validateOpts.requireNonEmptyString(opts.rfc822, "sign: rfc822",
|
|
208
|
+
MailAuthError, "arc-sign/bad-input");
|
|
209
|
+
}
|
|
210
|
+
opts = Object.assign({}, opts, { rfc822: dkim._toWire(opts.rfc822) });
|
|
187
211
|
if (typeof opts.instance !== "number" || !isFinite(opts.instance) ||
|
|
188
212
|
opts.instance < 1 || opts.instance > 50 || // RFC 8617 §5 chain bound
|
|
189
213
|
Math.floor(opts.instance) !== opts.instance) {
|
|
@@ -387,7 +411,14 @@ function sign(opts) {
|
|
|
387
411
|
"ARC-Seal: " + asValue + "\r\n" +
|
|
388
412
|
"ARC-Message-Signature: " + amsValue + "\r\n" +
|
|
389
413
|
"ARC-Authentication-Results: " + aarValue + "\r\n";
|
|
390
|
-
|
|
414
|
+
// Returned in the shape it arrived in, so a caller relaying the sealed
|
|
415
|
+
// message relays the octets they handed over. See the same note in
|
|
416
|
+
// lib/mail-dkim.js sign(): a string caller gets their own string back, which
|
|
417
|
+
// is the UTF-8 decode of the wire form rather than the wire form itself.
|
|
418
|
+
var sealedWire = prependedHeaders + opts.rfc822;
|
|
419
|
+
var sealedRfc822 = gaveBuffer
|
|
420
|
+
? Buffer.from(sealedWire, "latin1")
|
|
421
|
+
: Buffer.from(sealedWire, "latin1").toString("utf8");
|
|
391
422
|
|
|
392
423
|
if (auditOn) {
|
|
393
424
|
try {
|
|
@@ -418,15 +449,17 @@ function sign(opts) {
|
|
|
418
449
|
}
|
|
419
450
|
|
|
420
451
|
function _signOne(canonInput, keyObject, algorithm) {
|
|
452
|
+
// The canonicalized header octets, one code unit per octet — same boundary as
|
|
453
|
+
// the body hash above and as DKIM's.
|
|
454
|
+
var toSign = Buffer.from(canonInput, "latin1");
|
|
421
455
|
if (algorithm === "ed25519-sha256") {
|
|
422
456
|
// Ed25519 prehash variant — Node's `crypto.sign(null, msg, key)`
|
|
423
457
|
// accepts the message directly.
|
|
424
|
-
return nodeCrypto.sign(null,
|
|
425
|
-
.toString("base64");
|
|
458
|
+
return nodeCrypto.sign(null, toSign, keyObject).toString("base64");
|
|
426
459
|
}
|
|
427
460
|
// RSA-SHA256 / default — createSign + update + sign.
|
|
428
461
|
var signer = nodeCrypto.createSign("RSA-SHA256");
|
|
429
|
-
signer.update(
|
|
462
|
+
signer.update(toSign);
|
|
430
463
|
return signer.sign(keyObject).toString("base64");
|
|
431
464
|
}
|
|
432
465
|
|