@blamejs/core 0.14.19 → 0.14.20
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 +2 -0
- package/README.md +1 -1
- package/lib/auth/oauth.js +736 -1
- package/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/crypto-field.js +274 -17
- package/lib/mail-auth.js +333 -0
- package/lib/middleware/fetch-metadata.js +115 -14
- package/lib/middleware/security-headers.js +47 -0
- package/lib/observability.js +39 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-auth.js
CHANGED
|
@@ -50,6 +50,7 @@ var validateOpts = require("./validate-opts");
|
|
|
50
50
|
var bCrypto = require("./crypto");
|
|
51
51
|
var C = require("./constants");
|
|
52
52
|
var dkim = require("./mail-dkim");
|
|
53
|
+
var mimeParse = require("./mime-parse");
|
|
53
54
|
var safeXml = require("./parsers/safe-xml");
|
|
54
55
|
var ipUtils = require("./ip-utils");
|
|
55
56
|
var publicSuffix = require("./public-suffix");
|
|
@@ -2615,6 +2616,337 @@ async function iprevVerify(ip) {
|
|
|
2615
2616
|
};
|
|
2616
2617
|
}
|
|
2617
2618
|
|
|
2619
|
+
// ---- DMARC forensic (RUF) failure-report parser (RFC 6591 + RFC 7489 §7.3) ----
|
|
2620
|
+
//
|
|
2621
|
+
// A domain publishing a DMARC `ruf=` policy receives per-message
|
|
2622
|
+
// failure reports when an authentication check fails. RFC 7489 §7.3
|
|
2623
|
+
// specifies the Authentication Failure Reporting Format (AFRF) of
|
|
2624
|
+
// RFC 6591 for these: a multipart/report (report-type=feedback-report)
|
|
2625
|
+
// carrying a `message/feedback-report` part whose header block adds the
|
|
2626
|
+
// DMARC-specific fields (Auth-Failure, Delivery-Result, Identity-
|
|
2627
|
+
// Alignment, DKIM-*/SPF-* result fields) on top of the RFC 5965 base
|
|
2628
|
+
// fields, plus a third part (message/rfc822 or text/rfc822-headers)
|
|
2629
|
+
// with the reported message (in full or headers-only).
|
|
2630
|
+
//
|
|
2631
|
+
// Composes the shared lib/mime-parse.js substrate (the same MIME walker
|
|
2632
|
+
// the RFC 5965 ARF ingest in lib/mail-arf.js uses) for the
|
|
2633
|
+
// multipart/report bisection + message/feedback-report extraction, then
|
|
2634
|
+
// shapes the full RFC 6591 §3.1 forensic field set (which the abuse-
|
|
2635
|
+
// report profile does not model) plus the reported message's headers.
|
|
2636
|
+
//
|
|
2637
|
+
// var rv = b.mail.dmarc.parseForensicReport(rawMessageBytes);
|
|
2638
|
+
// if (!rv.ok) { /* rv.error.code / rv.error.message */ }
|
|
2639
|
+
// else { /* rv.report.feedbackType / .authFailure / … */ }
|
|
2640
|
+
//
|
|
2641
|
+
// Validation tier: DEFENSIVE READER. The input is hostile-by-default
|
|
2642
|
+
// (a per-message failure report arrives at an operator endpoint from an
|
|
2643
|
+
// arbitrary reporting peer). The parser RETURNS a typed error object on
|
|
2644
|
+
// any malformed / over-cap / wrong-shape input — it does NOT throw in
|
|
2645
|
+
// the hot path, so a crafted report can't crash the request that
|
|
2646
|
+
// ingested it. Bytes + part-count + reported-header counts are bounded
|
|
2647
|
+
// (CWE-400 resource-exhaustion class) like the sibling aggregate-report
|
|
2648
|
+
// parser.
|
|
2649
|
+
//
|
|
2650
|
+
// { ok: true, report: { … } }
|
|
2651
|
+
// { ok: false, error: { code: "<slug>", message: "<reason>" } }
|
|
2652
|
+
|
|
2653
|
+
// RFC 6591 §3.2 — the report is small in practice; cap at 8 MiB to match
|
|
2654
|
+
// the sibling DMARC aggregate-report ceiling so operators have one
|
|
2655
|
+
// mental model for "what fits".
|
|
2656
|
+
var DMARC_RUF_MAX_REPORT_BYTES = C.BYTES.mib(8);
|
|
2657
|
+
|
|
2658
|
+
// RFC 2046 §5.1 — a multipart/report failure report has a handful of
|
|
2659
|
+
// parts (text/plain + message/feedback-report + the reported message);
|
|
2660
|
+
// bound the part count so a hostile report with thousands of empty
|
|
2661
|
+
// boundary delimiters can't force unbounded walk work.
|
|
2662
|
+
var DMARC_RUF_MAX_PARTS = 64; // resource-exhaustion bound (CWE-400)
|
|
2663
|
+
|
|
2664
|
+
// RFC 6591 §3.1 — required forensic fields. Feedback-Type and Auth-
|
|
2665
|
+
// Failure are the two that make an auth-failure report a DMARC forensic
|
|
2666
|
+
// report (RFC 7489 §7.3). User-Agent / Version are advisory in practice.
|
|
2667
|
+
var DMARC_RUF_REQUIRED_FIELDS = ["feedback-type", "auth-failure"];
|
|
2668
|
+
|
|
2669
|
+
// RFC 6591 §3.1 — Auth-Failure registry values. Unknown values pass
|
|
2670
|
+
// through (the IANA "Authentication Failure" registry grows); this set
|
|
2671
|
+
// documents the launch vocabulary so operators can route on it.
|
|
2672
|
+
var DMARC_RUF_AUTH_FAILURE_TYPES = Object.freeze({
|
|
2673
|
+
adsp: 1, // RFC 6591 §3.1 (historic ADSP)
|
|
2674
|
+
"bodyhash": 1, // DKIM body-hash mismatch
|
|
2675
|
+
dkim: 1, // DKIM signature failure
|
|
2676
|
+
dmarc: 1, // RFC 7489 §7.3 — DMARC evaluation failure
|
|
2677
|
+
revoked: 1, // signing key revoked
|
|
2678
|
+
signature: 1, // DKIM signature syntactically invalid
|
|
2679
|
+
spf: 1, // SPF check failure
|
|
2680
|
+
});
|
|
2681
|
+
void DMARC_RUF_AUTH_FAILURE_TYPES;
|
|
2682
|
+
|
|
2683
|
+
// RFC 6591 §3.2 — the reported message's header section can be large but
|
|
2684
|
+
// is bounded; cap the number of reported headers we normalize so a
|
|
2685
|
+
// crafted report can't force unbounded work. The full reported message
|
|
2686
|
+
// text is still surfaced verbatim under `reportedMessage` (bounded by
|
|
2687
|
+
// the overall byte cap), but the parsed `reportedHeaders` list is
|
|
2688
|
+
// header-count-capped.
|
|
2689
|
+
var DMARC_RUF_MAX_REPORTED_HEADERS = 256; // resource-exhaustion bound (CWE-400)
|
|
2690
|
+
|
|
2691
|
+
function _rufError(code, message) {
|
|
2692
|
+
return { ok: false, error: { code: code, message: message } };
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// Parse the reported message's headers (RFC 6591 §3.2 — the third part
|
|
2696
|
+
// of the report carries the message that failed authentication, in full
|
|
2697
|
+
// or headers-only). Returns an own-keys-only map (null-prototype) of
|
|
2698
|
+
// header-name → value (last-wins for duplicate single-valued lookups;
|
|
2699
|
+
// the full ordered list is also returned) so a header named
|
|
2700
|
+
// `__proto__` / `constructor` in a hostile report can't pollute the
|
|
2701
|
+
// prototype chain (prototype-pollution class).
|
|
2702
|
+
function _parseReportedHeaders(reportedMessage) {
|
|
2703
|
+
var ordered = [];
|
|
2704
|
+
var map = Object.create(null);
|
|
2705
|
+
if (typeof reportedMessage !== "string" || reportedMessage.length === 0) {
|
|
2706
|
+
return { headers: ordered, map: map, truncated: false };
|
|
2707
|
+
}
|
|
2708
|
+
var split;
|
|
2709
|
+
try { split = mimeParse.splitHeadersAndBody(reportedMessage); }
|
|
2710
|
+
catch (_e) { return { headers: ordered, map: map, truncated: false }; }
|
|
2711
|
+
var hdrs = Array.isArray(split.headers) ? split.headers : [];
|
|
2712
|
+
var truncated = false;
|
|
2713
|
+
for (var i = 0; i < hdrs.length; i += 1) {
|
|
2714
|
+
if (ordered.length >= DMARC_RUF_MAX_REPORTED_HEADERS) { truncated = true; break; }
|
|
2715
|
+
var h = hdrs[i];
|
|
2716
|
+
if (!h || typeof h.name !== "string") continue;
|
|
2717
|
+
var name = h.name;
|
|
2718
|
+
var value = typeof h.value === "string" ? h.value : "";
|
|
2719
|
+
ordered.push({ name: name, value: value });
|
|
2720
|
+
// Own-key assignment on a null-prototype object: a reported header
|
|
2721
|
+
// named __proto__ / constructor / prototype is stored as data, not
|
|
2722
|
+
// walked up the chain.
|
|
2723
|
+
map[name.toLowerCase()] = value;
|
|
2724
|
+
}
|
|
2725
|
+
return { headers: ordered, map: map, truncated: truncated };
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// Reassemble a MIME part's headers + body so a reported message that
|
|
2729
|
+
// ships as text/rfc822-headers (no separate body) still round-trips its
|
|
2730
|
+
// header bytes (RFC 6591 §3.2 permits headers-only).
|
|
2731
|
+
function _reassembleRufPart(part) {
|
|
2732
|
+
var hdrs = "";
|
|
2733
|
+
var ph = Array.isArray(part.headers) ? part.headers : [];
|
|
2734
|
+
for (var i = 0; i < ph.length; i += 1) {
|
|
2735
|
+
hdrs += ph[i].name + ": " + ph[i].value + "\r\n";
|
|
2736
|
+
}
|
|
2737
|
+
return hdrs + "\r\n" + (part.body || "");
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function dmarcParseForensicReport(input, opts) {
|
|
2741
|
+
opts = opts || {};
|
|
2742
|
+
|
|
2743
|
+
// ---- Coerce + byte-cap (defensive: typed error, never throw) ----
|
|
2744
|
+
var asString;
|
|
2745
|
+
if (typeof input === "string") asString = input;
|
|
2746
|
+
else if (Buffer.isBuffer(input)) asString = input.toString("utf8");
|
|
2747
|
+
else {
|
|
2748
|
+
return _rufError("mail-auth/dmarc-ruf-bad-input",
|
|
2749
|
+
"dmarc.parseForensicReport: input must be a string or Buffer");
|
|
2750
|
+
}
|
|
2751
|
+
var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
|
|
2752
|
+
? opts.maxBytes
|
|
2753
|
+
: DMARC_RUF_MAX_REPORT_BYTES;
|
|
2754
|
+
if (asString.length > maxBytes) {
|
|
2755
|
+
return _rufError("mail-auth/dmarc-ruf-too-large",
|
|
2756
|
+
"dmarc.parseForensicReport: report exceeds " + maxBytes + " bytes (got " + asString.length + ")");
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// ---- Bisect top-level headers / body; require multipart/report ----
|
|
2760
|
+
var top;
|
|
2761
|
+
try { top = mimeParse.splitHeadersAndBody(asString); }
|
|
2762
|
+
catch (e) {
|
|
2763
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2764
|
+
"dmarc.parseForensicReport: header/body split failed: " + ((e && e.message) || String(e)));
|
|
2765
|
+
}
|
|
2766
|
+
var ct = mimeParse.parseContentType(mimeParse.findHeader(top.headers, "Content-Type") || "");
|
|
2767
|
+
if (ct.type !== "multipart/report") {
|
|
2768
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2769
|
+
"dmarc.parseForensicReport: top-level Content-Type must be multipart/report (got '" + ct.type + "')");
|
|
2770
|
+
}
|
|
2771
|
+
// RFC 6591 §2 / RFC 5965 §2 — report-type=feedback-report. Tolerate an
|
|
2772
|
+
// omitted report-type (shipping reporters sometimes drop it); refuse a
|
|
2773
|
+
// mismatched value.
|
|
2774
|
+
if (ct.params["report-type"] && ct.params["report-type"].toLowerCase() !== "feedback-report") {
|
|
2775
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2776
|
+
"dmarc.parseForensicReport: report-type must be feedback-report (got '" +
|
|
2777
|
+
ct.params["report-type"] + "')");
|
|
2778
|
+
}
|
|
2779
|
+
if (!ct.params.boundary) {
|
|
2780
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2781
|
+
"dmarc.parseForensicReport: multipart/report Content-Type lacks boundary parameter");
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// ---- Walk the parts; find message/feedback-report + reported msg ----
|
|
2785
|
+
var parts = mimeParse.splitMimeParts(top.body, ct.params.boundary);
|
|
2786
|
+
if (parts.length === 0) {
|
|
2787
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2788
|
+
"dmarc.parseForensicReport: multipart/report body contains no parts");
|
|
2789
|
+
}
|
|
2790
|
+
if (parts.length > DMARC_RUF_MAX_PARTS) {
|
|
2791
|
+
return _rufError("mail-auth/dmarc-ruf-too-many-parts",
|
|
2792
|
+
"dmarc.parseForensicReport: report has " + parts.length + " parts (cap " +
|
|
2793
|
+
DMARC_RUF_MAX_PARTS + ")");
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
var feedbackPart = null;
|
|
2797
|
+
var reportedPart = null;
|
|
2798
|
+
for (var pi = 0; pi < parts.length; pi += 1) {
|
|
2799
|
+
var split;
|
|
2800
|
+
try { split = mimeParse.splitHeadersAndBody(parts[pi]); }
|
|
2801
|
+
catch (_e) { continue; }
|
|
2802
|
+
var partCt = mimeParse.parseContentType(
|
|
2803
|
+
mimeParse.findHeader(split.headers, "Content-Type") || "");
|
|
2804
|
+
if (partCt.type === "message/feedback-report" && !feedbackPart) {
|
|
2805
|
+
feedbackPart = split;
|
|
2806
|
+
} else if ((partCt.type === "message/rfc822" ||
|
|
2807
|
+
partCt.type === "text/rfc822-headers") && !reportedPart) {
|
|
2808
|
+
reportedPart = split;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
if (!feedbackPart) {
|
|
2812
|
+
return _rufError("mail-auth/dmarc-ruf-no-feedback-report",
|
|
2813
|
+
"dmarc.parseForensicReport: missing message/feedback-report subpart (RFC 6591 §3)");
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// ---- Parse the feedback-report header block (RFC 6591 §3.1) ----
|
|
2817
|
+
// Field names are stored own-key on a null-prototype map so a hostile
|
|
2818
|
+
// field named __proto__ / constructor can't pollute the prototype.
|
|
2819
|
+
var fields;
|
|
2820
|
+
try { fields = mimeParse.parseHeaderBlock(feedbackPart.body); }
|
|
2821
|
+
catch (e) {
|
|
2822
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2823
|
+
"dmarc.parseForensicReport: feedback-report field parse failed: " + ((e && e.message) || String(e)));
|
|
2824
|
+
}
|
|
2825
|
+
var fieldMap = Object.create(null);
|
|
2826
|
+
var rcptToList = [];
|
|
2827
|
+
for (var fi = 0; fi < fields.length; fi += 1) {
|
|
2828
|
+
var f = fields[fi];
|
|
2829
|
+
if (!f || typeof f.name !== "string") continue;
|
|
2830
|
+
var lc = f.name.toLowerCase();
|
|
2831
|
+
var val = typeof f.value === "string" ? f.value : "";
|
|
2832
|
+
fieldMap[lc] = val;
|
|
2833
|
+
if (lc === "original-rcpt-to") rcptToList.push(val);
|
|
2834
|
+
}
|
|
2835
|
+
function _field(name) {
|
|
2836
|
+
return Object.prototype.hasOwnProperty.call(fieldMap, name) ? fieldMap[name] : null;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// ---- Required fields (RFC 6591 §3.1 / RFC 7489 §7.3) ----
|
|
2840
|
+
for (var ri = 0; ri < DMARC_RUF_REQUIRED_FIELDS.length; ri += 1) {
|
|
2841
|
+
var req = DMARC_RUF_REQUIRED_FIELDS[ri];
|
|
2842
|
+
var rv = _field(req);
|
|
2843
|
+
if (typeof rv !== "string" || rv.length === 0) {
|
|
2844
|
+
if (req === "auth-failure") {
|
|
2845
|
+
return _rufError("mail-auth/dmarc-ruf-missing-auth-failure",
|
|
2846
|
+
"dmarc.parseForensicReport: required field 'Auth-Failure' is missing (RFC 6591 §3.1)");
|
|
2847
|
+
}
|
|
2848
|
+
return _rufError("mail-auth/dmarc-ruf-missing-field",
|
|
2849
|
+
"dmarc.parseForensicReport: required field '" + req + "' is missing (RFC 6591 §3.1)");
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// RFC 7489 §7.3 — a DMARC forensic report carries Feedback-Type:
|
|
2854
|
+
// auth-failure (the AFRF profile of RFC 6591). A report whose Feedback-
|
|
2855
|
+
// Type is another ARF class (e.g. plain "abuse") is a valid feedback
|
|
2856
|
+
// report but NOT a DMARC forensic report; surface the mismatch rather
|
|
2857
|
+
// than mislabeling it. Field values are case-insensitive tokens.
|
|
2858
|
+
var feedbackType = String(_field("feedback-type")).toLowerCase();
|
|
2859
|
+
if (feedbackType !== "auth-failure") {
|
|
2860
|
+
return _rufError("mail-auth/dmarc-ruf-not-auth-failure",
|
|
2861
|
+
"dmarc.parseForensicReport: Feedback-Type must be 'auth-failure' for a " +
|
|
2862
|
+
"DMARC forensic report (RFC 7489 §7.3 / RFC 6591), got " +
|
|
2863
|
+
JSON.stringify(_field("feedback-type")));
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// ---- RFC 6591 §3.2 reported message ----
|
|
2867
|
+
var reportedMessage = null;
|
|
2868
|
+
if (reportedPart) {
|
|
2869
|
+
reportedMessage = (reportedPart.body && reportedPart.body.length > 0)
|
|
2870
|
+
? reportedPart.body
|
|
2871
|
+
: _reassembleRufPart(reportedPart);
|
|
2872
|
+
}
|
|
2873
|
+
var reported = _parseReportedHeaders(reportedMessage);
|
|
2874
|
+
|
|
2875
|
+
// ---- Normalize Arrival-Date / Incidents (RFC 5965 §3.1) ----
|
|
2876
|
+
var arrivalRaw = _field("arrival-date") || _field("received-date") || null;
|
|
2877
|
+
var arrivalIso = null;
|
|
2878
|
+
if (arrivalRaw) {
|
|
2879
|
+
var d = new Date(arrivalRaw);
|
|
2880
|
+
if (!isNaN(d.getTime())) arrivalIso = d.toISOString();
|
|
2881
|
+
}
|
|
2882
|
+
var incidentsRaw = _field("incidents");
|
|
2883
|
+
var incidents = null;
|
|
2884
|
+
if (typeof incidentsRaw === "string") {
|
|
2885
|
+
var inc = parseInt(incidentsRaw, 10);
|
|
2886
|
+
if (isFinite(inc) && inc >= 0) incidents = inc;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// Surface unmodeled fields under extraFields for operator visibility
|
|
2890
|
+
// (vendor X-* tags). Own-key copy off the null-prototype fieldMap onto
|
|
2891
|
+
// a null-prototype target so a field named __proto__ / constructor in a
|
|
2892
|
+
// hostile report is stored as data, not as a prototype mutation.
|
|
2893
|
+
var KNOWN = Object.create(null);
|
|
2894
|
+
["feedback-type", "user-agent", "version", "auth-failure",
|
|
2895
|
+
"delivery-result", "identity-alignment", "dkim-domain",
|
|
2896
|
+
"dkim-identity", "dkim-selector", "dkim-canonicalized-header",
|
|
2897
|
+
"dkim-canonicalized-body", "spf-dns", "original-mail-from",
|
|
2898
|
+
"original-rcpt-to", "arrival-date", "received-date",
|
|
2899
|
+
"reported-domain", "source-ip", "authentication-results",
|
|
2900
|
+
"reported-uri", "incidents", "original-envelope-id"
|
|
2901
|
+
].forEach(function (k) { KNOWN[k] = 1; });
|
|
2902
|
+
var extraFields = Object.create(null);
|
|
2903
|
+
Object.keys(fieldMap).forEach(function (k) {
|
|
2904
|
+
if (!Object.prototype.hasOwnProperty.call(KNOWN, k)) extraFields[k] = fieldMap[k];
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
var report = {
|
|
2908
|
+
// ---- RFC 5965 base fields ----
|
|
2909
|
+
feedbackType: _field("feedback-type"),
|
|
2910
|
+
userAgent: _field("user-agent"),
|
|
2911
|
+
version: _field("version") || "1",
|
|
2912
|
+
arrivalDate: arrivalIso || arrivalRaw,
|
|
2913
|
+
reportedDomain: _field("reported-domain"),
|
|
2914
|
+
sourceIp: _field("source-ip"),
|
|
2915
|
+
originalFrom: _field("original-mail-from"),
|
|
2916
|
+
originalRcptTo: rcptToList,
|
|
2917
|
+
originalEnvelopeId: _field("original-envelope-id"),
|
|
2918
|
+
authenticationResults: _field("authentication-results"),
|
|
2919
|
+
incidents: incidents,
|
|
2920
|
+
reportedUri: _field("reported-uri"),
|
|
2921
|
+
|
|
2922
|
+
// ---- RFC 6591 §3.1 / RFC 7489 §7.3 forensic-specific fields ----
|
|
2923
|
+
authFailure: _field("auth-failure"), // RFC 6591 §3.1 — "dkim" | "spf" | "dmarc" | "bodyhash" | …
|
|
2924
|
+
deliveryResult: _field("delivery-result"), // RFC 6591 §3.1 — "delivered" | "spam" | "policy" | "reject" | "other"
|
|
2925
|
+
identityAlignment: _field("identity-alignment"), // RFC 7489 §7.3 — "none" | "spf" | "dkim" | "dkim spf"
|
|
2926
|
+
dkim: {
|
|
2927
|
+
domain: _field("dkim-domain"), // RFC 6591 §3.1
|
|
2928
|
+
identity: _field("dkim-identity"),
|
|
2929
|
+
selector: _field("dkim-selector"),
|
|
2930
|
+
canonicalizedHeader: _field("dkim-canonicalized-header"),
|
|
2931
|
+
canonicalizedBody: _field("dkim-canonicalized-body"),
|
|
2932
|
+
},
|
|
2933
|
+
spf: {
|
|
2934
|
+
dns: _field("spf-dns"), // RFC 6591 §3.1 — the SPF DNS record at evaluation time
|
|
2935
|
+
},
|
|
2936
|
+
|
|
2937
|
+
// ---- RFC 6591 §3.2 reported message ----
|
|
2938
|
+
reportedMessage: reportedMessage,
|
|
2939
|
+
reportedHeaders: reported.headers, // [ { name, value }, … ] — order preserved
|
|
2940
|
+
reportedHeaderMap: reported.map, // null-prototype lower-cased-name → value
|
|
2941
|
+
reportedHeadersTruncated: reported.truncated, // true when the §3.2 header cap clipped the list
|
|
2942
|
+
|
|
2943
|
+
// ---- operator-visible passthrough for unmodeled fields ----
|
|
2944
|
+
extraFields: extraFields,
|
|
2945
|
+
};
|
|
2946
|
+
|
|
2947
|
+
return { ok: true, report: report };
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2618
2950
|
module.exports = {
|
|
2619
2951
|
spf: Object.freeze({
|
|
2620
2952
|
verify: spfVerify,
|
|
@@ -2625,6 +2957,7 @@ module.exports = {
|
|
|
2625
2957
|
parseRecord: _parseDmarcRecord,
|
|
2626
2958
|
parseAggregateReport: dmarcParseAggregateReport,
|
|
2627
2959
|
buildAggregateReport: dmarcBuildAggregateReport,
|
|
2960
|
+
parseForensicReport: dmarcParseForensicReport,
|
|
2628
2961
|
}),
|
|
2629
2962
|
arc: Object.freeze({
|
|
2630
2963
|
verify: arcVerify,
|
|
@@ -43,6 +43,52 @@ var observability = lazyRequire(function () { return require("../observability")
|
|
|
43
43
|
|
|
44
44
|
var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
|
|
45
45
|
|
|
46
|
+
// Sec-Fetch-Dest request-destination vocabulary (Fetch Standard §3.2.6
|
|
47
|
+
// "destination", https://fetch.spec.whatwg.org/#concept-request-destination;
|
|
48
|
+
// surfaced as the Sec-Fetch-Dest header by the Fetch Metadata Request
|
|
49
|
+
// Headers spec, https://www.w3.org/TR/fetch-metadata/#sec-fetch-dest-header).
|
|
50
|
+
// "webidentity" (FedCM credentialed-request destination,
|
|
51
|
+
// https://w3c.github.io/FedCM/) is included so an operator can recognize
|
|
52
|
+
// and gate FedCM traffic first-class — a webidentity Sec-Fetch-Dest on a
|
|
53
|
+
// route that is not a FedCM identity endpoint is a request worth refusing.
|
|
54
|
+
var KNOWN_DESTINATIONS = Object.freeze([
|
|
55
|
+
"audio", "audioworklet", "document", "embed", "empty", "fencedframe",
|
|
56
|
+
"font", "frame", "iframe", "image", "json", "manifest", "object",
|
|
57
|
+
"paintworklet", "report", "script", "serviceworker", "sharedworker",
|
|
58
|
+
"style", "track", "video", "webidentity", "worker", "xslt",
|
|
59
|
+
]);
|
|
60
|
+
var KNOWN_DEST_SET = Object.create(null);
|
|
61
|
+
(function () {
|
|
62
|
+
for (var i = 0; i < KNOWN_DESTINATIONS.length; i += 1) {
|
|
63
|
+
KNOWN_DEST_SET[KNOWN_DESTINATIONS[i]] = true;
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
// Sec-Fetch-Storage-Access status values (Storage Access API,
|
|
68
|
+
// https://privacycg.github.io/storage-access-headers/ — the header is
|
|
69
|
+
// distinct from Sec-Fetch-Dest). The browser sends this only on cross-site
|
|
70
|
+
// credentialed requests. "active" / "inactive" both indicate the embedded
|
|
71
|
+
// context can (active) or could (inactive, permission granted but not yet
|
|
72
|
+
// exercised) reach unpartitioned cross-site cookies; "none" carries no
|
|
73
|
+
// such capability. A route that does not participate in the Storage Access
|
|
74
|
+
// flow may refuse the active/inactive escalation.
|
|
75
|
+
var STORAGE_ACCESS_ESCALATED = Object.freeze({ active: true, inactive: true });
|
|
76
|
+
|
|
77
|
+
function _validateDestList(list, label) {
|
|
78
|
+
// Config-time tier — an unknown Sec-Fetch-Dest value in a strict
|
|
79
|
+
// allow/deny list is almost always an operator typo (e.g. "web-identity"
|
|
80
|
+
// for "webidentity"). Throw at boot per the config/entry-point tier so
|
|
81
|
+
// the typo surfaces before it silently fails to match at request time.
|
|
82
|
+
if (!Array.isArray(list)) return;
|
|
83
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
84
|
+
if (!KNOWN_DEST_SET[list[i]]) {
|
|
85
|
+
throw new Error("middleware.fetchMetadata: " + label + "[" + i +
|
|
86
|
+
"] is not a known Sec-Fetch-Dest value (got '" + String(list[i]) +
|
|
87
|
+
"'). Known destinations: " + KNOWN_DESTINATIONS.join(", ") + ".");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
46
92
|
function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
47
93
|
denyResponse(req, res, {
|
|
48
94
|
onDeny: onDeny,
|
|
@@ -75,17 +121,30 @@ function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
|
75
121
|
* the value-add; non-browser callers carry their own auth threat
|
|
76
122
|
* model.
|
|
77
123
|
*
|
|
124
|
+
* The Sec-Fetch-Dest vocabulary tracks the Fetch Standard request-
|
|
125
|
+
* destination list, including `webidentity` (FedCM credentialed
|
|
126
|
+
* requests). `deniedDest` refuses chosen destinations outright on the
|
|
127
|
+
* gated methods — a FedCM `webidentity` Sec-Fetch-Dest hitting a route
|
|
128
|
+
* that is not an identity endpoint is refused. `allowStorageAccess:
|
|
129
|
+
* false` refuses the Storage Access API escalation (a cross-site request
|
|
130
|
+
* carrying `Sec-Fetch-Storage-Access: active` / `inactive`) on routes
|
|
131
|
+
* that do not participate in the Storage Access flow. Both are opt-in;
|
|
132
|
+
* leaving them unset preserves the prior behavior exactly.
|
|
133
|
+
*
|
|
78
134
|
* @opts
|
|
79
135
|
* {
|
|
80
|
-
* allowSameSite:
|
|
81
|
-
* allowCrossSite:
|
|
82
|
-
* allowMissing:
|
|
83
|
-
* allowedDest:
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
136
|
+
* allowSameSite: boolean, // default true
|
|
137
|
+
* allowCrossSite: boolean, // default false
|
|
138
|
+
* allowMissing: boolean, // default true
|
|
139
|
+
* allowedDest: string[], // cross-site allowlist of Sec-Fetch-Dest values
|
|
140
|
+
* deniedDest: string[], // Sec-Fetch-Dest values refused on gated methods regardless of site (e.g. ["webidentity"])
|
|
141
|
+
* allowStorageAccess: boolean, // default true — false refuses Sec-Fetch-Storage-Access: active|inactive
|
|
142
|
+
* strictDest: boolean, // default false — true throws at config time on an allowedDest/deniedDest value outside the known Sec-Fetch-Dest vocabulary
|
|
143
|
+
* allowedNavigate: boolean, // default true
|
|
144
|
+
* methods: string[], // default POST/PUT/DELETE/PATCH
|
|
145
|
+
* audit: boolean, // default true
|
|
146
|
+
* onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
|
|
147
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
89
148
|
* }
|
|
90
149
|
*
|
|
91
150
|
* @example
|
|
@@ -102,15 +161,34 @@ function create(opts) {
|
|
|
102
161
|
opts = opts || {};
|
|
103
162
|
validateOpts(opts, [
|
|
104
163
|
"allowSameSite", "allowCrossSite", "allowMissing",
|
|
105
|
-
"allowedDest", "
|
|
164
|
+
"allowedDest", "deniedDest", "allowStorageAccess", "strictDest",
|
|
165
|
+
"allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
|
|
106
166
|
], "middleware.fetchMetadata");
|
|
167
|
+
validateOpts.optionalBoolean(opts.allowStorageAccess, "middleware.fetchMetadata: allowStorageAccess");
|
|
168
|
+
validateOpts.optionalBoolean(opts.strictDest, "middleware.fetchMetadata: strictDest");
|
|
169
|
+
validateOpts.optionalNonEmptyStringArray(opts.deniedDest, "middleware.fetchMetadata: deniedDest");
|
|
170
|
+
if (opts.strictDest === true) {
|
|
171
|
+
_validateDestList(opts.allowedDest, "allowedDest");
|
|
172
|
+
_validateDestList(opts.deniedDest, "deniedDest");
|
|
173
|
+
}
|
|
107
174
|
|
|
108
175
|
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
109
176
|
var problemMode = opts.problemDetails === true;
|
|
110
|
-
var allowSameSite
|
|
111
|
-
var allowCrossSite
|
|
112
|
-
var allowMissing
|
|
113
|
-
var allowedDest
|
|
177
|
+
var allowSameSite = opts.allowSameSite !== false;
|
|
178
|
+
var allowCrossSite = opts.allowCrossSite === true;
|
|
179
|
+
var allowMissing = opts.allowMissing !== false;
|
|
180
|
+
var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
|
|
181
|
+
var allowStorageAccess = opts.allowStorageAccess !== false;
|
|
182
|
+
// deniedDest → a null-prototype membership map; an operator-supplied
|
|
183
|
+
// destination string is never assigned onto a plain object, so no
|
|
184
|
+
// reserved name (__proto__ / constructor / prototype) can pollute it.
|
|
185
|
+
var deniedDest = null;
|
|
186
|
+
if (Array.isArray(opts.deniedDest) && opts.deniedDest.length > 0) {
|
|
187
|
+
deniedDest = Object.create(null);
|
|
188
|
+
for (var di = 0; di < opts.deniedDest.length; di += 1) {
|
|
189
|
+
deniedDest[opts.deniedDest[di]] = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
114
192
|
var allowedNavigate = opts.allowedNavigate !== false;
|
|
115
193
|
var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
|
|
116
194
|
var auditOn = opts.audit !== false;
|
|
@@ -139,6 +217,16 @@ function create(opts) {
|
|
|
139
217
|
var mode = headers["sec-fetch-mode"];
|
|
140
218
|
var dest = headers["sec-fetch-dest"];
|
|
141
219
|
|
|
220
|
+
// Destination refusal — independent of site. A FedCM `webidentity`
|
|
221
|
+
// (or any operator-denied) Sec-Fetch-Dest on a route that is not an
|
|
222
|
+
// identity endpoint is refused outright. The membership test is exact
|
|
223
|
+
// (null-prototype map keyed on the verbatim header value), never a
|
|
224
|
+
// substring scan.
|
|
225
|
+
if (deniedDest && typeof dest === "string" && deniedDest[dest] === true) {
|
|
226
|
+
_emitDenied(req, "dest-denied (dest=" + dest + ")");
|
|
227
|
+
return _writeReject(req, res, "Request destination not allowed for this route.", "dest-not-allowed", onDeny, problemMode);
|
|
228
|
+
}
|
|
229
|
+
|
|
142
230
|
if (typeof site !== "string" || site.length === 0) {
|
|
143
231
|
// No Sec-Fetch-Site header — legacy browser or non-browser client.
|
|
144
232
|
// Defer to other auth/CSRF layers per allowMissing.
|
|
@@ -165,6 +253,19 @@ function create(opts) {
|
|
|
165
253
|
}
|
|
166
254
|
|
|
167
255
|
// cross-site
|
|
256
|
+
// Storage Access API escalation — the browser sends
|
|
257
|
+
// Sec-Fetch-Storage-Access only on cross-site credentialed requests.
|
|
258
|
+
// active|inactive both mean the embedded context can / could reach
|
|
259
|
+
// unpartitioned cross-site cookies; refuse it on routes that do not
|
|
260
|
+
// participate in the Storage Access flow. Exact membership, never a
|
|
261
|
+
// substring scan. Checked before the allowCrossSite shortcut so the
|
|
262
|
+
// escalation is gated even when cross-site is otherwise permitted.
|
|
263
|
+
var storageAccess = headers["sec-fetch-storage-access"];
|
|
264
|
+
if (!allowStorageAccess && typeof storageAccess === "string" &&
|
|
265
|
+
STORAGE_ACCESS_ESCALATED[storageAccess] === true) {
|
|
266
|
+
_emitDenied(req, "storage-access-refused (status=" + storageAccess + ")");
|
|
267
|
+
return _writeReject(req, res, "Storage Access escalation not allowed for this route.", "storage-access-refused", onDeny, problemMode);
|
|
268
|
+
}
|
|
168
269
|
if (allowCrossSite) return next();
|
|
169
270
|
if (allowedDest && typeof dest === "string" && allowedDest.indexOf(dest) !== -1) {
|
|
170
271
|
return next();
|
|
@@ -34,6 +34,18 @@
|
|
|
34
34
|
* dnsPrefetchControl: 'off' (default) or 'on' or false
|
|
35
35
|
* csp: '<full CSP string>' or false to disable
|
|
36
36
|
* }
|
|
37
|
+
*
|
|
38
|
+
* Monitor-mode opt-ins (all default-off; unset emits no new header):
|
|
39
|
+
*
|
|
40
|
+
* coopReportOnly / coepReportOnly / documentPolicyReportOnly — set a
|
|
41
|
+
* policy string to emit the matching `*-Report-Only` header so the
|
|
42
|
+
* operator can roll out the enforcing policy in monitor mode first.
|
|
43
|
+
* The browser reports violations (to a Reporting-Endpoints group named
|
|
44
|
+
* in the value, e.g. `same-origin; report-to="coop"`) without blocking.
|
|
45
|
+
* requireDocumentPolicy — the embedder-required Document-Policy a
|
|
46
|
+
* subframe must advertise before this document will embed it.
|
|
47
|
+
* serviceWorkerAllowed — broadens the max scope a service worker
|
|
48
|
+
* registered from this script may claim (the operator opts in).
|
|
37
49
|
*/
|
|
38
50
|
|
|
39
51
|
var requestHelpers = require("../request-helpers");
|
|
@@ -186,6 +198,11 @@ function _validatePermissionsPolicy(value) {
|
|
|
186
198
|
* criticalCh: string|false,
|
|
187
199
|
* reportingEndpoints: object,
|
|
188
200
|
* trustProxy: boolean|number,
|
|
201
|
+
* coopReportOnly: string, // default: off — monitor-mode COOP
|
|
202
|
+
* coepReportOnly: string, // default: off — monitor-mode COEP
|
|
203
|
+
* documentPolicyReportOnly: string, // default: off — monitor-mode Document-Policy
|
|
204
|
+
* requireDocumentPolicy: string, // default: off — embedder-required subframe policy
|
|
205
|
+
* serviceWorkerAllowed: string, // default: off — broadens SW registration scope
|
|
189
206
|
* }
|
|
190
207
|
*
|
|
191
208
|
* @example
|
|
@@ -202,6 +219,8 @@ function create(opts) {
|
|
|
202
219
|
"permissionsPolicy", "coop", "coep", "corp",
|
|
203
220
|
"originAgentCluster", "dnsPrefetchControl", "csp", "trustProxy",
|
|
204
221
|
"reportingEndpoints", "documentPolicy", "criticalCh", "acceptCh",
|
|
222
|
+
"coopReportOnly", "coepReportOnly", "documentPolicyReportOnly",
|
|
223
|
+
"requireDocumentPolicy", "serviceWorkerAllowed",
|
|
205
224
|
], "middleware.securityHeaders");
|
|
206
225
|
if (opts.permissionsPolicy && typeof opts.permissionsPolicy === "string") {
|
|
207
226
|
_validatePermissionsPolicy(opts.permissionsPolicy);
|
|
@@ -222,6 +241,26 @@ function create(opts) {
|
|
|
222
241
|
var docPolicy = opts.documentPolicy === undefined ? DEFAULT_DOCUMENT_POLICY : opts.documentPolicy;
|
|
223
242
|
var criticalCh = opts.criticalCh && typeof opts.criticalCh === "string" ? opts.criticalCh : false;
|
|
224
243
|
var acceptCh = opts.acceptCh && typeof opts.acceptCh === "string" ? opts.acceptCh : false;
|
|
244
|
+
// Monitor-mode + scope opt-ins — all default-off. Each only emits its
|
|
245
|
+
// header when the operator passes a non-empty string; unset = silent.
|
|
246
|
+
// coopReportOnly / coepReportOnly — WHATWG HTML cross-origin isolation
|
|
247
|
+
// report-only variants: the UA evaluates the policy and reports
|
|
248
|
+
// violations to the named Reporting-Endpoints group without
|
|
249
|
+
// enforcing, so an operator can verify a same-origin / require-corp
|
|
250
|
+
// rollout won't break embeds before flipping the enforcing header.
|
|
251
|
+
// documentPolicyReportOnly — W3C Document Policy report-only variant
|
|
252
|
+
// (same monitor-mode semantics for the Document-Policy feature set).
|
|
253
|
+
// requireDocumentPolicy — W3C Document Policy: the policy a subframe
|
|
254
|
+
// must itself advertise (via Document-Policy) before this document
|
|
255
|
+
// will embed it; the embedder declares its floor.
|
|
256
|
+
// serviceWorkerAllowed — W3C Service Workers §Service-Worker-Allowed:
|
|
257
|
+
// widens the max scope a worker registered from this script may
|
|
258
|
+
// claim beyond the script's own path. Operator opts in explicitly.
|
|
259
|
+
var coopReportOnly = opts.coopReportOnly && typeof opts.coopReportOnly === "string" ? opts.coopReportOnly : false;
|
|
260
|
+
var coepReportOnly = opts.coepReportOnly && typeof opts.coepReportOnly === "string" ? opts.coepReportOnly : false;
|
|
261
|
+
var docPolicyReportOnly = opts.documentPolicyReportOnly && typeof opts.documentPolicyReportOnly === "string" ? opts.documentPolicyReportOnly : false;
|
|
262
|
+
var requireDocPolicy = opts.requireDocumentPolicy && typeof opts.requireDocumentPolicy === "string" ? opts.requireDocumentPolicy : false;
|
|
263
|
+
var serviceWorkerAllowed = opts.serviceWorkerAllowed && typeof opts.serviceWorkerAllowed === "string" ? opts.serviceWorkerAllowed : false;
|
|
225
264
|
// Reporting-Endpoints (W3C Reporting API) — when operator passes a
|
|
226
265
|
// map of endpoint-name → URL, we emit `Reporting-Endpoints: name="url",
|
|
227
266
|
// name2="url2", ...` and (when default CSP is in force) append
|
|
@@ -273,6 +312,14 @@ function create(opts) {
|
|
|
273
312
|
if (acceptCh) res.setHeader("Accept-CH", acceptCh);
|
|
274
313
|
if (criticalCh) res.setHeader("Critical-CH", criticalCh);
|
|
275
314
|
if (reportingEndpoints) res.setHeader("Reporting-Endpoints", reportingEndpoints);
|
|
315
|
+
// Monitor-mode + scope opt-ins — emitted only when the operator set
|
|
316
|
+
// the corresponding opt; the enforcing COOP/COEP/Document-Policy
|
|
317
|
+
// headers above are unaffected.
|
|
318
|
+
if (coopReportOnly) res.setHeader("Cross-Origin-Opener-Policy-Report-Only", coopReportOnly);
|
|
319
|
+
if (coepReportOnly) res.setHeader("Cross-Origin-Embedder-Policy-Report-Only", coepReportOnly);
|
|
320
|
+
if (docPolicyReportOnly) res.setHeader("Document-Policy-Report-Only", docPolicyReportOnly);
|
|
321
|
+
if (requireDocPolicy) res.setHeader("Require-Document-Policy", requireDocPolicy);
|
|
322
|
+
if (serviceWorkerAllowed) res.setHeader("Service-Worker-Allowed", serviceWorkerAllowed);
|
|
276
323
|
next();
|
|
277
324
|
};
|
|
278
325
|
}
|