@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.
@@ -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: mtaStsPublish,
488
- danePublish: danePublish,
489
- autoConfigXml: autoConfigXml,
490
- autoDiscoverXml: autoDiscoverXml,
491
- MailDeployError: MailDeployError,
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
  };