@blamejs/core 0.7.64 → 0.7.74

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.js CHANGED
@@ -71,7 +71,9 @@ var httpClient = lazyRequire(function () { return require("./http-client"); });
71
71
  var guardEmail = lazyRequire(function () { return require("./guard-email"); });
72
72
  var mailDkim = require("./mail-dkim");
73
73
  var mailAuth = require("./mail-auth");
74
+ var mailUnsubscribe = require("./mail-unsubscribe");
74
75
  var net = lazyRequire(function () { return require("net"); });
76
+ var nodeUrl = require("url");
75
77
  var tls = lazyRequire(function () { return require("tls"); });
76
78
  var safeJson = require("./safe-json");
77
79
  var safeSchema = require("./safe-schema");
@@ -100,9 +102,86 @@ var EMAIL_RE = safeSchema.EMAIL_RE;
100
102
  // test so a megabyte-long input can't exhaust the regex engine.
101
103
  var EMAIL_MAX_LEN = 254;
102
104
 
105
+ // EAI / SMTPUTF8 — RFC 6531/6532/6533 internationalized email. Detect
106
+ // non-ASCII content; convert IDN domains to Punycode (RFC 3492) for the
107
+ // ASCII path, leave them in Unicode where the peer announces SMTPUTF8.
108
+
109
+ // Match any code point outside ASCII (U+0080 and above). Used to
110
+ // detect EAI / SMTPUTF8-required content in an address or subject.
111
+ // eslint-disable-next-line no-control-regex
112
+ var NON_ASCII_RE = /[^\x00-\x7f]/;
113
+
114
+ function _isAscii(s) {
115
+ if (typeof s !== "string" || s.length > EMAIL_MAX_LEN) return false; // bound BEFORE regex test
116
+ return !NON_ASCII_RE.test(s);
117
+ }
118
+
119
+ // IDN domain encode — domain MUST be the part after '@'. Returns the
120
+ // Punycode-encoded ASCII domain, OR null if the input isn't a valid
121
+ // IDN-encodable domain.
122
+ function toAscii(domain) {
123
+ if (typeof domain !== "string" || domain.length === 0) return null;
124
+ var ascii;
125
+ try { ascii = nodeUrl.domainToASCII(domain); }
126
+ catch (_e) { return null; }
127
+ if (typeof ascii !== "string" || ascii.length === 0) return null;
128
+ return ascii;
129
+ }
130
+
131
+ function toUnicode(domain) {
132
+ if (typeof domain !== "string" || domain.length === 0) return null;
133
+ try { return nodeUrl.domainToUnicode(domain); }
134
+ catch (_e) { return null; }
135
+ }
136
+
103
137
  function _isValidEmail(addr) {
104
- return typeof addr === "string" && addr.length > 0 &&
105
- addr.length <= EMAIL_MAX_LEN && EMAIL_RE.test(addr);
138
+ if (typeof addr !== "string" || addr.length === 0 || addr.length > EMAIL_MAX_LEN) {
139
+ return false;
140
+ }
141
+ // Pure ASCII — fast path through the existing regex (length bounded above).
142
+ if (_isAscii(addr)) return EMAIL_RE.test(addr); // bound: addr.length <= EMAIL_MAX_LEN
143
+ // EAI path — split at last '@', convert domain to Punycode, then test
144
+ // ASCII-only the assembled local@ascii-domain. The local part can be
145
+ // Unicode under RFC 6531 §3.3 — we accept it without further regex
146
+ // gating beyond the existing CRLF/NUL refusals upstream.
147
+ var atIdx = addr.lastIndexOf("@");
148
+ if (atIdx <= 0 || atIdx === addr.length - 1) return false;
149
+ var local = addr.slice(0, atIdx);
150
+ var domain = addr.slice(atIdx + 1);
151
+ var ascii = toAscii(domain);
152
+ if (!ascii) return false;
153
+ // Re-test the ASCII-converted domain against the existing email regex
154
+ // to refuse junk like "..invalid" that domainToASCII rubber-stamps
155
+ // (Node's WHATWG-URL implementation is permissive on dotted-empty
156
+ // labels). Substitute a placeholder local part so the regex sees an
157
+ // ASCII-only shape; the actual local part may legitimately be Unicode
158
+ // under RFC 6531 §3.3 and is enforced separately below.
159
+ if (ascii.length > EMAIL_MAX_LEN - 2) return false; // bound BEFORE regex test
160
+ if (!EMAIL_RE.test("x@" + ascii)) return false;
161
+ // Local part must not contain CRLF / NUL (header injection / SMTP
162
+ // smuggling). Other Unicode is fine per RFC 6531.
163
+ if (/[\r\n\0]/.test(local)) return false;
164
+ // Length cap also applies to the ASCII-equivalent so a long IDN
165
+ // domain that punycodes to >254 ASCII bytes is refused.
166
+ if ((local.length + 1 + ascii.length) > EMAIL_MAX_LEN) return false;
167
+ return true;
168
+ }
169
+
170
+ // Does this message require SMTPUTF8 on the wire? RFC 6531 §3.2 — true
171
+ // when any of from / to / cc / bcc / subject / mailbox-display-name
172
+ // contains non-ASCII octets.
173
+ function _messageRequiresSmtpUtf8(message) {
174
+ if (!message) return false;
175
+ if (!_isAscii(String(message.from || ""))) return true;
176
+ if (!_isAscii(String(message.subject || ""))) return true;
177
+ var lists = [message.to, message.cc, message.bcc];
178
+ for (var li = 0; li < lists.length; li += 1) {
179
+ var arr = Array.isArray(lists[li]) ? lists[li] : (lists[li] ? [lists[li]] : []);
180
+ for (var i = 0; i < arr.length; i += 1) {
181
+ if (!_isAscii(String(arr[i]))) return true;
182
+ }
183
+ }
184
+ return false;
106
185
  }
107
186
 
108
187
  function _normalizeRecipientList(value, label) {
@@ -511,11 +590,21 @@ var SMTP_STEP_DATA = 0x7;
511
590
  var SMTP_STEP_BODY = 0x8;
512
591
  var SMTP_STEP_STARTTLS = 0xA;
513
592
 
593
+ function _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8) {
594
+ // RFC 6531 §3.4 — when SMTPUTF8 is advertised by the peer AND the
595
+ // message requires it, append " SMTPUTF8" to MAIL FROM to opt this
596
+ // transaction into the EAI extension. Pure-ASCII messages don't add
597
+ // it (some peers reject the keyword on legacy mailboxes).
598
+ return (requiresSmtpUtf8 && peerSupportsSmtpUtf8) ? " SMTPUTF8" : "";
599
+ }
600
+
514
601
  function _smtpSend(message, cfg) {
515
602
  return new Promise(function (resolve, reject) {
516
603
  var socket;
517
604
  var step = SMTP_STEP_GREETING;
518
605
  var buffer = "";
606
+ var ehloLines = []; // RFC 5321 §4.1.1.1 — EHLO extension lines
607
+ var peerSupportsSmtpUtf8 = false; // RFC 6531 — set from EHLO response
519
608
  var upgradedToTLS = false;
520
609
  var settled = false;
521
610
  var rcptIndex = 0;
@@ -525,6 +614,7 @@ function _smtpSend(message, cfg) {
525
614
  var ccList = _toArray(message.cc).map(_extractAddr);
526
615
  var bccList = _toArray(message.bcc).map(_extractAddr);
527
616
  var rcpts = toList.concat(ccList, bccList);
617
+ var requiresSmtpUtf8 = _messageRequiresSmtpUtf8(message);
528
618
  var dataMessage = _buildRfc822(message);
529
619
  if (cfg.dkimSigner) {
530
620
  try { dataMessage = cfg.dkimSigner.sign(dataMessage); }
@@ -582,6 +672,13 @@ function _smtpSend(message, cfg) {
582
672
  var line = lines[i];
583
673
  if (!line) continue;
584
674
  var code = parseInt(line.slice(0, 3), 10);
675
+ // EHLO continuation lines (250-X) carry extension names. We
676
+ // capture them so the dispatcher can branch on SMTPUTF8 / 8BITMIME
677
+ // / STARTTLS support before MAIL FROM is sent.
678
+ if (step === SMTP_STEP_EHLO_RESP) {
679
+ var keyword = line.slice(4).split(" ")[0].toUpperCase();
680
+ if (keyword) ehloLines.push(keyword);
681
+ }
585
682
  if (line[3] === "-") continue; // continuation line
586
683
  try { handleResponse(code); }
587
684
  catch (e) { fail(e.message || String(e)); return; }
@@ -615,9 +712,19 @@ function _smtpSend(message, cfg) {
615
712
  }
616
713
  else if (step === SMTP_STEP_EHLO_RESP) {
617
714
  if (code < 200 || code >= 300) { fail("ehlo-rejected (code " + code + ")"); return; }
715
+ // Snapshot extensions advertised on the wire for downstream use.
716
+ peerSupportsSmtpUtf8 = ehloLines.indexOf("SMTPUTF8") !== -1;
717
+ // RFC 6531 §3.2 — if the message requires SMTPUTF8 and the
718
+ // peer does not advertise it, refuse hard rather than emit a
719
+ // mangled wire (server might still accept but headers/local
720
+ // parts would silently corrupt downstream).
721
+ if (requiresSmtpUtf8 && !peerSupportsSmtpUtf8) {
722
+ fail("eai-required-not-supported: message has non-ASCII content but peer does not advertise SMTPUTF8");
723
+ return;
724
+ }
618
725
  if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step = SMTP_STEP_STARTTLS; }
619
726
  else if (cfg.user) { send("AUTH LOGIN"); step = SMTP_STEP_AUTH_USER; }
620
- else { send("MAIL FROM:<" + fromAddr + ">"); step = SMTP_STEP_MAIL_FROM; }
727
+ else { send("MAIL FROM:<" + fromAddr + ">" + _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8)); step = SMTP_STEP_MAIL_FROM; }
621
728
  }
622
729
  else if (step === SMTP_STEP_STARTTLS) {
623
730
  if (code !== 220) { fail("starttls-rejected (code " + code + ")"); return; }
@@ -644,7 +751,7 @@ function _smtpSend(message, cfg) {
644
751
  }
645
752
  else if (step === SMTP_STEP_AUTH_FINAL) {
646
753
  if (code !== 235) { fail("auth-failed (code " + code + ")"); return; }
647
- send("MAIL FROM:<" + fromAddr + ">"); step = SMTP_STEP_MAIL_FROM;
754
+ send("MAIL FROM:<" + fromAddr + ">" + _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8)); step = SMTP_STEP_MAIL_FROM;
648
755
  }
649
756
  else if (step === SMTP_STEP_MAIL_FROM) {
650
757
  if (code < 200 || code >= 300) { fail("mail-from-rejected (code " + code + ")"); return; }
@@ -921,6 +1028,16 @@ function create(opts) {
921
1028
 
922
1029
  async function send(message) {
923
1030
  var merged = _mergeMessage(defaults, message);
1031
+ // RFC 8058 / RFC 2369 List-Unsubscribe support: when the operator
1032
+ // passes `unsubscribe: { url, mailto, oneClick }`, expand into the
1033
+ // header pair and merge into the message headers. Lets bulk
1034
+ // senders comply with the Gmail / Yahoo / Microsoft bulk-sender
1035
+ // mandate without hand-rolling the header byte sequence.
1036
+ if (merged.unsubscribe && typeof merged.unsubscribe === "object") {
1037
+ var unsubHeaders = mailUnsubscribe.buildHeaders(merged.unsubscribe);
1038
+ merged.headers = Object.assign({}, merged.headers || {}, unsubHeaders);
1039
+ delete merged.unsubscribe;
1040
+ }
924
1041
  _validateMessage(merged);
925
1042
 
926
1043
  var t0 = Date.now();
@@ -963,8 +1080,16 @@ function create(opts) {
963
1080
  }
964
1081
 
965
1082
  module.exports = {
966
- create: create,
967
- MailError: MailError,
1083
+ create: create,
1084
+ MailError: MailError,
1085
+ unsubscribe: mailUnsubscribe,
1086
+ // RFC 3492 Punycode IDN domain encode/decode (b.mail.toAscii /
1087
+ // toUnicode). Wraps node:url.domainToASCII / domainToUnicode so
1088
+ // operators have one obvious place to reach for IDN handling. Used
1089
+ // internally by send() to convert IDN domain parts before the
1090
+ // pre-SMTPUTF8 ASCII regex check.
1091
+ toAscii: toAscii,
1092
+ toUnicode: toUnicode,
968
1093
  // DKIM-Signature header generation for outbound mail (rsa-sha256
969
1094
  // default, ed25519-sha256 opt-in). Wire it into the smtp transport
970
1095
  // via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
@@ -973,9 +1098,10 @@ module.exports = {
973
1098
  // DMARC (RFC 7489), ARC (RFC 8617). Outbound DKIM signing lives in
974
1099
  // .dkim above; per-hop DKIM verification is deferred (composes with
975
1100
  // the existing canonicalization helpers in lib/mail-dkim.js).
976
- spf: mailAuth.spf,
977
- dmarc: mailAuth.dmarc,
978
- arc: mailAuth.arc,
1101
+ spf: mailAuth.spf,
1102
+ dmarc: mailAuth.dmarc,
1103
+ arc: mailAuth.arc,
1104
+ authResults: mailAuth.authResults,
979
1105
  // Test-only export: lets unit tests inspect the wire format without
980
1106
  // standing up a TLS-capable SMTP fixture. Operators don't call this.
981
1107
  _buildRfc822ForTest: _buildRfc822,
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ /**
3
+ * dpop middleware — RFC 9449 Demonstrating Proof of Possession.
4
+ *
5
+ * Verifies the `DPoP` header on inbound requests, attaches the result
6
+ * to `req.dpop = { header, payload, jkt }` for downstream handlers, and
7
+ * rejects with 401 + `WWW-Authenticate: DPoP` on any failure.
8
+ *
9
+ * var dpop = b.middleware.dpop({
10
+ * replayStore: b.nonceStore.create({ backend: "memory" }),
11
+ * algorithms: ["ES256", "EdDSA", "ML-DSA-87"],
12
+ * iatWindowSec: 60,
13
+ * getAccessToken: function (req) {
14
+ * // optional — extract Bearer token to bind ath
15
+ * var h = req.headers.authorization || "";
16
+ * return h.toLowerCase().startsWith("bearer ") ? h.slice(7) : null;
17
+ * },
18
+ * getNonce: async function (req) {
19
+ * // optional — server-issued challenge (RFC 9449 §8); return null
20
+ * // to skip nonce enforcement
21
+ * return null;
22
+ * },
23
+ * audit: true,
24
+ * });
25
+ * router.use("/api", dpop);
26
+ *
27
+ * On success:
28
+ * - req.dpop = { header, payload, jkt }
29
+ * - downstream handlers can compare req.dpop.jkt to the cnf claim
30
+ * of the access token to enforce key-bound bearer semantics
31
+ *
32
+ * On failure:
33
+ * - 401 with WWW-Authenticate: DPoP error="invalid_dpop_proof",
34
+ * error_description="<reason>"
35
+ * - audit.bearer.failure event when audit: true (default)
36
+ */
37
+
38
+ var lazyRequire = require("../lazy-require");
39
+ var requestHelpers = require("../request-helpers");
40
+ var validateOpts = require("../validate-opts");
41
+ var { AuthError } = require("../framework-error");
42
+
43
+ var dpop = lazyRequire(function () { return require("../auth/dpop"); });
44
+ var audit = lazyRequire(function () { return require("../audit"); });
45
+
46
+ function _writeUnauthorized(res, errorCode, description) {
47
+ if (res.headersSent) return;
48
+ var body = JSON.stringify({ error: errorCode, error_description: description });
49
+ // RFC 9449 §7 — error code is invalid_dpop_proof OR use_dpop_nonce.
50
+ var challenge = 'DPoP error="' + errorCode + '", error_description="' +
51
+ description.replace(/"/g, "'") + '"';
52
+ res.writeHead(401, { // allow:raw-byte-literal — HTTP 401 status
53
+ "Content-Type": "application/json; charset=utf-8",
54
+ "Content-Length": Buffer.byteLength(body),
55
+ "WWW-Authenticate": challenge,
56
+ });
57
+ res.end(body);
58
+ }
59
+
60
+ function _reconstructHtu(req) {
61
+ // The proof's htu is the request URI WITHOUT query/fragment. Behind
62
+ // a reverse proxy the operator may need to override via opts.htu /
63
+ // opts.getHtu — defaults read X-Forwarded-* if present.
64
+ var proto = req.headers["x-forwarded-proto"] || (req.socket && req.socket.encrypted ? "https" : "http");
65
+ var host = req.headers["x-forwarded-host"] || req.headers.host;
66
+ if (!host) return null;
67
+ var path = req.url || "/";
68
+ var qIdx = path.indexOf("?");
69
+ if (qIdx !== -1) path = path.slice(0, qIdx);
70
+ var hIdx = path.indexOf("#");
71
+ if (hIdx !== -1) path = path.slice(0, hIdx);
72
+ return proto + "://" + host + path;
73
+ }
74
+
75
+ function create(opts) {
76
+ opts = opts || {};
77
+ validateOpts(opts, [
78
+ "replayStore", "algorithms", "iatWindowSec",
79
+ "getAccessToken", "getNonce", "getHtu", "audit",
80
+ ], "middleware.dpop");
81
+
82
+ var auditOn = opts.audit !== false;
83
+ var algorithms = opts.algorithms;
84
+ var iatWindowSec = opts.iatWindowSec;
85
+ var replayStore = opts.replayStore;
86
+
87
+ validateOpts.optionalFunction(opts.getAccessToken,
88
+ "middleware.dpop: getAccessToken", AuthError, "auth-dpop/bad-opt");
89
+ validateOpts.optionalFunction(opts.getNonce,
90
+ "middleware.dpop: getNonce", AuthError, "auth-dpop/bad-opt");
91
+ validateOpts.optionalFunction(opts.getHtu,
92
+ "middleware.dpop: getHtu", AuthError, "auth-dpop/bad-opt");
93
+
94
+ return async function dpopMiddleware(req, res, next) {
95
+ var proofHeader = req.headers && req.headers.dpop;
96
+ if (typeof proofHeader !== "string" || proofHeader.length === 0) {
97
+ return _writeUnauthorized(res, "invalid_dpop_proof", "DPoP header required");
98
+ }
99
+ // RFC 9449 §4.1 — only ONE DPoP header value per request.
100
+ if (Array.isArray(proofHeader)) {
101
+ return _writeUnauthorized(res, "invalid_dpop_proof",
102
+ "multiple DPoP headers are not allowed");
103
+ }
104
+
105
+ var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req));
106
+ if (!htu) {
107
+ return _writeUnauthorized(res, "invalid_dpop_proof", "could not reconstruct htu");
108
+ }
109
+ var htm = (req.method || "").toUpperCase();
110
+
111
+ var accessToken = null;
112
+ if (typeof opts.getAccessToken === "function") {
113
+ try { accessToken = await opts.getAccessToken(req); }
114
+ catch (_e) { accessToken = null; }
115
+ }
116
+ var nonce = null;
117
+ if (typeof opts.getNonce === "function") {
118
+ try { nonce = await opts.getNonce(req); }
119
+ catch (_e) { nonce = null; }
120
+ }
121
+
122
+ var verifyOpts = { htm: htm, htu: htu };
123
+ if (algorithms) verifyOpts.algorithms = algorithms;
124
+ if (iatWindowSec !== undefined) verifyOpts.iatWindowSec = iatWindowSec;
125
+ if (accessToken) verifyOpts.accessToken = accessToken;
126
+ if (nonce) verifyOpts.nonce = nonce;
127
+ if (replayStore) verifyOpts.replayStore = replayStore;
128
+
129
+ var result;
130
+ try { result = await dpop().verify(proofHeader, verifyOpts); }
131
+ catch (e) {
132
+ if (auditOn) {
133
+ try {
134
+ audit().safeEmit({
135
+ action: "auth.bearer.failure",
136
+ actor: { clientIp: requestHelpers.clientIp(req) },
137
+ outcome: "fail",
138
+ metadata: {
139
+ method: "dpop",
140
+ reason: (e && e.code) || "verify-failed",
141
+ route: req.url,
142
+ },
143
+ });
144
+ } catch (_ignored) { /* drop-silent — observability sink failure */ }
145
+ }
146
+ var errorCode = "invalid_dpop_proof";
147
+ // RFC 9449 §8 — when nonce is missing/invalid the server SHOULD use
148
+ // use_dpop_nonce to signal the client to retry with a new nonce.
149
+ if (e && (e.code === "auth-dpop/missing-nonce" || e.code === "auth-dpop/nonce-mismatch")) {
150
+ errorCode = "use_dpop_nonce";
151
+ }
152
+ return _writeUnauthorized(res, errorCode,
153
+ (e && e.message) || "DPoP proof verification failed");
154
+ }
155
+
156
+ req.dpop = result;
157
+ if (auditOn) {
158
+ try {
159
+ audit().safeEmit({
160
+ action: "auth.bearer.success",
161
+ actor: { clientIp: requestHelpers.clientIp(req) },
162
+ outcome: "ok",
163
+ metadata: { method: "dpop", jkt: result.jkt, route: req.url },
164
+ });
165
+ } catch (_ignored) { /* drop-silent */ }
166
+ }
167
+ return next();
168
+ };
169
+ }
170
+
171
+ module.exports = {
172
+ create: create,
173
+ };
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * b.middleware.gpc — Sec-GPC (Global Privacy Control) middleware.
4
+ *
5
+ * Reads the `Sec-GPC: 1` request header (W3C Privacy Sandbox / IETF
6
+ * draft-doty-gpc-header) and, when present, sets `req.gpcOptOut =
7
+ * true` for downstream consumers. Optionally records the opt-out
8
+ * via `b.consent` (when wired) so the operator's data-flow primitives
9
+ * can refuse `sale`/`share`/`targeted-ads`/`profiling` purposes for
10
+ * this session.
11
+ *
12
+ * Echoes a `Sec-GPC-Status: honored` response header per the
13
+ * California Privacy Protection Agency's recommended posture so the
14
+ * client UA can confirm honoring (and so audits show the
15
+ * acknowledgement).
16
+ *
17
+ * Compliance context — Sec-GPC is **legally required** by:
18
+ * - California (CCPA / CPRA) — effective Jan 1 2024
19
+ * - Colorado, Connecticut, Texas, Oregon, Delaware, Montana, Iowa,
20
+ * Nebraska, New Hampshire, New Jersey, Maryland, Minnesota — effective
21
+ * dates vary; most by Jan 1 2026
22
+ *
23
+ * Operators handling US user traffic without honoring Sec-GPC face
24
+ * regulator-action exposure (CPPA fines up to $7,500 per intentional
25
+ * violation). The framework's posture is to honor by default; operators
26
+ * who legitimately don't process the listed purposes still emit the
27
+ * acknowledgement header so audits trace correctly.
28
+ *
29
+ * var gpc = b.middleware.gpc({
30
+ * audit: b.audit,
31
+ * consent: b.consent, // optional — auto-records purpose-withdrawal
32
+ * mode: "enforce", // "enforce" | "audit-only"
33
+ * });
34
+ * app.use(gpc);
35
+ *
36
+ * app.get("/api/data", function (req, res) {
37
+ * if (req.gpcOptOut) {
38
+ * // Don't serve targeted ads, don't share with data brokers, etc.
39
+ * }
40
+ * });
41
+ */
42
+
43
+ var lazyRequire = require("../lazy-require");
44
+
45
+ var observability = lazyRequire(function () { return require("../observability"); });
46
+ void observability;
47
+
48
+ // Purposes the operator must NOT process when Sec-GPC: 1 is set, per
49
+ // CCPA/CPRA + the multi-state cohort. Operators consuming `req.gpcOptOut`
50
+ // gate these specific purposes.
51
+ var GPC_OPTOUT_PURPOSES = Object.freeze([
52
+ "sale",
53
+ "share",
54
+ "targeted-ads",
55
+ "cross-context-behavioral-advertising",
56
+ "profiling",
57
+ ]);
58
+
59
+ function _emitAudit(audit, action, outcome, metadata) {
60
+ if (!audit || typeof audit.safeEmit !== "function") return;
61
+ try {
62
+ audit.safeEmit({
63
+ action: action,
64
+ actor: metadata.actor || { kind: "framework", id: "middleware/gpc" },
65
+ outcome: outcome,
66
+ metadata: metadata,
67
+ });
68
+ } catch (_e) { /* drop-silent */ }
69
+ }
70
+
71
+ function create(opts) {
72
+ opts = opts || {};
73
+ var mode = opts.mode || "enforce";
74
+ var audit = opts.audit || null;
75
+ var consentApi = opts.consent || null;
76
+ var statusHeader = opts.statusHeader !== false;
77
+
78
+ return function gpcMiddleware(req, res, next) {
79
+ var rawHeader = (req.headers && req.headers["sec-gpc"]) || "";
80
+ var optOut = rawHeader === "1";
81
+ req.gpcOptOut = optOut;
82
+
83
+ if (optOut) {
84
+ // Stamp the acknowledgement header so the UA + auditors see we
85
+ // honored.
86
+ if (statusHeader && typeof res.setHeader === "function") {
87
+ res.setHeader("Sec-GPC-Status", mode === "audit-only" ? "audit-only" : "honored");
88
+ }
89
+
90
+ // Optional integration with b.consent — record purpose
91
+ // withdrawal so downstream consumers see the GPC signal as a
92
+ // structured opt-out.
93
+ if (consentApi && typeof consentApi.recordOptOut === "function") {
94
+ try {
95
+ consentApi.recordOptOut({
96
+ req: req,
97
+ purposes: GPC_OPTOUT_PURPOSES,
98
+ source: "sec-gpc",
99
+ mode: mode,
100
+ });
101
+ } catch (e) {
102
+ _emitAudit(audit, "middleware.gpc.consent-error", "audit", {
103
+ error: (e && e.message) || String(e),
104
+ });
105
+ }
106
+ }
107
+
108
+ _emitAudit(audit, "middleware.gpc.opt-out-honored", "success", {
109
+ mode: mode,
110
+ purposes: GPC_OPTOUT_PURPOSES.slice(),
111
+ });
112
+ }
113
+ return next();
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ create: create,
119
+ GPC_OPTOUT_PURPOSES: GPC_OPTOUT_PURPOSES,
120
+ };
@@ -27,14 +27,17 @@ var cors = require("./cors");
27
27
  var cspNonce = require("./csp-nonce");
28
28
  var csrfProtect = require("./csrf-protect");
29
29
  var dbRoleFor = require("./db-role-for");
30
+ var dpop = require("./dpop");
30
31
  var errorHandler = require("./error-handler");
31
32
  var fetchMetadata = require("./fetch-metadata");
33
+ var gpc = require("./gpc");
32
34
  var headers = require("./headers");
33
35
  var health = require("./health");
34
36
  var networkAllowlist = require("./network-allowlist");
35
37
  var rateLimit = require("./rate-limit");
36
38
  var requestId = require("./request-id");
37
39
  var requestLog = require("./request-log");
40
+ var requireAal = require("./require-aal");
38
41
  var requireAuth = require("./require-auth");
39
42
  var securityHeaders = require("./security-headers");
40
43
  var sse = require("./sse");
@@ -48,9 +51,11 @@ module.exports = {
48
51
  rateLimit: rateLimit.create,
49
52
  attachUser: attachUser.create,
50
53
  bearerAuth: bearerAuth.create,
54
+ requireAal: requireAal.create,
51
55
  requireAuth: requireAuth.create,
52
56
  csrfProtect: csrfProtect.create,
53
57
  fetchMetadata: fetchMetadata.create,
58
+ gpc: gpc.create,
54
59
  headers: headers.create,
55
60
  bodyParser: bodyParser.create,
56
61
  health: health.create,
@@ -61,6 +66,7 @@ module.exports = {
61
66
  requestLog: requestLog.create,
62
67
  apiEncrypt: apiEncrypt,
63
68
  dbRoleFor: dbRoleFor.create,
69
+ dpop: dpop.create,
64
70
  networkAllowlist: networkAllowlist.create,
65
71
 
66
72
  // Module exports for advanced use (constants, raw factory access)
@@ -73,6 +79,7 @@ module.exports = {
73
79
  rateLimit: rateLimit,
74
80
  attachUser: attachUser,
75
81
  bearerAuth: bearerAuth,
82
+ requireAal: requireAal,
76
83
  requireAuth: requireAuth,
77
84
  csrfProtect: csrfProtect,
78
85
  fetchMetadata: fetchMetadata,
@@ -84,6 +91,7 @@ module.exports = {
84
91
  requestLog: requestLog,
85
92
  apiEncrypt: apiEncrypt,
86
93
  dbRoleFor: dbRoleFor,
94
+ dpop: dpop,
87
95
  networkAllowlist: networkAllowlist,
88
96
  },
89
97
  };
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ /**
3
+ * require-aal middleware — gate routes by NIST SP 800-63-4 AAL band.
4
+ *
5
+ * var stepUp = b.middleware.requireAal({ minimum: "AAL2" });
6
+ * router.use("/admin", stepUp);
7
+ *
8
+ * Reads the AAL band from `req.user.aal` by default. Operators with a
9
+ * different shape pass `getAal(req)` returning the band string.
10
+ *
11
+ * On failure the middleware writes 401 with
12
+ * `WWW-Authenticate: AAL-StepUp realm="<X>", required="<minimum>"`
13
+ * — the bespoke scheme name signals to the operator's frontend that a
14
+ * step-up flow should be triggered (re-prompt for TOTP / passkey).
15
+ *
16
+ * Audit:
17
+ * auth.aal.granted — request passed (carries the actual band)
18
+ * auth.aal.denied — request below the required minimum
19
+ */
20
+
21
+ var lazyRequire = require("../lazy-require");
22
+ var requestHelpers = require("../request-helpers");
23
+ var validateOpts = require("../validate-opts");
24
+ var { AuthError } = require("../framework-error");
25
+
26
+ var aal = lazyRequire(function () { return require("../auth/aal"); });
27
+ var audit = lazyRequire(function () { return require("../audit"); });
28
+
29
+ function _writeUnauthorized(res, requiredBand, actualBand, realm) {
30
+ if (res.headersSent) return;
31
+ var body = JSON.stringify({
32
+ error: "step_up_required",
33
+ error_description: "AAL " + requiredBand + " is required for this resource",
34
+ required_aal: requiredBand,
35
+ actual_aal: actualBand || null,
36
+ });
37
+ var realmStr = realm ? ' realm="' + realm + '"' : "";
38
+ var challenge = "AAL-StepUp" + realmStr + ', required="' + requiredBand + '"';
39
+ res.writeHead(401, { // allow:raw-byte-literal — HTTP 401 status
40
+ "Content-Type": "application/json; charset=utf-8",
41
+ "Content-Length": Buffer.byteLength(body),
42
+ "WWW-Authenticate": challenge,
43
+ });
44
+ res.end(body);
45
+ }
46
+
47
+ function create(opts) {
48
+ opts = opts || {};
49
+ validateOpts(opts, [
50
+ "minimum", "getAal", "audit", "realm",
51
+ ], "middleware.requireAal");
52
+
53
+ var minimum = opts.minimum;
54
+ if (!aal().isValidBand(minimum)) {
55
+ throw new AuthError("auth-aal/bad-minimum",
56
+ "middleware.requireAal: opts.minimum must be one of " +
57
+ aal().BANDS.join(", ") + " (got " + JSON.stringify(minimum) + ")");
58
+ }
59
+ validateOpts.optionalFunction(opts.getAal,
60
+ "middleware.requireAal: getAal", AuthError, "auth-aal/bad-opt");
61
+
62
+ var auditOn = opts.audit !== false;
63
+ var realm = (typeof opts.realm === "string" && opts.realm.length > 0) ? opts.realm : null;
64
+
65
+ return function requireAalMiddleware(req, res, next) {
66
+ var actual = null;
67
+ if (typeof opts.getAal === "function") {
68
+ try { actual = opts.getAal(req); } catch (_e) { actual = null; }
69
+ } else if (req.user && typeof req.user.aal === "string") {
70
+ actual = req.user.aal;
71
+ }
72
+
73
+ if (!aal().meets(actual, minimum)) {
74
+ if (auditOn) {
75
+ try {
76
+ audit().safeEmit({
77
+ action: "auth.aal.denied",
78
+ actor: { clientIp: requestHelpers.clientIp(req), userId: req.user && req.user.id },
79
+ outcome: "fail",
80
+ metadata: {
81
+ required: minimum,
82
+ actual: actual || null,
83
+ route: req.url,
84
+ },
85
+ });
86
+ } catch (_ignored) { /* drop-silent */ }
87
+ }
88
+ return _writeUnauthorized(res, minimum, actual, realm);
89
+ }
90
+
91
+ if (auditOn) {
92
+ try {
93
+ audit().safeEmit({
94
+ action: "auth.aal.granted",
95
+ actor: { clientIp: requestHelpers.clientIp(req), userId: req.user && req.user.id },
96
+ outcome: "ok",
97
+ metadata: { aal: actual, required: minimum, route: req.url },
98
+ });
99
+ } catch (_ignored) { /* drop-silent */ }
100
+ }
101
+ return next();
102
+ };
103
+ }
104
+
105
+ module.exports = {
106
+ create: create,
107
+ };