@blamejs/core 0.10.14 → 0.11.0
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 +4 -0
- package/README.md +1 -0
- package/index.js +18 -0
- package/lib/auth/oauth.js +187 -0
- package/lib/auth/saml.js +1366 -13
- package/lib/cms-codec.js +141 -0
- package/lib/compliance.js +73 -0
- package/lib/csp.js +271 -0
- package/lib/dbsc.js +299 -0
- package/lib/fedcm.js +264 -0
- package/lib/hal.js +125 -0
- package/lib/importmap-integrity.js +90 -0
- package/lib/jsonapi.js +230 -0
- package/lib/lro.js +200 -0
- package/lib/mail-crypto-pgp.js +312 -2
- package/lib/mail-crypto-smime.js +530 -69
- package/lib/mail-deploy.js +632 -5
- package/lib/metrics.js +62 -12
- package/lib/middleware/security-headers.js +2 -1
- package/lib/standard-webhooks.js +183 -0
- package/lib/web-push-vapid.js +322 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-deploy.js
CHANGED
|
@@ -46,11 +46,19 @@
|
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
48
|
var nodeCrypto = require("node:crypto");
|
|
49
|
+
var zlib = require("node:zlib");
|
|
50
|
+
var lazyRequire = require("./lazy-require");
|
|
49
51
|
var validateOpts = require("./validate-opts");
|
|
50
52
|
var numericBounds = require("./numeric-bounds");
|
|
53
|
+
var C = require("./constants");
|
|
54
|
+
var safeJson = require("./safe-json");
|
|
55
|
+
var safeBuffer = require("./safe-buffer");
|
|
56
|
+
var guardJson = lazyRequire(function () { return require("./guard-json"); });
|
|
57
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
51
58
|
var { defineClass } = require("./framework-error");
|
|
52
59
|
|
|
53
60
|
var MailDeployError = defineClass("MailDeployError", { alwaysPermanent: true });
|
|
61
|
+
var TlsRptParseError = defineClass("TlsRptParseError", { alwaysPermanent: true });
|
|
54
62
|
|
|
55
63
|
// RFC 8461 §3.2 MTA-STS policy field allowlist. Field values typed +
|
|
56
64
|
// bounded — operator supplies them; we never echo arbitrary bytes
|
|
@@ -483,10 +491,629 @@ function autoDiscoverXml(opts) {
|
|
|
483
491
|
"</Autodiscover>\n";
|
|
484
492
|
}
|
|
485
493
|
|
|
494
|
+
// ---- TLS-RPT receiver (RFC 8460) ----
|
|
495
|
+
//
|
|
496
|
+
// Inbound aggregate-report ingest for operators who publish
|
|
497
|
+
// `rua=https://reports.example.com/tlsrpt` on `_smtp._tls.<domain>`.
|
|
498
|
+
// Reporters POST `application/tlsrpt+json` (raw) or
|
|
499
|
+
// `application/tlsrpt+gzip` (gzip-wrapped JSON) per RFC 8460 §5.4
|
|
500
|
+
// + §6.4-6.5 IANA media-type registrations.
|
|
501
|
+
//
|
|
502
|
+
// v1 scope (this slice):
|
|
503
|
+
// - `parseTlsRptReport(bytes, opts?)` — pure parser + §4.4 schema
|
|
504
|
+
// validator. Caps decompressed size (default 32 MiB), compressed
|
|
505
|
+
// size (default 4 MiB), and compression ratio (default 50:1) to
|
|
506
|
+
// defend CVE-2025-0725 / generic decompression-amplification.
|
|
507
|
+
// - `tlsRptIngestHttp({...})` — (req, res) factory returning an
|
|
508
|
+
// RFC 8460 §5.4-compliant handler (201 on accept / 400 on bad
|
|
509
|
+
// JSON / 413 on size / 415 on bad media-type / 405 on non-POST).
|
|
510
|
+
// - `tlsRptReportSchema()` — schema descriptor for operator
|
|
511
|
+
// dashboards.
|
|
512
|
+
//
|
|
513
|
+
// Deferred from v1 (each with documented condition):
|
|
514
|
+
// - `mailto:` ingest via b.mail.server.mx. Defer condition: no
|
|
515
|
+
// operator demand has surfaced; HTTPS POST is the de-facto
|
|
516
|
+
// deployment shape for TLS-RPT today (reporters with `rua=mailto:`
|
|
517
|
+
// ingest are a long tail). Operators wanting mailto: ingest
|
|
518
|
+
// compose b.mail.server.mx today + call `parseTlsRptReport` on
|
|
519
|
+
// the extracted body part themselves. Reopens when an operator
|
|
520
|
+
// surfaces concrete demand AND the mail.server.mx surface stays
|
|
521
|
+
// stable across the upcoming UTA-draft revisions.
|
|
522
|
+
// - Brotli decompression. Defer condition: no fielded reporter
|
|
523
|
+
// uses `Content-Encoding: br` for TLS-RPT today; the IANA
|
|
524
|
+
// media-type registry (RFC 8460 §6.4) only registers +json and
|
|
525
|
+
// +gzip. Operators behind a brotli-encoding proxy decode at the
|
|
526
|
+
// proxy layer. Reopens when at least one fielded reporter ships
|
|
527
|
+
// brotli or the in-progress UTA-draft requires it.
|
|
528
|
+
|
|
529
|
+
// Hard caps — defensive against CVE-2025-0725 (libcurl/zlib
|
|
530
|
+
// integer overflow), CVE-2024-zlib decompression amplification, and
|
|
531
|
+
// the §5.2 community ceiling (receivers commonly cap at 10 MiB).
|
|
532
|
+
var TLSRPT_MAX_COMPRESSED_BYTES = C.BYTES.mib(4); // allow:raw-byte-literal — 4 MiB compressed cap per §5.2 community practice
|
|
533
|
+
var TLSRPT_MAX_DECOMPRESSED_BYTES = C.BYTES.mib(32); // allow:raw-byte-literal — 32 MiB decompressed cap (operators override via opts)
|
|
534
|
+
var TLSRPT_MAX_RATIO = 50; // allow:raw-byte-literal — 50:1 compression ratio refusal
|
|
535
|
+
var TLSRPT_MAX_POLICIES = 1000; // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §4.4 policy-cardinality cap
|
|
536
|
+
var TLSRPT_MAX_FAILURE_DETAILS = 10000; // allow:raw-byte-literal — per-policy failure-details cap
|
|
537
|
+
var TLSRPT_GZIP_MAGIC_0 = 0x1f; // allow:raw-byte-literal — RFC 1952 gzip magic byte 0
|
|
538
|
+
var TLSRPT_GZIP_MAGIC_1 = 0x8b; // allow:raw-byte-literal — RFC 1952 gzip magic byte 1
|
|
539
|
+
|
|
540
|
+
// Valid RFC 8460 §4.4 result-type values for `failure-details[].result-type`.
|
|
541
|
+
var TLSRPT_RESULT_TYPES = Object.freeze({
|
|
542
|
+
"starttls-not-supported": 1,
|
|
543
|
+
"certificate-host-mismatch": 1,
|
|
544
|
+
"certificate-expired": 1,
|
|
545
|
+
"certificate-not-trusted": 1,
|
|
546
|
+
"validation-failure": 1,
|
|
547
|
+
"tlsa-invalid": 1,
|
|
548
|
+
"dnssec-invalid": 1,
|
|
549
|
+
"dane-required": 1,
|
|
550
|
+
"sts-policy-fetch-error": 1,
|
|
551
|
+
"sts-policy-invalid": 1,
|
|
552
|
+
"sts-webpki-invalid": 1,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Valid RFC 8460 §4.4 policy-type values.
|
|
556
|
+
var TLSRPT_POLICY_TYPES = Object.freeze({
|
|
557
|
+
sts: 1, tlsa: 1, "no-policy-found": 1,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @primitive b.mail.deploy.parseTlsRptReport
|
|
562
|
+
* @signature b.mail.deploy.parseTlsRptReport(input, opts?)
|
|
563
|
+
* @since 0.10.15
|
|
564
|
+
* @status stable
|
|
565
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
566
|
+
* @related b.mail.deploy.tlsRptIngestHttp, b.mail.deploy.tlsRptReportSchema
|
|
567
|
+
*
|
|
568
|
+
* Parse + validate an RFC 8460 TLS-RPT aggregate report. Accepts:
|
|
569
|
+
* - Raw `application/tlsrpt+json` bytes (Buffer or string).
|
|
570
|
+
* - `application/tlsrpt+gzip` bytes (gzip magic auto-detected via
|
|
571
|
+
* `0x1f 0x8b` per RFC 1952, or routed when `opts.contentType`
|
|
572
|
+
* names a gzip media-type).
|
|
573
|
+
*
|
|
574
|
+
* Refusal posture:
|
|
575
|
+
* - Compressed payload > `opts.maxCompressedBytes` (default 4 MiB)
|
|
576
|
+
* → `mail-tlsrpt/oversize-compressed`.
|
|
577
|
+
* - Decompressed payload > `opts.maxDecompressedBytes` (default
|
|
578
|
+
* 32 MiB) → `mail-tlsrpt/gunzip-bomb`.
|
|
579
|
+
* - Compression ratio > `opts.maxRatio` (default 50:1) →
|
|
580
|
+
* `mail-tlsrpt/ratio-bomb`.
|
|
581
|
+
* - Malformed gzip → `mail-tlsrpt/gunzip-failed`.
|
|
582
|
+
* - Routes through `b.guardJson.parse` for proto-pollution / depth
|
|
583
|
+
* / key-count defenses before the §4.4 schema walk.
|
|
584
|
+
* - Missing REQUIRED §4.4 fields → `mail-tlsrpt/bad-schema`.
|
|
585
|
+
* - `policies` MUST be an array (RFC 8460 §4.4 erratum, even for
|
|
586
|
+
* single-policy reports).
|
|
587
|
+
*
|
|
588
|
+
* @opts
|
|
589
|
+
* contentType: string, // optional — hint for gzip routing
|
|
590
|
+
* maxCompressedBytes: number, // default TLSRPT_MAX_COMPRESSED_BYTES (4 MiB)
|
|
591
|
+
* maxDecompressedBytes: number, // default TLSRPT_MAX_DECOMPRESSED_BYTES (32 MiB)
|
|
592
|
+
* maxRatio: number, // default 50 (compressed:decompressed cap)
|
|
593
|
+
*
|
|
594
|
+
* @example
|
|
595
|
+
* var report = b.mail.deploy.parseTlsRptReport(reqBody, {
|
|
596
|
+
* contentType: req.headers["content-type"],
|
|
597
|
+
* });
|
|
598
|
+
* // → { organization-name, date-range: {start, end}, contact-info,
|
|
599
|
+
* // report-id, policies: [{ policy-type, policy-domain, ... }] }
|
|
600
|
+
*/
|
|
601
|
+
function parseTlsRptReport(input, opts) {
|
|
602
|
+
opts = opts || {};
|
|
603
|
+
var bytes;
|
|
604
|
+
if (Buffer.isBuffer(input)) bytes = input;
|
|
605
|
+
else if (typeof input === "string") bytes = Buffer.from(input, "utf8");
|
|
606
|
+
else {
|
|
607
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-input",
|
|
608
|
+
"parseTlsRptReport: input must be a Buffer or string");
|
|
609
|
+
}
|
|
610
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
611
|
+
["maxCompressedBytes", "maxDecompressedBytes", "maxRatio"],
|
|
612
|
+
"parseTlsRptReport", TlsRptParseError, "mail-tlsrpt/bad-opts");
|
|
613
|
+
var maxCompressed = opts.maxCompressedBytes || TLSRPT_MAX_COMPRESSED_BYTES;
|
|
614
|
+
var maxDecompressed = opts.maxDecompressedBytes || TLSRPT_MAX_DECOMPRESSED_BYTES;
|
|
615
|
+
var maxRatio = opts.maxRatio || TLSRPT_MAX_RATIO;
|
|
616
|
+
if (bytes.length > maxCompressed) {
|
|
617
|
+
throw new TlsRptParseError("mail-tlsrpt/oversize-compressed",
|
|
618
|
+
"parseTlsRptReport: compressed payload " + bytes.length +
|
|
619
|
+
" bytes exceeds maxCompressedBytes=" + maxCompressed);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// gzip auto-detect — magic 0x1f 0x8b per RFC 1952. Routes through
|
|
623
|
+
// the same defensive shape as DMARC RUA (lib/mail-auth.js): bound
|
|
624
|
+
// decompression at the cap, surface bomb-vs-malformed as distinct
|
|
625
|
+
// typed errors so audit / alert wiring can react differently.
|
|
626
|
+
var contentType = (opts.contentType || "").toLowerCase();
|
|
627
|
+
var compressedLen = bytes.length;
|
|
628
|
+
var looksGzip = bytes.length >= 2 && bytes[0] === TLSRPT_GZIP_MAGIC_0 && bytes[1] === TLSRPT_GZIP_MAGIC_1;
|
|
629
|
+
var wasCompressed = false;
|
|
630
|
+
if (contentType.indexOf("gzip") !== -1 || looksGzip) {
|
|
631
|
+
wasCompressed = true;
|
|
632
|
+
try { bytes = zlib.gunzipSync(bytes, { maxOutputLength: maxDecompressed }); }
|
|
633
|
+
catch (e) {
|
|
634
|
+
var msg = (e && e.message) || String(e);
|
|
635
|
+
var isBomb = (e && (e.code === "ERR_BUFFER_TOO_LARGE" || e.code === "ERR_OUT_OF_RANGE")) ||
|
|
636
|
+
/output length|max(?:imum)?\s+output|exceeds?/i.test(msg);
|
|
637
|
+
if (isBomb) {
|
|
638
|
+
throw new TlsRptParseError("mail-tlsrpt/gunzip-bomb",
|
|
639
|
+
"parseTlsRptReport: gunzip output exceeded " + maxDecompressed +
|
|
640
|
+
" bytes (decompression amplification — refused per CVE-2025-0725 class)");
|
|
641
|
+
}
|
|
642
|
+
throw new TlsRptParseError("mail-tlsrpt/gunzip-failed",
|
|
643
|
+
"parseTlsRptReport: gunzip failed: " + msg);
|
|
644
|
+
}
|
|
645
|
+
if (compressedLen > 0 && bytes.length / compressedLen > maxRatio) {
|
|
646
|
+
throw new TlsRptParseError("mail-tlsrpt/ratio-bomb",
|
|
647
|
+
"parseTlsRptReport: decompression ratio " +
|
|
648
|
+
Math.round(bytes.length / compressedLen) + ":1 exceeds maxRatio=" +
|
|
649
|
+
maxRatio + ":1 (decompression amplification — refused)");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Route through b.guardJson — proto-pollution / depth / key-count
|
|
654
|
+
// defenses on every untrusted-JSON parse path (closes v0.10.14
|
|
655
|
+
// detector class for untrusted-json-without-guardjson).
|
|
656
|
+
var raw;
|
|
657
|
+
try {
|
|
658
|
+
raw = guardJson().parse(bytes.toString("utf8"), {
|
|
659
|
+
maxBytes: maxDecompressed,
|
|
660
|
+
maxDepth: 32, // allow:raw-byte-literal — JSON depth cap
|
|
661
|
+
maxKeys: 1000, // allow:raw-byte-literal — top-level key cap
|
|
662
|
+
});
|
|
663
|
+
} catch (_e) {
|
|
664
|
+
// Fall back to b.safeJson.parse if guardJson isn't available (in
|
|
665
|
+
// certain bootstrap paths). Both refuse __proto__ / depth-bombs.
|
|
666
|
+
try { raw = safeJson.parse(bytes.toString("utf8")); }
|
|
667
|
+
catch (e2) {
|
|
668
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-json",
|
|
669
|
+
"parseTlsRptReport: JSON parse failed: " + ((e2 && e2.message) || String(e2)));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return _validateTlsRptReport(raw, { wasCompressed: wasCompressed });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function _validateTlsRptReport(raw, ctx) {
|
|
677
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
678
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-schema",
|
|
679
|
+
"parseTlsRptReport: top-level must be a JSON object");
|
|
680
|
+
}
|
|
681
|
+
// RFC 8460 §4.4 REQUIRED fields.
|
|
682
|
+
var orgName = raw["organization-name"];
|
|
683
|
+
var contact = raw["contact-info"];
|
|
684
|
+
var reportId = raw["report-id"];
|
|
685
|
+
var dateRange = raw["date-range"];
|
|
686
|
+
var policies = raw["policies"];
|
|
687
|
+
if (typeof orgName !== "string" || orgName.length === 0) {
|
|
688
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-schema",
|
|
689
|
+
"parseTlsRptReport: missing required string 'organization-name'");
|
|
690
|
+
}
|
|
691
|
+
if (typeof contact !== "string" || contact.length === 0) {
|
|
692
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-schema",
|
|
693
|
+
"parseTlsRptReport: missing required string 'contact-info'");
|
|
694
|
+
}
|
|
695
|
+
if (typeof reportId !== "string" || reportId.length === 0) {
|
|
696
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-schema",
|
|
697
|
+
"parseTlsRptReport: missing required string 'report-id'");
|
|
698
|
+
}
|
|
699
|
+
if (!dateRange || typeof dateRange !== "object" ||
|
|
700
|
+
typeof dateRange["start-datetime"] !== "string" ||
|
|
701
|
+
typeof dateRange["end-datetime"] !== "string") {
|
|
702
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-schema",
|
|
703
|
+
"parseTlsRptReport: 'date-range' must have string start-datetime + end-datetime");
|
|
704
|
+
}
|
|
705
|
+
// RFC 8460 §4.4 erratum — `policies` MUST be an array even for a
|
|
706
|
+
// single-policy report. Some legacy implementations emit a bare
|
|
707
|
+
// object; we refuse to normalize so the operator catches the
|
|
708
|
+
// upstream non-conformance.
|
|
709
|
+
if (!Array.isArray(policies)) {
|
|
710
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-schema",
|
|
711
|
+
"parseTlsRptReport: 'policies' must be an array (RFC 8460 §4.4 erratum); single-policy reports still use [policy] form");
|
|
712
|
+
}
|
|
713
|
+
if (policies.length === 0) {
|
|
714
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-schema",
|
|
715
|
+
"parseTlsRptReport: 'policies' must be a non-empty array");
|
|
716
|
+
}
|
|
717
|
+
if (policies.length > TLSRPT_MAX_POLICIES) {
|
|
718
|
+
throw new TlsRptParseError("mail-tlsrpt/too-many-policies",
|
|
719
|
+
"parseTlsRptReport: report has " + policies.length +
|
|
720
|
+
" policies (cap " + TLSRPT_MAX_POLICIES + ")");
|
|
721
|
+
}
|
|
722
|
+
// Codex P2 (v0.10.15) — validate summary counts as finite non-negative
|
|
723
|
+
// integers before summing. `Number(...) || 0` would accept
|
|
724
|
+
// `Infinity` (from JSON literal `1e309` or string "Infinity"),
|
|
725
|
+
// negative values, and arbitrary strings (coerced to NaN→0). Each
|
|
726
|
+
// is operator-untrusted input on an audit-emitted path.
|
|
727
|
+
var totalSuccess = 0, totalFailure = 0;
|
|
728
|
+
for (var i = 0; i < policies.length; i += 1) {
|
|
729
|
+
_validatePolicy(policies[i], i);
|
|
730
|
+
var summary = policies[i]["summary"];
|
|
731
|
+
if (summary && typeof summary === "object") {
|
|
732
|
+
var sRaw = summary["total-successful-session-count"];
|
|
733
|
+
var fRaw = summary["total-failure-session-count"];
|
|
734
|
+
if (sRaw !== undefined) {
|
|
735
|
+
if (typeof sRaw !== "number" || !isFinite(sRaw) || sRaw < 0 || Math.floor(sRaw) !== sRaw) {
|
|
736
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-summary",
|
|
737
|
+
"parseTlsRptReport: policies[" + i + "].summary.total-successful-session-count must be a finite non-negative integer");
|
|
738
|
+
}
|
|
739
|
+
totalSuccess += sRaw;
|
|
740
|
+
}
|
|
741
|
+
if (fRaw !== undefined) {
|
|
742
|
+
if (typeof fRaw !== "number" || !isFinite(fRaw) || fRaw < 0 || Math.floor(fRaw) !== fRaw) {
|
|
743
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-summary",
|
|
744
|
+
"parseTlsRptReport: policies[" + i + "].summary.total-failure-session-count must be a finite non-negative integer");
|
|
745
|
+
}
|
|
746
|
+
totalFailure += fRaw;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// Return a normalized shape — preserve every operator-readable
|
|
751
|
+
// field, plus add framework-attached metadata (sessionTotals,
|
|
752
|
+
// wasCompressed) that doesn't conflict with the RFC schema.
|
|
753
|
+
return {
|
|
754
|
+
"organization-name": orgName,
|
|
755
|
+
"contact-info": contact,
|
|
756
|
+
"report-id": reportId,
|
|
757
|
+
"date-range": {
|
|
758
|
+
"start-datetime": dateRange["start-datetime"],
|
|
759
|
+
"end-datetime": dateRange["end-datetime"],
|
|
760
|
+
},
|
|
761
|
+
"policies": policies,
|
|
762
|
+
sessionTotals: {
|
|
763
|
+
success: totalSuccess,
|
|
764
|
+
failure: totalFailure,
|
|
765
|
+
},
|
|
766
|
+
wasCompressed: ctx.wasCompressed === true,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function _validatePolicy(p, idx) {
|
|
771
|
+
if (!p || typeof p !== "object") {
|
|
772
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-policy",
|
|
773
|
+
"parseTlsRptReport: policies[" + idx + "] must be an object");
|
|
774
|
+
}
|
|
775
|
+
var policy = p["policy"];
|
|
776
|
+
if (!policy || typeof policy !== "object") {
|
|
777
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-policy",
|
|
778
|
+
"parseTlsRptReport: policies[" + idx + "].policy missing");
|
|
779
|
+
}
|
|
780
|
+
var pType = policy["policy-type"];
|
|
781
|
+
if (!TLSRPT_POLICY_TYPES[pType]) {
|
|
782
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-policy",
|
|
783
|
+
"parseTlsRptReport: policies[" + idx + "].policy.policy-type '" + pType +
|
|
784
|
+
"' not in {sts, tlsa, no-policy-found}");
|
|
785
|
+
}
|
|
786
|
+
if (typeof policy["policy-domain"] !== "string" || policy["policy-domain"].length === 0) {
|
|
787
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-policy",
|
|
788
|
+
"parseTlsRptReport: policies[" + idx + "].policy.policy-domain missing");
|
|
789
|
+
}
|
|
790
|
+
// policy-string is optional for no-policy-found, REQUIRED otherwise.
|
|
791
|
+
// We don't enforce — operators may receive partial reports from
|
|
792
|
+
// legacy reporters; we surface the field as-is.
|
|
793
|
+
var failureDetails = p["failure-details"];
|
|
794
|
+
if (failureDetails !== undefined) {
|
|
795
|
+
if (!Array.isArray(failureDetails)) {
|
|
796
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-policy",
|
|
797
|
+
"parseTlsRptReport: policies[" + idx + "].failure-details must be an array");
|
|
798
|
+
}
|
|
799
|
+
if (failureDetails.length > TLSRPT_MAX_FAILURE_DETAILS) {
|
|
800
|
+
throw new TlsRptParseError("mail-tlsrpt/too-many-failures",
|
|
801
|
+
"parseTlsRptReport: policies[" + idx + "] has " + failureDetails.length +
|
|
802
|
+
" failure-details (cap " + TLSRPT_MAX_FAILURE_DETAILS + ")");
|
|
803
|
+
}
|
|
804
|
+
for (var k = 0; k < failureDetails.length; k += 1) {
|
|
805
|
+
var fd = failureDetails[k];
|
|
806
|
+
if (!fd || typeof fd !== "object") {
|
|
807
|
+
throw new TlsRptParseError("mail-tlsrpt/bad-failure-detail",
|
|
808
|
+
"parseTlsRptReport: policies[" + idx + "].failure-details[" + k + "] must be an object");
|
|
809
|
+
}
|
|
810
|
+
if (typeof fd["result-type"] === "string" && !TLSRPT_RESULT_TYPES[fd["result-type"]]) {
|
|
811
|
+
// Unknown result-type — surface as audit metadata but don't
|
|
812
|
+
// refuse; RFC 8460 §4.4 result-type registry can grow over
|
|
813
|
+
// time and we shouldn't break on new IANA entries.
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* @primitive b.mail.deploy.tlsRptReportSchema
|
|
821
|
+
* @signature b.mail.deploy.tlsRptReportSchema()
|
|
822
|
+
* @since 0.10.15
|
|
823
|
+
* @status stable
|
|
824
|
+
* @related b.mail.deploy.parseTlsRptReport
|
|
825
|
+
*
|
|
826
|
+
* Returns a structured RFC 8460 §4.4 schema descriptor — operator
|
|
827
|
+
* dashboards consume this to render report shape consistently.
|
|
828
|
+
* The descriptor names every required + optional field with type +
|
|
829
|
+
* cardinality + brief description. Pure function; safe to cache.
|
|
830
|
+
*
|
|
831
|
+
* @example
|
|
832
|
+
* var schema = b.mail.deploy.tlsRptReportSchema();
|
|
833
|
+
* schema.required.indexOf("report-id") !== -1; // → true
|
|
834
|
+
*/
|
|
835
|
+
function tlsRptReportSchema() {
|
|
836
|
+
return {
|
|
837
|
+
rfc: "RFC 8460 §4.4",
|
|
838
|
+
required: [
|
|
839
|
+
"organization-name", "contact-info", "report-id", "date-range", "policies",
|
|
840
|
+
],
|
|
841
|
+
fields: {
|
|
842
|
+
"organization-name": { type: "string", required: true, description: "Reporter organisation display name." },
|
|
843
|
+
"contact-info": { type: "string", required: true, description: "Email / URI for reporter contact." },
|
|
844
|
+
"report-id": { type: "string", required: true, description: "Reporter-issued unique report identifier (RFC 5322 msg-id shape)." },
|
|
845
|
+
"date-range": { type: "object", required: true, description: "Window the report covers; { start-datetime, end-datetime } in RFC 3339 form." },
|
|
846
|
+
"policies": { type: "array", required: true, description: "Array of policy evaluations (RFC 8460 §4.4 erratum — always array, even for single-policy reports)." },
|
|
847
|
+
},
|
|
848
|
+
policyFields: {
|
|
849
|
+
"policy": { type: "object", required: true, description: "{ policy-type, policy-string, policy-domain, mx-host }." },
|
|
850
|
+
"summary": { type: "object", required: false, description: "{ total-successful-session-count, total-failure-session-count }." },
|
|
851
|
+
"failure-details": { type: "array", required: false, description: "Per-failure details (result-type, sending-mta-ip, etc.)." },
|
|
852
|
+
},
|
|
853
|
+
policyTypes: Object.keys(TLSRPT_POLICY_TYPES),
|
|
854
|
+
resultTypes: Object.keys(TLSRPT_RESULT_TYPES),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* @primitive b.mail.deploy.tlsRptIngestHttp
|
|
860
|
+
* @signature b.mail.deploy.tlsRptIngestHttp(opts)
|
|
861
|
+
* @since 0.10.15
|
|
862
|
+
* @status stable
|
|
863
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
864
|
+
* @related b.mail.deploy.parseTlsRptReport, b.mail.deploy.tlsRptReportSchema
|
|
865
|
+
*
|
|
866
|
+
* Returns an `(req, res)` request handler mounted at the operator's
|
|
867
|
+
* `rua=https://<host>/<path>` endpoint. Implements the receive-side
|
|
868
|
+
* of RFC 8460 §5.4:
|
|
869
|
+
*
|
|
870
|
+
* - POST only — non-POST returns 405 with Allow: POST.
|
|
871
|
+
* - Accepts `application/tlsrpt+json` and `application/tlsrpt+gzip`
|
|
872
|
+
* (RFC 8460 §6.4-6.5 IANA media types). 415 on others.
|
|
873
|
+
* - Body size cap (default 4 MiB compressed) — 413 on exceed.
|
|
874
|
+
* - Routes the bytes through `parseTlsRptReport`. 400 on parse
|
|
875
|
+
* failure (with `Error-Type:` header naming the typed error
|
|
876
|
+
* code). 201 on accept.
|
|
877
|
+
* - Calls `opts.onAccept(report, req)` after successful parse.
|
|
878
|
+
* Operator's hook decides storage (most operators journal +
|
|
879
|
+
* emit a metric); the framework does NOT persist by default.
|
|
880
|
+
* - Emits a `mail.tlsrpt.ingest_http` audit event with
|
|
881
|
+
* posture-aware payload (organization-name, report-id,
|
|
882
|
+
* policy-domain set, session totals).
|
|
883
|
+
*
|
|
884
|
+
* Authentication discipline (Codex P2 v0.10.15):
|
|
885
|
+
* - `trustedReporters` is a CONTENT-SIDE soft filter — it compares
|
|
886
|
+
* the reporter's self-declared `organization-name` field (the
|
|
887
|
+
* report body, operator-untrusted) against the operator's
|
|
888
|
+
* allowlist. A hostile sender can forge any `organization-name`
|
|
889
|
+
* string to bypass it. This option is ADVISORY: a tripwire that
|
|
890
|
+
* surfaces unexpected reporter-name strings in audit, not an
|
|
891
|
+
* authentication boundary.
|
|
892
|
+
* - For real authentication, supply `opts.authenticate(req)` — the
|
|
893
|
+
* hook fires BEFORE parsing the body and returns truthy / falsy
|
|
894
|
+
* (or a Promise). False / falsy refuses with 401 + the
|
|
895
|
+
* `mail-tlsrpt/unauthenticated` audit code. Operators wire this
|
|
896
|
+
* to their mTLS-peer-cert / IP-allowlist / signed-header /
|
|
897
|
+
* reverse-proxy auth boundary. The framework intentionally does
|
|
898
|
+
* NOT couple to any specific auth scheme.
|
|
899
|
+
*
|
|
900
|
+
* @opts
|
|
901
|
+
* authenticate: Function, // (req) → boolean | Promise<boolean>; SHA real auth boundary
|
|
902
|
+
* trustedReporters: string[], // ADVISORY content filter on report.organization-name (operator-untrusted field)
|
|
903
|
+
* maxCompressedBytes: number, // default 4 MiB
|
|
904
|
+
* maxDecompressedBytes: number, // default 32 MiB
|
|
905
|
+
* maxRatio: number, // default 50
|
|
906
|
+
* onAccept: Function, // (report, req) → void | Promise
|
|
907
|
+
* onRefuse: Function, // (errCode, errMessage, req) → void
|
|
908
|
+
* audit: object, // optional b.audit handle (default: framework audit)
|
|
909
|
+
*
|
|
910
|
+
* @example
|
|
911
|
+
* app.post("/tlsrpt", b.mail.deploy.tlsRptIngestHttp({
|
|
912
|
+
* onAccept: function (report) {
|
|
913
|
+
* b.journal.append({ kind: "tlsrpt", report: report });
|
|
914
|
+
* },
|
|
915
|
+
* }));
|
|
916
|
+
*/
|
|
917
|
+
function tlsRptIngestHttp(opts) {
|
|
918
|
+
opts = opts || {};
|
|
919
|
+
validateOpts(opts, ["authenticate", "trustedReporters", "maxCompressedBytes",
|
|
920
|
+
"maxDecompressedBytes", "maxRatio", "onAccept", "onRefuse",
|
|
921
|
+
"audit", "compliance"],
|
|
922
|
+
"mail.deploy.tlsRptIngestHttp");
|
|
923
|
+
validateOpts.optionalFunction(opts.authenticate, "tlsRptIngestHttp: opts.authenticate",
|
|
924
|
+
MailDeployError, "mail-tlsrpt/bad-opts");
|
|
925
|
+
if (opts.trustedReporters !== undefined &&
|
|
926
|
+
(!Array.isArray(opts.trustedReporters) ||
|
|
927
|
+
opts.trustedReporters.some(function (s) { return typeof s !== "string"; }))) {
|
|
928
|
+
throw new MailDeployError("mail-tlsrpt/bad-opts",
|
|
929
|
+
"tlsRptIngestHttp: opts.trustedReporters must be an array of strings");
|
|
930
|
+
}
|
|
931
|
+
var authenticate = typeof opts.authenticate === "function" ? opts.authenticate : null;
|
|
932
|
+
var trusted = opts.trustedReporters
|
|
933
|
+
? Object.freeze(opts.trustedReporters.reduce(function (a, s) { a[s] = 1; return a; }, {}))
|
|
934
|
+
: null;
|
|
935
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxCompressedBytes, "maxCompressedBytes", MailDeployError, "mail-tlsrpt/bad-opts");
|
|
936
|
+
var maxCompressed = opts.maxCompressedBytes || TLSRPT_MAX_COMPRESSED_BYTES;
|
|
937
|
+
// Cache the other caps so the per-request parser call sees them.
|
|
938
|
+
var parseOpts = {
|
|
939
|
+
maxCompressedBytes: maxCompressed,
|
|
940
|
+
maxDecompressedBytes: opts.maxDecompressedBytes,
|
|
941
|
+
maxRatio: opts.maxRatio,
|
|
942
|
+
};
|
|
943
|
+
var onAccept = typeof opts.onAccept === "function" ? opts.onAccept : null;
|
|
944
|
+
var onRefuse = typeof opts.onRefuse === "function" ? opts.onRefuse : null;
|
|
945
|
+
|
|
946
|
+
return function tlsRptHandler(req, res) {
|
|
947
|
+
if (req.method !== "POST") {
|
|
948
|
+
res.writeHead(405, { "Allow": "POST", "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
949
|
+
res.end("RFC 8460 §5.4 requires POST\n");
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
var ct = (req.headers["content-type"] || "").toLowerCase();
|
|
953
|
+
var ctRoot = ct.split(";")[0].trim();
|
|
954
|
+
if (ctRoot !== "application/tlsrpt+json" && ctRoot !== "application/tlsrpt+gzip") {
|
|
955
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
|
|
956
|
+
reason: "bad-content-type", contentType: ctRoot,
|
|
957
|
+
});
|
|
958
|
+
if (onRefuse) try { onRefuse("mail-tlsrpt/bad-content-type", "unexpected content-type " + ctRoot, req); }
|
|
959
|
+
catch (_e) { /* drop-silent */ }
|
|
960
|
+
res.writeHead(415, { "Content-Type": "text/plain", "Accept": "application/tlsrpt+json, application/tlsrpt+gzip" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
961
|
+
res.end("RFC 8460 §6.4-6.5 media types required\n");
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
// Codex P2 (v0.10.15) — real-authentication boundary BEFORE body
|
|
965
|
+
// collection. The operator-supplied `authenticate(req)` hook
|
|
966
|
+
// routes to mTLS peer-cert / IP-allowlist / signed-header /
|
|
967
|
+
// reverse-proxy header inspection. Sync-or-async; falsy → 401.
|
|
968
|
+
if (authenticate) {
|
|
969
|
+
var authPromise;
|
|
970
|
+
try { authPromise = Promise.resolve(authenticate(req)); }
|
|
971
|
+
catch (e) { authPromise = Promise.reject(e); }
|
|
972
|
+
authPromise.then(function (ok) {
|
|
973
|
+
if (!ok) {
|
|
974
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", { reason: "unauthenticated" });
|
|
975
|
+
if (onRefuse) try { onRefuse("mail-tlsrpt/unauthenticated", "authenticate(req) returned falsy", req); }
|
|
976
|
+
catch (_e) { /* drop-silent */ }
|
|
977
|
+
res.writeHead(401, { "Content-Type": "text/plain", "Error-Type": "mail-tlsrpt/unauthenticated" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
978
|
+
res.end("authentication required\n");
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
_collectAndProcess();
|
|
982
|
+
}, function (err) {
|
|
983
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
|
|
984
|
+
reason: "auth-error", message: (err && err.message) || String(err),
|
|
985
|
+
});
|
|
986
|
+
if (onRefuse) try { onRefuse("mail-tlsrpt/auth-error", (err && err.message) || String(err), req); }
|
|
987
|
+
catch (_e) { /* drop-silent */ }
|
|
988
|
+
res.writeHead(500, { "Content-Type": "text/plain", "Error-Type": "mail-tlsrpt/auth-error" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
989
|
+
res.end("authenticate hook threw\n");
|
|
990
|
+
});
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
_collectAndProcess();
|
|
994
|
+
|
|
995
|
+
function _collectAndProcess() {
|
|
996
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
997
|
+
maxBytes: maxCompressed,
|
|
998
|
+
errorClass: MailDeployError,
|
|
999
|
+
sizeCode: "mail-tlsrpt/oversize-compressed",
|
|
1000
|
+
});
|
|
1001
|
+
var aborted = false;
|
|
1002
|
+
req.on("data", function (chunk) {
|
|
1003
|
+
if (aborted) return;
|
|
1004
|
+
try { collector.push(chunk); }
|
|
1005
|
+
catch (e) {
|
|
1006
|
+
aborted = true;
|
|
1007
|
+
try { req.destroy(); } catch (_e) { /* best-effort */ }
|
|
1008
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
|
|
1009
|
+
reason: "oversize-compressed", bytes: collector.bytesCollected(), cap: maxCompressed,
|
|
1010
|
+
});
|
|
1011
|
+
if (onRefuse) try { onRefuse("mail-tlsrpt/oversize-compressed", "body exceeded " + maxCompressed + " bytes", req); }
|
|
1012
|
+
catch (_e) { /* drop-silent */ }
|
|
1013
|
+
if (!res.headersSent) {
|
|
1014
|
+
res.writeHead(413, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
1015
|
+
res.end("RFC 8460 §5.4 — body exceeds " + maxCompressed + " bytes\n");
|
|
1016
|
+
}
|
|
1017
|
+
void e; // _e shadowed by lower scope; mark intent
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
req.on("end", function () {
|
|
1021
|
+
if (aborted) return;
|
|
1022
|
+
var report;
|
|
1023
|
+
try {
|
|
1024
|
+
report = parseTlsRptReport(collector.result(), Object.assign({
|
|
1025
|
+
contentType: ctRoot,
|
|
1026
|
+
}, parseOpts));
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
var code = (e && e.code) || "mail-tlsrpt/unknown";
|
|
1029
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
|
|
1030
|
+
reason: code, message: (e && e.message) || String(e),
|
|
1031
|
+
});
|
|
1032
|
+
if (onRefuse) try { onRefuse(code, (e && e.message) || String(e), req); }
|
|
1033
|
+
catch (_e) { /* drop-silent */ }
|
|
1034
|
+
var status = code === "mail-tlsrpt/oversize-compressed" ? 413
|
|
1035
|
+
: code === "mail-tlsrpt/gunzip-bomb" ? 413
|
|
1036
|
+
: code === "mail-tlsrpt/ratio-bomb" ? 413
|
|
1037
|
+
: code === "mail-tlsrpt/bad-content-type" ? 415
|
|
1038
|
+
: 400; // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
1039
|
+
res.writeHead(status, { "Content-Type": "text/plain", "Error-Type": code });
|
|
1040
|
+
res.end("RFC 8460 §5.4 — refused: " + code + "\n");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (trusted && !trusted[report["organization-name"]]) {
|
|
1044
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
|
|
1045
|
+
reason: "untrusted-reporter", reporter: report["organization-name"],
|
|
1046
|
+
});
|
|
1047
|
+
if (onRefuse) try { onRefuse("mail-tlsrpt/untrusted-reporter",
|
|
1048
|
+
"reporter '" + report["organization-name"] + "' not in trustedReporters", req); }
|
|
1049
|
+
catch (_e) { /* drop-silent */ }
|
|
1050
|
+
res.writeHead(403, { "Content-Type": "text/plain", "Error-Type": "mail-tlsrpt/untrusted-reporter" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
1051
|
+
res.end("RFC 8460 §5.3-class: untrusted reporter\n");
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
var policyDomains = report.policies.map(function (p) {
|
|
1055
|
+
return p && p.policy && p.policy["policy-domain"];
|
|
1056
|
+
}).filter(Boolean);
|
|
1057
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "success", {
|
|
1058
|
+
reporter: report["organization-name"],
|
|
1059
|
+
reportId: report["report-id"],
|
|
1060
|
+
policyDomains: policyDomains,
|
|
1061
|
+
sessionTotals: report.sessionTotals,
|
|
1062
|
+
policyCount: report.policies.length,
|
|
1063
|
+
wasCompressed: report.wasCompressed,
|
|
1064
|
+
});
|
|
1065
|
+
if (onAccept) {
|
|
1066
|
+
try {
|
|
1067
|
+
var ret = onAccept(report, req);
|
|
1068
|
+
if (ret && typeof ret.then === "function") {
|
|
1069
|
+
ret.then(function () {
|
|
1070
|
+
if (!res.headersSent) {
|
|
1071
|
+
res.writeHead(201, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
1072
|
+
res.end("RFC 8460 §5.4 — accepted\n");
|
|
1073
|
+
}
|
|
1074
|
+
}, function (_e) {
|
|
1075
|
+
if (!res.headersSent) {
|
|
1076
|
+
res.writeHead(500, { "Content-Type": "text/plain" }); // allow:raw-byte-literal — internal-error status
|
|
1077
|
+
res.end("internal error processing report\n");
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
} catch (_e) { /* fall through to 201 — operator hook is best-effort */ }
|
|
1083
|
+
}
|
|
1084
|
+
res.writeHead(201, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
1085
|
+
res.end("RFC 8460 §5.4 — accepted\n");
|
|
1086
|
+
});
|
|
1087
|
+
req.on("error", function () {
|
|
1088
|
+
if (aborted) return;
|
|
1089
|
+
aborted = true;
|
|
1090
|
+
_safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", { reason: "req-error" });
|
|
1091
|
+
if (!res.headersSent) {
|
|
1092
|
+
res.writeHead(400, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
|
|
1093
|
+
res.end("malformed request\n");
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
} // end _collectAndProcess
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function _safeAuditEmit(handle, action, outcome, metadata) {
|
|
1101
|
+
try {
|
|
1102
|
+
var a = handle || audit();
|
|
1103
|
+
if (a && typeof a.safeEmit === "function") {
|
|
1104
|
+
a.safeEmit({ action: action, outcome: outcome, actor: {}, metadata: metadata });
|
|
1105
|
+
}
|
|
1106
|
+
} catch (_e) { /* drop-silent — audit failure must not block ingest */ }
|
|
1107
|
+
}
|
|
1108
|
+
|
|
486
1109
|
module.exports = {
|
|
487
|
-
mtaStsPublish:
|
|
488
|
-
danePublish:
|
|
489
|
-
autoConfigXml:
|
|
490
|
-
autoDiscoverXml:
|
|
491
|
-
|
|
1110
|
+
mtaStsPublish: mtaStsPublish,
|
|
1111
|
+
danePublish: danePublish,
|
|
1112
|
+
autoConfigXml: autoConfigXml,
|
|
1113
|
+
autoDiscoverXml: autoDiscoverXml,
|
|
1114
|
+
parseTlsRptReport: parseTlsRptReport,
|
|
1115
|
+
tlsRptReportSchema: tlsRptReportSchema,
|
|
1116
|
+
tlsRptIngestHttp: tlsRptIngestHttp,
|
|
1117
|
+
MailDeployError: MailDeployError,
|
|
1118
|
+
TlsRptParseError: TlsRptParseError,
|
|
492
1119
|
};
|