@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/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: boolean, // default true
81
- * allowCrossSite: boolean, // default false
82
- * allowMissing: boolean, // default true
83
- * allowedDest: string[],
84
- * allowedNavigate: boolean, // default true
85
- * methods: string[], // default POST/PUT/DELETE/PATCH
86
- * audit: boolean, // default true
87
- * onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
88
- * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
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", "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
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 = opts.allowSameSite !== false;
111
- var allowCrossSite = opts.allowCrossSite === true;
112
- var allowMissing = opts.allowMissing !== false;
113
- var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
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
  }