@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.
- package/CHANGELOG.md +228 -0
- package/NOTICE +1 -1
- package/README.md +5 -5
- package/lib/agent-audit.js +27 -2
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- 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/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +41 -33
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +73 -12
- package/lib/guard-regex.js +3 -5
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/guard-yaml.js +60 -15
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +29 -13
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +134 -22
- 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 +186 -89
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +198 -42
- package/lib/mail-server-mx.js +191 -38
- package/lib/mail-server-net.js +281 -1
- package/lib/mail-server-pop3.js +89 -41
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +183 -35
- package/lib/mail-server-tls.js +48 -3
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/mcp.js +11 -3
- 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/middleware/require-mtls.js +8 -1
- 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/network-tls.js +18 -0
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +672 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- 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:
|
|
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:
|
|
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: "
|
|
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:
|
|
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:
|
|
402
|
+
jwtToken: _HOSTILE_JWT,
|
|
378
403
|
}), "utf8"),
|
|
379
404
|
benignAuthBundle: {
|
|
380
|
-
jwtToken:
|
|
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:
|
|
409
|
+
jwtToken: _HOSTILE_JWT,
|
|
387
410
|
},
|
|
388
411
|
});
|
|
389
412
|
|
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({
|
|
@@ -565,7 +561,14 @@ function _detectIssues(input, opts) {
|
|
|
565
561
|
}
|
|
566
562
|
|
|
567
563
|
// 14. Extension allowlist.
|
|
568
|
-
|
|
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.
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
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
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
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", //
|
|
739
|
-
* // is
|
|
740
|
-
* //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
var literalPlus
|
|
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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
});
|
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) {
|
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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();
|