@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/CHANGELOG.md +20 -0
- package/index.js +2 -0
- package/lib/auth/aal.js +149 -0
- package/lib/auth/dpop.js +512 -0
- package/lib/auth/jwt.js +67 -0
- package/lib/auth/oauth.js +13 -6
- package/lib/cookies.js +2 -1
- package/lib/mail-auth.js +356 -2
- package/lib/mail-unsubscribe.js +160 -0
- package/lib/mail.js +135 -9
- package/lib/middleware/dpop.js +173 -0
- package/lib/middleware/gpc.js +120 -0
- package/lib/middleware/index.js +8 -0
- package/lib/middleware/require-aal.js +107 -0
- package/lib/middleware/security-headers.js +29 -1
- package/lib/network-dns.js +131 -12
- package/lib/network-smtp-policy.js +118 -3
- package/lib/vendor/MANIFEST.json +21 -5
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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
|
-
|
|
105
|
-
|
|
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:
|
|
967
|
-
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:
|
|
977
|
-
dmarc:
|
|
978
|
-
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
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -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
|
+
};
|