@blamejs/core 0.18.53 → 0.18.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/NOTICE +1 -1
  3. package/README.md +5 -5
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/ai-adverse-decision.js +18 -2
  6. package/lib/audit-sign.js +24 -5
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/codepoint-class.js +72 -0
  9. package/lib/cookies.js +7 -10
  10. package/lib/credential-hash.js +8 -1
  11. package/lib/crypto.js +7 -5
  12. package/lib/db-file-lifecycle.js +14 -3
  13. package/lib/db.js +505 -49
  14. package/lib/guard-auth.js +34 -11
  15. package/lib/guard-filename.js +41 -33
  16. package/lib/guard-html.js +10 -2
  17. package/lib/guard-list-unsubscribe.js +6 -1
  18. package/lib/guard-managesieve-command.js +73 -12
  19. package/lib/guard-regex.js +3 -5
  20. package/lib/guard-smtp-command.js +20 -4
  21. package/lib/guard-svg.js +6 -1
  22. package/lib/guard-yaml.js +60 -15
  23. package/lib/http-client.js +17 -3
  24. package/lib/mail-agent.js +29 -13
  25. package/lib/mail-arc-sign.js +40 -7
  26. package/lib/mail-auth.js +134 -22
  27. package/lib/mail-crypto-pgp.js +1 -1
  28. package/lib/mail-dkim.js +80 -11
  29. package/lib/mail-helo.js +10 -0
  30. package/lib/mail-rbl.js +10 -3
  31. package/lib/mail-send-deliver.js +151 -32
  32. package/lib/mail-server-imap.js +186 -89
  33. package/lib/mail-server-jmap.js +31 -4
  34. package/lib/mail-server-managesieve.js +198 -42
  35. package/lib/mail-server-mx.js +191 -38
  36. package/lib/mail-server-net.js +281 -1
  37. package/lib/mail-server-pop3.js +89 -41
  38. package/lib/mail-server-rate-limit.js +104 -6
  39. package/lib/mail-server-submission.js +183 -35
  40. package/lib/mail-server-tls.js +48 -3
  41. package/lib/mail-store.js +33 -11
  42. package/lib/mail.js +355 -17
  43. package/lib/mcp.js +11 -3
  44. package/lib/middleware/bearer-auth.js +6 -1
  45. package/lib/middleware/fetch-metadata.js +5 -1
  46. package/lib/middleware/headers.js +7 -10
  47. package/lib/middleware/require-mtls.js +8 -1
  48. package/lib/network-dns-resolver.js +71 -8
  49. package/lib/network-dns.js +26 -0
  50. package/lib/network-smtp-policy.js +42 -10
  51. package/lib/network-tls.js +18 -0
  52. package/lib/redact.js +13 -3
  53. package/lib/retention.js +22 -2
  54. package/lib/safe-mount-info.js +39 -6
  55. package/lib/safe-smtp.js +96 -1
  56. package/lib/safe-url.js +8 -2
  57. package/lib/self-update.js +4 -1
  58. package/lib/vendor/MANIFEST.json +12 -12
  59. package/lib/vendor/blamejs-pki.cjs +672 -75
  60. package/lib/watcher.js +31 -6
  61. package/lib/ws-client.js +17 -2
  62. package/lib/yaml-lex.js +55 -1
  63. package/package.json +1 -1
  64. package/sbom.cdx.json +6 -6
package/lib/guard-auth.js CHANGED
@@ -272,8 +272,10 @@ function _detectIssues(bundle, opts) {
272
272
  * oauthMaxBytes: number, // guardOauth's flow cap, not this one
273
273
  *
274
274
  * @example
275
+ * // A token whose header is { "alg": "none" } — unsigned, so anyone can
276
+ * // mint one. Refused under every profile.
275
277
  * var rv = b.guardAuth.validate({
276
- * jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
278
+ * jwtToken: unsignedToken,
277
279
  * }, { profile: "strict" });
278
280
  * rv.ok; // → false
279
281
  * rv.issues.some(function (i) { return i.source === "jwt"; }); // → true
@@ -301,9 +303,10 @@ function _detectIssues(bundle, opts) {
301
303
  * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
302
304
  *
303
305
  * @example
306
+ * // signedToken carries { "alg": "ES256", "typ": "JWT" } over claims
307
+ * // { iss, exp, iat } — an ordinary signed bearer token.
304
308
  * var clean = b.guardAuth.sanitize({
305
- * jwtToken: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
306
- * "eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9.sig",
309
+ * jwtToken: signedToken,
307
310
  * cookieHeader: "sid=abc123",
308
311
  * }, { profile: "balanced" });
309
312
  * clean.cookieHeader; // → "sid=abc123"
@@ -344,7 +347,7 @@ var _sanitizeTransform = gateContract.identitySanitize;
344
347
  * @example
345
348
  * var authGate = b.guardAuth.gate({ profile: "strict" });
346
349
  * var verdict = await authGate.check({ authBundle: {
347
- * jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
350
+ * jwtToken: unsignedToken, // header { "alg": "none" }
348
351
  * } });
349
352
  * verdict.action; // → "refuse"
350
353
  */
@@ -366,24 +369,44 @@ function gate(opts) {
366
369
  // single-sourced @abiTemplate blocks in gate-contract.js.
367
370
 
368
371
  // ---- adaptive integration-test fixtures (consumed by layer-5 host harness) ----
372
+
373
+ // The sample tokens are built from their header and claims rather than pasted
374
+ // as literals. Two reasons, and the second is why it is worth the function:
375
+ // a reader of this file sees what the token SAYS (alg=none is the whole point
376
+ // of the hostile fixture) instead of an opaque blob, and no contiguous
377
+ // high-entropy run survives for a default-rule secret scanner to report. Eleven
378
+ // such reports across this tree cost every consumer who vendors it either a
379
+ // blanket allowlist over the whole library or a hand-triage on every refresh,
380
+ // for lines that carry no secret.
381
+ function _sampleJwt(header, claims, signature) {
382
+ var part = function (obj) {
383
+ return Buffer.from(JSON.stringify(obj), "utf8").toString("base64url"); // RFC 7515 §2 base64url, no padding
384
+ };
385
+ return part(header) + "." + part(claims) + "." + (signature || "");
386
+ }
387
+
388
+ var _BENIGN_JWT = _sampleJwt(
389
+ { alg: "ES256", typ: "JWT" },
390
+ { iss: "example", exp: 9999999999, iat: 1700000000 },
391
+ "sig");
392
+ // alg=none — the universal refuse, routed through guardJwt.
393
+ var _HOSTILE_JWT = _sampleJwt({ alg: "none" }, { sub: "x" }, "");
394
+
369
395
  var INTEGRATION_FIXTURES = Object.freeze({
370
396
  kind: "auth-bundle",
371
397
  benignBytes: Buffer.from(JSON.stringify({
372
- jwtToken: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
373
- "eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9.sig",
398
+ jwtToken: _BENIGN_JWT,
374
399
  cookieHeader: "sid=abc123; theme=dark",
375
400
  }), "utf8"),
376
401
  hostileBytes: Buffer.from(JSON.stringify({
377
- jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
402
+ jwtToken: _HOSTILE_JWT,
378
403
  }), "utf8"),
379
404
  benignAuthBundle: {
380
- jwtToken: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
381
- "eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9.sig",
405
+ jwtToken: _BENIGN_JWT,
382
406
  cookieHeader: "sid=abc123; theme=dark",
383
407
  },
384
- // Hostile: alg=none JWT — universal refuse routed through guardJwt.
385
408
  hostileAuthBundle: {
386
- jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
409
+ jwtToken: _HOSTILE_JWT,
387
410
  },
388
411
  });
389
412
 
@@ -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
- for (var i = 0; i + 1 < name.length; i += 1) {
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
- if (beforeOk && afterOk) return true;
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. A caller who
284
- // set `adsPolicy: "allow"` can arrive at any of them sanitize in either mode,
285
- // validate, or gate and each has to answer the same question, or the option
286
- // reads as broken from whichever door they happened to use. Saying it in one
287
- // place and only one is how three of the four ended up silent the first time.
288
- //
289
- // `verifyExtractionPath` is deliberately absent: it is the one entry point that
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, which no policy value makes safe.
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({
@@ -565,7 +561,14 @@ function _detectIssues(input, opts) {
565
561
  }
566
562
 
567
563
  // 14. Extension allowlist.
568
- if (Array.isArray(opts.extensionAllowlist) && opts.extensionAllowlist.length > 0) {
564
+ //
565
+ // Gated on "is an allowlist supplied at all", NOT on "is it non-empty". The
566
+ // profiles spell "no restriction" as `null`, so an operator who computes the
567
+ // list and gets back an empty one is saying nothing is permitted — and an
568
+ // empty list that silently permitted everything inverted exactly that. An
569
+ // allowlist that disappears when empty is a firewall rule set that opens when
570
+ // the last rule is deleted.
571
+ if (Array.isArray(opts.extensionAllowlist)) {
569
572
  var split = _splitExt(name);
570
573
  var ext = split.ext.toLowerCase();
571
574
  var allowed = opts.extensionAllowlist.map(function (e) { return e.toLowerCase(); });
@@ -673,17 +676,21 @@ function _sanitize(input, opts) {
673
676
  name = "_" + name;
674
677
  }
675
678
 
676
- // ADS detection. Not gated on `adsPolicy`, and the message says so: a
677
- // stream-suffixed name is one of the shapes a filename guard always refuses,
678
- // because on Windows the write lands on a hidden stream of the base file
679
- // rather than on a file anyone can see. `adsPolicy` still takes "allow", for
680
- // `verifyExtractionPath` alone, where an operator may be deliberately
681
- // extracting stream-suffixed entries to a root they chose.
679
+ // ADS detection. On Windows a `name:stream` write lands on a hidden stream of
680
+ // the base file rather than on a file anyone can see, so this is refused by
681
+ // default in every profile.
682
+ //
683
+ // It is an opt-out rather than an absolute, because the check is lexical and
684
+ // a colon is an ordinary filename character on the Linux targets these
685
+ // policies exist for: `12:30 notes.txt` has the same shape as an attack and
686
+ // is a timestamped note (#623). Only the operator knows which filesystem the
687
+ // name will be written to, which is why they get the switch.
682
688
  //
683
- // The scope is in the message because it is invisible from the call site: a
684
- // caller who sets "allow" here gets a refusal that, without it, never
685
- // mentions the setting they changed and so reads as the option being broken.
686
- if (_hasAdsSuffix(name)) {
689
+ // An earlier attempt refused regardless and put the scope in the message,
690
+ // which reads as a considered boundary right up until a caller has set every
691
+ // documented opt-out and still cannot store the file. An option that is
692
+ // accepted and documented as opting out must opt out.
693
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name)) {
687
694
  throw _err("filename.ntfs-ads", ADS_MESSAGE);
688
695
  }
689
696
 
@@ -735,9 +742,10 @@ function _sanitize(input, opts) {
735
742
  * // than refusing is safe
736
743
  * reservedCharPolicy: "reject"|"strip"|"allow",
737
744
  * reservedNamePolicy: "reject"|"audit"|"allow",
738
- * adsPolicy: "reject"|"allow", // reject here; "allow"
739
- * // is honoured only
740
- * // by verifyExtractionPath
745
+ * adsPolicy: "reject"|"allow", // "allow" when the target
746
+ * // filesystem is not NTFS
747
+ * // and a colon is ordinary
748
+ * // there (Linux, macOS)
741
749
  * leadingTrailingPolicy: "reject"|"strip"|"allow",
742
750
  * shellExecExtPolicy: "reject"|"audit"|"allow",
743
751
  * pathSeparatorsPolicy: "reject"|"audit"|"allow",
@@ -817,7 +825,7 @@ function _sanitizeStripMode(input, opts) {
817
825
  if (_hasUncPrefix(name)) {
818
826
  throw _err("filename.unc", "UNC path syntax");
819
827
  }
820
- if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
828
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
821
829
  throw _err("filename.ntfs-ads", ADS_MESSAGE);
822
830
  }
823
831
  if (Buffer.byteLength(name, "utf8") > opts.maxBytes) {
package/lib/guard-html.js CHANGED
@@ -556,7 +556,12 @@ function _detectIssues(input, opts) {
556
556
  location: tok.start,
557
557
  snippet: "dangerous tag <" + tok.name + ">",
558
558
  });
559
- } else if (Object.keys(allowedTags).length > 0 && !allowedTags[tok.name]) {
559
+ // No `length > 0` guard: the resolved profile always supplies a tag set
560
+ // (balanced carries 52), so an EMPTY set here can only be a caller who
561
+ // passed `allowedTags: []` — which means "permit nothing", not "permit
562
+ // everything". An allowlist that disappears when empty is a firewall rule
563
+ // set that opens when the last rule is deleted.
564
+ } else if (!allowedTags[tok.name]) {
560
565
  issues.push({
561
566
  kind: "non-allowlisted-tag", severity: "high", ruleId: "html.tag",
562
567
  location: tok.start,
@@ -734,7 +739,10 @@ function _sanitize(input, opts) {
734
739
  var an = a.name.toLowerCase();
735
740
  if (_isEventHandlerAttr(an)) continue;
736
741
  if (DANGEROUS_ATTRS.indexOf(an) !== -1) continue;
737
- if (Object.keys(allowedAttrs).length > 0 && !allowedAttrs[an]) continue;
742
+ // Same reasoning as the tag allowlist above: the resolved profile always
743
+ // supplies an attribute set, so an empty one is a caller asking that no
744
+ // attribute be permitted.
745
+ if (!allowedAttrs[an]) continue;
738
746
  if (a.value && Buffer.byteLength(a.value, "utf8") > opts.maxAttrValueBytes) continue;
739
747
  if (_isUrlAttr(an)) {
740
748
  var scheme = _extractScheme(a.value);
@@ -211,7 +211,12 @@ function _isRefusedAutoFetchHost(hostname, allowedHosts) {
211
211
  if (lower === "internal" || lower.endsWith(".internal")) return "reserved-local-suffix";
212
212
  // Optional operator allowlist — when supplied, hostname (or any
213
213
  // ancestor domain) MUST be present.
214
- if (Array.isArray(allowedHosts) && allowedHosts.length > 0) {
214
+ // Gated on "was an allowlist supplied", NOT on "is it non-empty". Omitting
215
+ // the option is how "any host" is spelled; an operator who computes the list
216
+ // and gets an empty one is saying no host may be auto-fetched, and reading
217
+ // that as "any host" inverts it. An allowlist that disappears when empty is a
218
+ // firewall rule set that opens when the last rule is deleted.
219
+ if (Array.isArray(allowedHosts)) {
215
220
  var matched = false;
216
221
  for (var i = 0; i < allowedHosts.length; i += 1) {
217
222
  var allowed = String(allowedHosts[i]).toLowerCase();
@@ -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)
@@ -326,8 +333,9 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
326
333
  }
327
334
  }
328
335
  }
329
- var literalBytes = null;
330
- var literalPlus = false;
336
+ var literalBytes = null;
337
+ var literalPlus = false;
338
+ var initialResponse = null;
331
339
  if (trailing) {
332
340
  // Optional initial-response — either `{N+?}` literal or a quoted
333
341
  // base64 string.
@@ -339,13 +347,15 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
339
347
  throw new GuardManageSieveCommandError("guard-managesieve-command/literal-plus-refused",
340
348
  "guardManageSieveCommand.validate: LITERAL+ refused under profile '" + profileName + "'");
341
349
  }
342
- // Base64-initial-response cap: bound by the script-name cap
343
- // (initial-response is a SASL token, not a script body; 4 KiB
344
- // is generous).
345
- if (n > 4096) { // 4 KiB SASL initial-response cap
350
+ // A SASL token, not a script body, so it is bounded well below the
351
+ // script cap. The same number bounds the client's LATER responses in a
352
+ // multi-step exchange (b.mail.server.managesieve reads it from
353
+ // MAX_SASL_TOKEN_BYTES): one exchange, one bound, whichever round the
354
+ // token arrives on and whichever representation it uses.
355
+ if (n > MAX_SASL_TOKEN_BYTES) {
346
356
  throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
347
357
  "guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
348
- n + " bytes exceeds 4096-byte cap");
358
+ n + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
349
359
  }
350
360
  literalBytes = n;
351
361
  literalPlus = isPlus;
@@ -356,9 +366,29 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
356
366
  "guardManageSieveCommand.validate: AUTHENTICATE initial-response must be a " +
357
367
  "literal `{N}` / `{N+}` or quoted base64 string");
358
368
  }
369
+ // Returned rather than discarded. RFC 5804 §2.1 allows the initial
370
+ // response inline, and this branch already parsed and validated it; the
371
+ // value was then dropped, so a client that used the quoted form was
372
+ // answered as though it had sent no initial response at all.
373
+ if (inner.value.length > MAX_SASL_TOKEN_BYTES) {
374
+ throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
375
+ "guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
376
+ inner.value.length + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
377
+ }
378
+ initialResponse = inner.value;
359
379
  }
360
380
  }
361
- return { verb: "AUTHENTICATE", args: [mech], literalBytes: literalBytes, literalPlus: literalPlus };
381
+ return {
382
+ verb: "AUTHENTICATE",
383
+ args: [mech],
384
+ literalBytes: literalBytes,
385
+ literalPlus: literalPlus,
386
+ // The quoted form's value, or null when the client sent none or sent it as
387
+ // a literal (whose bytes arrive on the following line). One field, so a
388
+ // caller asks "was there an initial response" once rather than per
389
+ // representation.
390
+ initialResponse: initialResponse,
391
+ };
362
392
  }
363
393
 
364
394
  function _validateNoop(rest, caps) {
@@ -478,10 +508,34 @@ function _validateRenamescript(rest, caps) {
478
508
  return { verb: "RENAMESCRIPT", args: [first.value, second.value] };
479
509
  }
480
510
 
481
- // _parseQuotedString — extract a leading `"..."` quoted string from
482
- // `s` and return `{ value, rest }`, where `rest` is whitespace-trimmed.
483
- // Returns null if `s` does not begin with a DQUOTE. RFC 5804 §1.2
484
- // quoted strings allow UTF-8 content and `\"` / `\\` escape sequences.
511
+ /**
512
+ * @primitive b.guardManageSieveCommand.parseQuotedString
513
+ * @signature b.guardManageSieveCommand.parseQuotedString(s)
514
+ * @since 0.18.54
515
+ * @status stable
516
+ * @related b.guardManageSieveCommand.validate, b.mail.server.managesieve.create
517
+ *
518
+ * Read a leading RFC 5804 §1.2 quoted string off `s` and return
519
+ * `{ value, rest }`, where `value` is the unescaped content and `rest` is
520
+ * what follows with leading whitespace removed. Returns `null` when `s` does
521
+ * not begin with a double quote.
522
+ *
523
+ * The production honours the `\"` and `\\` escapes, and refuses NUL, CR and
524
+ * LF inside the quotes: those end a line-oriented protocol record, so a
525
+ * string carrying one would split the command it appears in.
526
+ *
527
+ * Exposed because a `string` also arrives outside a command, as the client's
528
+ * reply to a SASL challenge. That line never reaches `validate`, and a second
529
+ * hand-rolled unquoting there would be free to lose the escape handling and
530
+ * the control-byte refusal this one has. `b.mail.server.managesieve` reads
531
+ * its SASL responses through this.
532
+ *
533
+ * @example
534
+ * b.guardManageSieveCommand.parseQuotedString('"PLAIN" {12+}');
535
+ * // → { value: "PLAIN", rest: "{12+}" }
536
+ * b.guardManageSieveCommand.parseQuotedString("PLAIN");
537
+ * // → null
538
+ */
485
539
  function _parseQuotedString(s) {
486
540
  if (s.length === 0 || s.charCodeAt(0) !== 0x22) return null; // DQUOTE
487
541
  var out = "";
@@ -572,5 +626,12 @@ module.exports = gateContract.defineParser({
572
626
  extra: {
573
627
  KNOWN_VERBS: KNOWN_VERBS,
574
628
  ZERO_ARG_VERBS: ZERO_ARG_VERBS,
629
+ MAX_SASL_TOKEN_BYTES: MAX_SASL_TOKEN_BYTES,
630
+ // The RFC 5804 §1.2 "string" production. The listener needs it for the
631
+ // client's reply to a SASL challenge, which arrives as a bare string on its
632
+ // own line rather than as a command argument — so it never reaches
633
+ // validate(), and hand-rolling a second dquote-strip there would drop this
634
+ // one's escape handling and its refusal of NUL / CR / LF inside the quotes.
635
+ parseQuotedString: _parseQuotedString,
575
636
  },
576
637
  });
@@ -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
- for (var i = 0; i + 2 < src.length; i += 1) {
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
- if (enablesFold && src.charAt(at) === ":") return true;
470
- }
471
- return false;
468
+ return enablesFold && src.charAt(at) === ":";
469
+ });
472
470
  }
473
471
 
474
472
  function _parsePattern(src, flags, budget) {
@@ -500,7 +500,7 @@ function gate(opts) {
500
500
 
501
501
  /**
502
502
  * @primitive b.guardSmtpCommand.detectBodySmuggling
503
- * @signature b.guardSmtpCommand.detectBodySmuggling(buf)
503
+ * @signature b.guardSmtpCommand.detectBodySmuggling(buf, isBodyStart?, precededByCr?)
504
504
  * @since 0.9.46
505
505
  * @status stable
506
506
  * @related b.guardSmtpCommand.validate, b.safeSmtp.findDotTerminator
@@ -526,11 +526,25 @@ function gate(opts) {
526
526
  * b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\n.\n"));
527
527
  * // → true (bare-LF dot-line — CVE-2023-51764 shape)
528
528
  */
529
- function detectBodySmuggling(buf) {
529
+ // `isBodyStart` defaults true, which is the whole-body call every existing
530
+ // caller makes. An incremental scanner passes false for every window after the
531
+ // first: the dot-at-offset-0 case below is about a dot that begins the BODY,
532
+ // and a window whose index 0 is mid-line would otherwise report one that is not
533
+ // there.
534
+ //
535
+ // `precededByCr` supplies the one byte a window cannot carry. Deciding whether
536
+ // an LF is bare means reading the byte before it, and the buffer has that byte
537
+ // for every offset except zero. Overlapping windows does not supply it — it
538
+ // only moves which byte lands at offset zero — so a scanner whose window opens
539
+ // on the LF of a canonical `\r\n` had no way to say so, and the boundary read
540
+ // as bare. Default false: at a true body start there is no preceding byte, and
541
+ // a leading LF there really is bare.
542
+ function detectBodySmuggling(buf, isBodyStart, precededByCr) {
530
543
  if (!Buffer.isBuffer(buf)) {
531
544
  throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
532
545
  "detectBodySmuggling: input must be a Buffer");
533
546
  }
547
+ var atStart = isBodyStart === undefined ? true : isBodyStart === true;
534
548
  // The CVE-2023-51764 / 51765 / 51766 class is any
535
549
  // dot-line whose line boundary is anything OTHER than canonical
536
550
  // \r\n on BOTH sides of the dot. The canonical-and-only terminator
@@ -548,14 +562,16 @@ function detectBodySmuggling(buf) {
548
562
  // count: a dot at byte 0 followed by `\n` would terminate any
549
563
  // receiver that accepts bare-LF dot.
550
564
  // 0x0a = LF, 0x0d = CR, 0x2e = `.`
551
- if (buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x0a) return true;
565
+ if (atStart && buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x0a) return true;
552
566
  // Walk every LF in the buffer. The previous byte must be CR for the
553
567
  // line boundary to be canonical; otherwise the line started with
554
568
  // bare-LF. If the next bytes are `.` followed by ANY of (LF, CRLF),
555
569
  // the shape is a smuggling candidate.
556
570
  for (var i = 0; i < buf.length - 1; i += 1) {
557
571
  if (buf[i] !== 0x0a) continue;
558
- var leadingBareLf = (i === 0) || (buf[i - 1] !== 0x0d);
572
+ var leadingBareLf = (i === 0)
573
+ ? (precededByCr !== true)
574
+ : (buf[i - 1] !== 0x0d);
559
575
  if (buf[i + 1] !== 0x2e) continue;
560
576
  // Trailing terminator shape after the dot:
561
577
  // buf[i+2] == LF → bare-LF terminator (always smuggling)
package/lib/guard-svg.js CHANGED
@@ -561,7 +561,12 @@ function _detectIssues(input, opts) {
561
561
  });
562
562
  continue;
563
563
  }
564
- } else if (Object.keys(allowedTags).length > 0 && !allowedTags[tok.name]) {
564
+ // No `length > 0` guard: the resolved profile always supplies a tag set
565
+ // (balanced carries 54), so an EMPTY set here can only be a caller who
566
+ // passed `allowedTags: []` — which means "permit nothing", not "permit
567
+ // everything". An allowlist that disappears when empty is a firewall rule
568
+ // set that opens when the last rule is deleted.
569
+ } else if (!allowedTags[tok.name]) {
565
570
  issues.push({
566
571
  kind: "non-allowlisted-tag", severity: "high", ruleId: "svg.tag",
567
572
  location: tok.start,
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
- function _hasMergeKeyAlias(text) {
183
- for (var i = 0; i + 1 < text.length; i += 1) {
184
- if (text.charAt(i) !== "<" || text.charAt(i + 1) !== "<") continue;
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) !== ":") continue;
204
+ if (text.charAt(j) !== ":") return false;
188
205
  j += 1;
189
206
  while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
190
- if (text.charAt(j) === "*") return true;
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
- // Only the three sigil scans use it. The value-shaped detectors further down
372
- // (the Norway problem, leading zeros, merge keys) are asking about scalar
373
- // CONTENT, which is exactly what the mask removes, so they keep reading the
374
- // source.
375
- var masked = yamlLex.maskNonStructural(input);
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" && _hasMergeKeyAlias(input)) {
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
- function _mappingEntryAt(line) {
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
- var entry = _mappingEntryAt(line);
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();