@blamejs/core 0.14.5 → 0.14.7
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 +4 -0
- package/README.md +4 -2
- package/lib/agent-event-bus.js +4 -4
- package/lib/agent-idempotency.js +6 -6
- package/lib/agent-orchestrator.js +9 -9
- package/lib/agent-posture-chain.js +10 -10
- package/lib/agent-saga.js +6 -7
- package/lib/agent-snapshot.js +8 -8
- package/lib/agent-stream.js +3 -3
- package/lib/agent-tenant.js +4 -4
- package/lib/agent-trace.js +5 -5
- package/lib/ai-disclosure.js +3 -3
- package/lib/app.js +2 -2
- package/lib/archive-read.js +1 -1
- package/lib/archive-tar-read.js +1 -1
- package/lib/archive-wrap.js +5 -5
- package/lib/audit-tools.js +65 -5
- package/lib/audit.js +2 -2
- package/lib/auth/ciba.js +1 -1
- package/lib/auth/dpop.js +1 -1
- package/lib/auth/fal.js +1 -1
- package/lib/auth/fido-mds3.js +2 -3
- package/lib/auth/jwt-external.js +2 -2
- package/lib/auth/oauth.js +9 -9
- package/lib/auth/oid4vci.js +7 -7
- package/lib/auth/oid4vp.js +1 -1
- package/lib/auth/openid-federation.js +5 -5
- package/lib/auth/passkey.js +6 -6
- package/lib/auth/saml.js +1 -1
- package/lib/auth/sd-jwt-vc.js +3 -6
- package/lib/backup/index.js +18 -18
- package/lib/cache.js +4 -4
- package/lib/calendar.js +5 -5
- package/lib/circuit-breaker.js +1 -1
- package/lib/cms-codec.js +2 -2
- package/lib/compliance.js +14 -14
- package/lib/cra-report.js +3 -3
- package/lib/crypto-field.js +58 -21
- package/lib/crypto.js +5 -6
- package/lib/db-query.js +131 -9
- package/lib/db.js +106 -22
- package/lib/external-db.js +64 -16
- package/lib/framework-schema.js +4 -4
- package/lib/guard-list-id.js +2 -2
- package/lib/guard-list-unsubscribe.js +1 -2
- package/lib/incident-report.js +150 -0
- package/lib/mail-crypto-smime.js +1 -1
- package/lib/mail-deploy.js +3 -3
- package/lib/mail-server-managesieve.js +2 -2
- package/lib/mail-server-pop3.js +2 -2
- package/lib/mail-store.js +1 -1
- package/lib/metrics.js +8 -8
- package/lib/middleware/age-gate.js +20 -7
- package/lib/middleware/bearer-auth.js +36 -35
- package/lib/middleware/bot-guard.js +17 -5
- package/lib/middleware/cors.js +28 -12
- package/lib/middleware/csrf-protect.js +23 -15
- package/lib/middleware/daily-byte-quota.js +27 -13
- package/lib/middleware/deny-response.js +140 -0
- package/lib/middleware/dpop.js +37 -24
- package/lib/middleware/fetch-metadata.js +21 -12
- package/lib/middleware/host-allowlist.js +19 -8
- package/lib/middleware/idempotency-key.js +21 -22
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/network-allowlist.js +24 -10
- package/lib/middleware/protected-resource-metadata.js +2 -2
- package/lib/middleware/rate-limit.js +22 -5
- package/lib/middleware/require-aal.js +25 -10
- package/lib/middleware/require-auth.js +32 -16
- package/lib/middleware/require-bound-key.js +49 -18
- package/lib/middleware/require-content-type.js +19 -8
- package/lib/middleware/require-methods.js +17 -7
- package/lib/middleware/require-mtls.js +27 -14
- package/lib/network-dns-resolver.js +2 -2
- package/lib/network-dns.js +1 -2
- package/lib/network-tls.js +0 -1
- package/lib/network.js +4 -4
- package/lib/outbox.js +1 -1
- package/lib/pqc-agent.js +1 -1
- package/lib/retention.js +1 -1
- package/lib/retry.js +1 -1
- package/lib/safe-archive.js +2 -2
- package/lib/safe-ical.js +2 -2
- package/lib/safe-mime.js +1 -1
- package/lib/self-update-standalone-verifier.js +1 -1
- package/lib/self-update.js +2 -2
- package/lib/static.js +1 -1
- package/lib/subject.js +2 -2
- package/lib/vault/index.js +64 -1
- package/lib/vault/rotate.js +19 -0
- package/lib/vendor-data.js +1 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/middleware/dpop.js
CHANGED
|
@@ -40,6 +40,7 @@ var bCrypto = require("../crypto");
|
|
|
40
40
|
var lazyRequire = require("../lazy-require");
|
|
41
41
|
var requestHelpers = require("../request-helpers");
|
|
42
42
|
var validateOpts = require("../validate-opts");
|
|
43
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
43
44
|
var { AuthError } = require("../framework-error");
|
|
44
45
|
|
|
45
46
|
var dpop = lazyRequire(function () { return require("../auth/dpop"); });
|
|
@@ -49,20 +50,28 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
49
50
|
// of entropy after base64url, far above the spec's "unpredictable" bar).
|
|
50
51
|
var DPOP_NONCE_BYTES = C.BYTES.bytes(24);
|
|
51
52
|
|
|
52
|
-
function _writeUnauthorized(res, errorCode, description, freshNonce) {
|
|
53
|
-
if (res.headersSent) return;
|
|
53
|
+
function _writeUnauthorized(req, res, errorCode, description, freshNonce, onDeny, problemMode) {
|
|
54
54
|
var body = JSON.stringify({ error: errorCode, error_description: description });
|
|
55
55
|
// RFC 9449 §7 — error code is invalid_dpop_proof OR use_dpop_nonce.
|
|
56
56
|
var challenge = 'DPoP error="' + errorCode + '", error_description="' +
|
|
57
57
|
description.replace(/"/g, "'") + '"';
|
|
58
|
-
var headers = {
|
|
59
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
60
|
-
"Content-Length": Buffer.byteLength(body),
|
|
58
|
+
var headers = {
|
|
61
59
|
"WWW-Authenticate": challenge,
|
|
62
60
|
};
|
|
63
61
|
if (freshNonce) headers["DPoP-Nonce"] = freshNonce;
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
denyResponse(req, res, {
|
|
63
|
+
onDeny: onDeny,
|
|
64
|
+
problem: problemMode,
|
|
65
|
+
status: 401, // HTTP 401 status
|
|
66
|
+
info: { status: 401, reason: errorCode, error_description: description },
|
|
67
|
+
problemCode: "dpop-" + errorCode.replace(/_/g, "-"),
|
|
68
|
+
problemTitle: "Unauthorized",
|
|
69
|
+
problemDetail: description,
|
|
70
|
+
problemExt: { error: errorCode, error_description: description },
|
|
71
|
+
headers: headers,
|
|
72
|
+
contentType: "application/json; charset=utf-8",
|
|
73
|
+
body: body,
|
|
74
|
+
});
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
// RFC 9449 §8 — server-issued DPoP-Nonce challenge. The framework
|
|
@@ -110,7 +119,7 @@ function _nonceManager(rotateSec) {
|
|
|
110
119
|
if (previous && bCrypto.timingSafeEqual(n, previous.nonce)) return true;
|
|
111
120
|
return false;
|
|
112
121
|
},
|
|
113
|
-
//
|
|
122
|
+
// Hot-reload coexistence. Operators redeploying without
|
|
114
123
|
// a clean process restart need a way to drain in-flight clients
|
|
115
124
|
// before swapping the middleware instance. shutdown() returns no
|
|
116
125
|
// fresh nonces and refuses every presented nonce, so the
|
|
@@ -147,7 +156,7 @@ function _reconstructHtu(req, mopts) {
|
|
|
147
156
|
//
|
|
148
157
|
// Default: ignore X-Forwarded-* and derive proto/host from the
|
|
149
158
|
// socket. Operators with a confirmed-trusted front proxy opt in
|
|
150
|
-
// via opts.trustForwardedHeaders: true.
|
|
159
|
+
// via opts.trustForwardedHeaders: true.
|
|
151
160
|
mopts = mopts || {};
|
|
152
161
|
var trustForwarded = mopts.trustForwardedHeaders === true;
|
|
153
162
|
var proto;
|
|
@@ -201,6 +210,8 @@ function _reconstructHtu(req, mopts) {
|
|
|
201
210
|
* nonceRotateSec: number,
|
|
202
211
|
* requireNonce: boolean,
|
|
203
212
|
* audit: boolean, // default true
|
|
213
|
+
* onDeny: function(req, res, info): void, // own the 401; info = { status, reason, error_description }
|
|
214
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
204
215
|
* }
|
|
205
216
|
*
|
|
206
217
|
* @example
|
|
@@ -218,11 +229,13 @@ function create(opts) {
|
|
|
218
229
|
"getAccessToken", "getNonce", "getHtu", "audit",
|
|
219
230
|
"nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
|
|
220
231
|
// v0.9.4 — opt-in trust gate for X-Forwarded-Proto/Host when
|
|
221
|
-
// reconstructing htu. Default off
|
|
232
|
+
// reconstructing htu. Default off; operators
|
|
222
233
|
// with a confirmed-trusted front proxy set this to `true`.
|
|
223
|
-
"trustForwardedHeaders",
|
|
234
|
+
"trustForwardedHeaders", "onDeny", "problemDetails",
|
|
224
235
|
], "middleware.dpop");
|
|
225
236
|
|
|
237
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
238
|
+
var problemMode = opts.problemDetails === true;
|
|
226
239
|
var auditOn = opts.audit !== false;
|
|
227
240
|
var algorithms = opts.algorithms;
|
|
228
241
|
var iatWindowSec = opts.iatWindowSec;
|
|
@@ -265,16 +278,16 @@ function create(opts) {
|
|
|
265
278
|
var middleware = async function dpopMiddleware(req, res, next) {
|
|
266
279
|
var proofHeader = req.headers && req.headers.dpop;
|
|
267
280
|
if (typeof proofHeader !== "string" || proofHeader.length === 0) {
|
|
268
|
-
return _writeUnauthorized(res,
|
|
281
|
+
return _writeUnauthorized(req, res,
|
|
269
282
|
nonceMgr ? "use_dpop_nonce" : "invalid_dpop_proof",
|
|
270
|
-
"DPoP header required", _freshNonce());
|
|
283
|
+
"DPoP header required", _freshNonce(), onDeny, problemMode);
|
|
271
284
|
}
|
|
272
285
|
// RFC 9449 §4.1 — only ONE DPoP header value per request.
|
|
273
286
|
if (Array.isArray(proofHeader)) {
|
|
274
|
-
return _writeUnauthorized(res, "invalid_dpop_proof",
|
|
275
|
-
"multiple DPoP headers are not allowed");
|
|
287
|
+
return _writeUnauthorized(req, res, "invalid_dpop_proof",
|
|
288
|
+
"multiple DPoP headers are not allowed", null, onDeny, problemMode);
|
|
276
289
|
}
|
|
277
|
-
//
|
|
290
|
+
// RFC 9449 §4.1 single-value invariant. node:http
|
|
278
291
|
// collapses repeated headers into a comma-joined string when the
|
|
279
292
|
// client ships `DPoP: proof1, DPoP: proof2`; the Array.isArray
|
|
280
293
|
// check above catches the multi-value array shape but a
|
|
@@ -283,13 +296,13 @@ function create(opts) {
|
|
|
283
296
|
// verify() call below would only see the first one, leaving the
|
|
284
297
|
// second unprocessed).
|
|
285
298
|
if (proofHeader.indexOf(",") !== -1) {
|
|
286
|
-
return _writeUnauthorized(res, "invalid_dpop_proof",
|
|
287
|
-
"multiple DPoP proofs in one header value are not allowed");
|
|
299
|
+
return _writeUnauthorized(req, res, "invalid_dpop_proof",
|
|
300
|
+
"multiple DPoP proofs in one header value are not allowed", null, onDeny, problemMode);
|
|
288
301
|
}
|
|
289
302
|
|
|
290
303
|
var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
|
|
291
304
|
if (!htu) {
|
|
292
|
-
return _writeUnauthorized(res, "invalid_dpop_proof", "could not reconstruct htu");
|
|
305
|
+
return _writeUnauthorized(req, res, "invalid_dpop_proof", "could not reconstruct htu", null, onDeny, problemMode);
|
|
293
306
|
}
|
|
294
307
|
var htm = (req.method || "").toUpperCase();
|
|
295
308
|
|
|
@@ -340,9 +353,9 @@ function create(opts) {
|
|
|
340
353
|
if (e && (e.code === "auth-dpop/missing-nonce" || e.code === "auth-dpop/nonce-mismatch")) {
|
|
341
354
|
errorCode = "use_dpop_nonce";
|
|
342
355
|
}
|
|
343
|
-
return _writeUnauthorized(res, errorCode,
|
|
356
|
+
return _writeUnauthorized(req, res, errorCode,
|
|
344
357
|
(e && e.message) || "DPoP proof verification failed",
|
|
345
|
-
_freshNonce());
|
|
358
|
+
_freshNonce(), onDeny, problemMode);
|
|
346
359
|
}
|
|
347
360
|
|
|
348
361
|
// Server-managed nonce check — payload MUST carry a recognized
|
|
@@ -360,8 +373,8 @@ function create(opts) {
|
|
|
360
373
|
});
|
|
361
374
|
} catch (_ignored) { /* drop-silent */ }
|
|
362
375
|
}
|
|
363
|
-
return _writeUnauthorized(res, "use_dpop_nonce",
|
|
364
|
-
"DPoP-Nonce required (server-managed challenge)", _freshNonce());
|
|
376
|
+
return _writeUnauthorized(req, res, "use_dpop_nonce",
|
|
377
|
+
"DPoP-Nonce required (server-managed challenge)", _freshNonce(), onDeny, problemMode);
|
|
365
378
|
}
|
|
366
379
|
}
|
|
367
380
|
|
|
@@ -386,7 +399,7 @@ function create(opts) {
|
|
|
386
399
|
return next();
|
|
387
400
|
};
|
|
388
401
|
|
|
389
|
-
//
|
|
402
|
+
// Surface the nonce manager's lifecycle hooks on the
|
|
390
403
|
// returned middleware so hot-reload deploys can drain in-flight
|
|
391
404
|
// clients before swapping instances. shutdown() refuses every
|
|
392
405
|
// subsequent proof + issues no fresh nonces; revoke() rotates the
|
|
@@ -36,20 +36,25 @@
|
|
|
36
36
|
var requestHelpers = require("../request-helpers");
|
|
37
37
|
var validateOpts = require("../validate-opts");
|
|
38
38
|
var lazyRequire = require("../lazy-require");
|
|
39
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
39
40
|
|
|
40
41
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
41
42
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
42
43
|
|
|
43
44
|
var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
|
|
44
45
|
|
|
45
|
-
function _writeReject(res, message) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
47
|
+
denyResponse(req, res, {
|
|
48
|
+
onDeny: onDeny,
|
|
49
|
+
problem: problemMode,
|
|
50
|
+
status: requestHelpers.HTTP_STATUS.FORBIDDEN,
|
|
51
|
+
info: { status: 403, reason: reason },
|
|
52
|
+
problemCode: "fetch-metadata-refused",
|
|
53
|
+
problemTitle: "Forbidden",
|
|
54
|
+
problemDetail: message,
|
|
55
|
+
contentType: "application/json; charset=utf-8",
|
|
56
|
+
body: JSON.stringify({ error: message }),
|
|
51
57
|
});
|
|
52
|
-
res.end(body);
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
/**
|
|
@@ -79,6 +84,8 @@ function _writeReject(res, message) {
|
|
|
79
84
|
* allowedNavigate: boolean, // default true
|
|
80
85
|
* methods: string[], // default POST/PUT/DELETE/PATCH
|
|
81
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
|
|
82
89
|
* }
|
|
83
90
|
*
|
|
84
91
|
* @example
|
|
@@ -95,9 +102,11 @@ function create(opts) {
|
|
|
95
102
|
opts = opts || {};
|
|
96
103
|
validateOpts(opts, [
|
|
97
104
|
"allowSameSite", "allowCrossSite", "allowMissing",
|
|
98
|
-
"allowedDest", "allowedNavigate", "methods", "audit",
|
|
105
|
+
"allowedDest", "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
|
|
99
106
|
], "middleware.fetchMetadata");
|
|
100
107
|
|
|
108
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
109
|
+
var problemMode = opts.problemDetails === true;
|
|
101
110
|
var allowSameSite = opts.allowSameSite !== false;
|
|
102
111
|
var allowCrossSite = opts.allowCrossSite === true;
|
|
103
112
|
var allowMissing = opts.allowMissing !== false;
|
|
@@ -135,7 +144,7 @@ function create(opts) {
|
|
|
135
144
|
// Defer to other auth/CSRF layers per allowMissing.
|
|
136
145
|
if (!allowMissing) {
|
|
137
146
|
_emitDenied(req, "fetch-metadata-missing");
|
|
138
|
-
return _writeReject(res, "Fetch-metadata required.");
|
|
147
|
+
return _writeReject(req, res, "Fetch-metadata required.", "fetch-metadata-missing", onDeny, problemMode);
|
|
139
148
|
}
|
|
140
149
|
return next();
|
|
141
150
|
}
|
|
@@ -144,7 +153,7 @@ function create(opts) {
|
|
|
144
153
|
if (site === "none") {
|
|
145
154
|
if (allowedNavigate) return next();
|
|
146
155
|
_emitDenied(req, "navigate-disallowed");
|
|
147
|
-
return _writeReject(res, "Direct navigation not allowed for this method.");
|
|
156
|
+
return _writeReject(req, res, "Direct navigation not allowed for this method.", "navigation-not-allowed", onDeny, problemMode);
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
if (site === "same-origin") return next();
|
|
@@ -152,7 +161,7 @@ function create(opts) {
|
|
|
152
161
|
if (site === "same-site") {
|
|
153
162
|
if (allowSameSite) return next();
|
|
154
163
|
_emitDenied(req, "same-site-disallowed");
|
|
155
|
-
return _writeReject(res, "Same-site request not allowed.");
|
|
164
|
+
return _writeReject(req, res, "Same-site request not allowed.", "same-site-not-allowed", onDeny, problemMode);
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
// cross-site
|
|
@@ -164,7 +173,7 @@ function create(opts) {
|
|
|
164
173
|
", dest=" + (dest || "?") + ")");
|
|
165
174
|
try { observability().count("auth.fetch_metadata.cross_site_refused", 1, {}); }
|
|
166
175
|
catch (_e) { /* best-effort */ }
|
|
167
|
-
return _writeReject(res, "Cross-site request refused.");
|
|
176
|
+
return _writeReject(req, res, "Cross-site request refused.", "cross-site-refused", onDeny, problemMode);
|
|
168
177
|
};
|
|
169
178
|
}
|
|
170
179
|
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
var lazyRequire = require("../lazy-require");
|
|
42
42
|
var requestHelpers = require("../request-helpers");
|
|
43
43
|
var validateOpts = require("../validate-opts");
|
|
44
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
44
45
|
var { defineClass } = require("../framework-error");
|
|
45
46
|
|
|
46
47
|
var HostAllowlistError = defineClass("HostAllowlistError", { alwaysPermanent: true });
|
|
@@ -102,6 +103,8 @@ function _matches(entry, actual) {
|
|
|
102
103
|
* denyStatus: number, // default 421
|
|
103
104
|
* denyBody: string,
|
|
104
105
|
* audit: boolean, // default true
|
|
106
|
+
* onDeny: function(req, res, info): void, // own the refusal; info = { status, reason, host }
|
|
107
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
105
108
|
* }
|
|
106
109
|
*
|
|
107
110
|
* @example
|
|
@@ -114,7 +117,7 @@ function _matches(entry, actual) {
|
|
|
114
117
|
function create(opts) {
|
|
115
118
|
validateOpts.requireObject(opts, "middleware.hostAllowlist", HostAllowlistError);
|
|
116
119
|
validateOpts(opts, [
|
|
117
|
-
"hosts", "denyStatus", "denyBody", "audit",
|
|
120
|
+
"hosts", "denyStatus", "denyBody", "audit", "onDeny", "problemDetails",
|
|
118
121
|
], "middleware.hostAllowlist");
|
|
119
122
|
|
|
120
123
|
if (!Array.isArray(opts.hosts) || opts.hosts.length === 0) {
|
|
@@ -134,6 +137,8 @@ function create(opts) {
|
|
|
134
137
|
var denyStatus = (typeof opts.denyStatus === "number") ? opts.denyStatus : 421; // HTTP 421 status
|
|
135
138
|
var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Misdirected Request";
|
|
136
139
|
var auditOn = opts.audit !== false;
|
|
140
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
141
|
+
var problemMode = opts.problemDetails === true;
|
|
137
142
|
|
|
138
143
|
return function hostAllowlistMiddleware(req, res, next) {
|
|
139
144
|
var raw = req.headers && req.headers.host;
|
|
@@ -141,7 +146,7 @@ function create(opts) {
|
|
|
141
146
|
// RFC 7230 §5.4 — a request without a Host header is malformed
|
|
142
147
|
// for HTTP/1.1; HTTP/2 maps :authority into req.headers.host
|
|
143
148
|
// automatically. Reject either shape.
|
|
144
|
-
_deny(res,
|
|
149
|
+
_deny(req, res, "missing-host", null);
|
|
145
150
|
_emitDenied(req, "missing-host");
|
|
146
151
|
return;
|
|
147
152
|
}
|
|
@@ -151,20 +156,26 @@ function create(opts) {
|
|
|
151
156
|
if (_matches(hosts[hi], actual)) { matched = true; break; }
|
|
152
157
|
}
|
|
153
158
|
if (!matched) {
|
|
154
|
-
_deny(res,
|
|
159
|
+
_deny(req, res, "host-not-in-allowlist", actual);
|
|
155
160
|
_emitDenied(req, "host-not-in-allowlist", actual);
|
|
156
161
|
return;
|
|
157
162
|
}
|
|
158
163
|
return next();
|
|
159
164
|
};
|
|
160
165
|
|
|
161
|
-
function _deny(res,
|
|
166
|
+
function _deny(req, res, reason, host) {
|
|
162
167
|
if (res.headersSent) return;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
denyResponse(req, res, {
|
|
169
|
+
onDeny: onDeny,
|
|
170
|
+
problem: problemMode,
|
|
171
|
+
status: denyStatus,
|
|
172
|
+
info: { status: denyStatus, reason: reason, host: host },
|
|
173
|
+
problemCode: "misdirected-request",
|
|
174
|
+
problemTitle: "Misdirected Request",
|
|
175
|
+
problemDetail: "The request Host header is not served by this endpoint.",
|
|
176
|
+
contentType: "text/plain; charset=utf-8",
|
|
177
|
+
body: denyBody,
|
|
166
178
|
});
|
|
167
|
-
res.end(body);
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
function _emitDenied(req, reason, actual) {
|
|
@@ -175,7 +175,7 @@ function memoryStore(opts) {
|
|
|
175
175
|
* headers. Requires `b.vault.init(...)` to have run; falls back to
|
|
176
176
|
* plain-text with a one-shot audit warning when vault isn't ready,
|
|
177
177
|
* so test-fixture / boot-script callers still work.
|
|
178
|
-
* - `aad: true` (since 0.9.58
|
|
178
|
+
* - `aad: true` (since 0.9.58) — sealed columns are bound
|
|
179
179
|
* via Additional Authenticated Data to (table, k, column,
|
|
180
180
|
* schemaVersion) so a DB-write attacker can't copy a sealed
|
|
181
181
|
* header/body cell from one row to another (which previously
|
|
@@ -184,12 +184,12 @@ function memoryStore(opts) {
|
|
|
184
184
|
* detects the envelope shape; lazy re-seal on next `set()` upgrades
|
|
185
185
|
* each row to AAD form. Operators wanting a one-shot migration
|
|
186
186
|
* call `b.middleware.idempotencyKey.resealMigrate(store)`.
|
|
187
|
-
* - `fingerprintSeal: true` (since 0.9.58
|
|
187
|
+
* - `fingerprintSeal: true` (since 0.9.58) — the request
|
|
188
188
|
* `fingerprint` column carries an HMAC under a vault-derived
|
|
189
189
|
* secret instead of a bare SHA3-256 of method+path+body. The
|
|
190
190
|
* compare path is constant-time so the column doubles as a
|
|
191
191
|
* mismatch oracle without offline-brute-force exposure.
|
|
192
|
-
* - `bodyFingerprintFallback: "deny"` (since 0.9.58
|
|
192
|
+
* - `bodyFingerprintFallback: "deny"` (since 0.9.58) —
|
|
193
193
|
* when neither `bodyFingerprint` nor `req._rawBody`/`req.body` is
|
|
194
194
|
* populated for a body-bearing method, the middleware previously
|
|
195
195
|
* silently degraded the fingerprint to method+path. Set to
|
|
@@ -228,8 +228,8 @@ function memoryStore(opts) {
|
|
|
228
228
|
* init?: boolean, // default true — run CREATE TABLE IF NOT EXISTS at construction
|
|
229
229
|
* hashKeys?: boolean, // default true — store sha3-512 namespace-hash of the key, not the raw key
|
|
230
230
|
* seal?: boolean, // default true — seal headers + body via b.cryptoField when vault is ready
|
|
231
|
-
* aad?: boolean, // default true — AAD-bind seal to (table,k,column) so a DB-write attacker can't cross-row swap
|
|
232
|
-
* fingerprintSeal?: boolean, // default true — HMAC fingerprint under a vault-derived secret instead of bare sha3-256
|
|
231
|
+
* aad?: boolean, // default true — AAD-bind seal to (table,k,column) so a DB-write attacker can't cross-row swap
|
|
232
|
+
* fingerprintSeal?: boolean, // default true — HMAC fingerprint under a vault-derived secret instead of bare sha3-256
|
|
233
233
|
*
|
|
234
234
|
* @example
|
|
235
235
|
* // single-process daemon, framework's internal sqlite, both defaults on:
|
|
@@ -264,11 +264,11 @@ function dbStore(opts) {
|
|
|
264
264
|
var doInit = opts.init !== false;
|
|
265
265
|
var hashKeys = opts.hashKeys !== false;
|
|
266
266
|
var sealReq = opts.seal !== false;
|
|
267
|
-
//
|
|
267
|
+
// AAD-bind sealing to (table, k, column) by default.
|
|
268
268
|
// Forms a defense-in-depth pair with seal: cross-row swap fails
|
|
269
269
|
// Poly1305 even when the attacker controls the DB layer.
|
|
270
270
|
var aadOn = opts.aad !== false;
|
|
271
|
-
//
|
|
271
|
+
// HMAC the fingerprint under a vault-derived secret by
|
|
272
272
|
// default. Bare SHA3-256 of method+path+body is offline-brute-
|
|
273
273
|
// forceable for any DB-dump attacker; HMAC under a vault secret
|
|
274
274
|
// forces them to break the vault first.
|
|
@@ -297,7 +297,7 @@ function dbStore(opts) {
|
|
|
297
297
|
|
|
298
298
|
// Register the table with cryptoField. registerTable is idempotent
|
|
299
299
|
// — subsequent dbStore() calls with the same tableName re-declare
|
|
300
|
-
// the same sealedFields and no-op.
|
|
300
|
+
// the same sealedFields and no-op. When aad is on,
|
|
301
301
|
// (table, k, column) is threaded into the AEAD AAD so a DB-write
|
|
302
302
|
// attacker can't copy a sealed value between rows.
|
|
303
303
|
if (sealEnabled) {
|
|
@@ -309,7 +309,7 @@ function dbStore(opts) {
|
|
|
309
309
|
});
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
//
|
|
312
|
+
// Derive a per-vault HMAC secret for fingerprint sealing.
|
|
313
313
|
// The vault root key is the trust root; without it the secret is
|
|
314
314
|
// unrecoverable. Lazy: only derived when fpSealOn is enabled AND the
|
|
315
315
|
// vault is ready, so test fixtures that haven't initialized the
|
|
@@ -349,7 +349,7 @@ function dbStore(opts) {
|
|
|
349
349
|
// so audit/forensic SELECTs don't have to unseal-everything. The
|
|
350
350
|
// `k` column is selected even when not strictly needed for read
|
|
351
351
|
// because cryptoField.unsealRow uses it as the rowId in AAD when
|
|
352
|
-
// the table is AAD-bound
|
|
352
|
+
// the table is AAD-bound.
|
|
353
353
|
var stmtGet = db.prepare(
|
|
354
354
|
"SELECT k, fingerprint, status_code, headers, body, expires_at FROM " +
|
|
355
355
|
qTable + " WHERE k = ?");
|
|
@@ -372,7 +372,7 @@ function dbStore(opts) {
|
|
|
372
372
|
return bCrypto.namespaceHash("idempotency-key", rawKey);
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
//
|
|
375
|
+
// Emit / compare HMAC-shape fingerprints. The store
|
|
376
376
|
// round-trips the column as plain text (no transformation per-get);
|
|
377
377
|
// sealing happens at MINT time (when the middleware builds the
|
|
378
378
|
// fingerprint and hands it to set()). The store's responsibility is
|
|
@@ -391,7 +391,7 @@ function dbStore(opts) {
|
|
|
391
391
|
if (sealEnabled) {
|
|
392
392
|
try { liveRow = cryptoField.unsealRow(tableNameRaw, row); }
|
|
393
393
|
catch (_unsealErr) {
|
|
394
|
-
//
|
|
394
|
+
// Decryption failure used to delete the row,
|
|
395
395
|
// which let an attacker probe key presence via a "tamper +
|
|
396
396
|
// observe subsequent SELECT" oracle. The fix: emit audit,
|
|
397
397
|
// return null, do NOT delete. TTL sweeps stale rows out
|
|
@@ -407,7 +407,7 @@ function dbStore(opts) {
|
|
|
407
407
|
}
|
|
408
408
|
var headersObj;
|
|
409
409
|
try {
|
|
410
|
-
//
|
|
410
|
+
// Route through C.BYTES.mib(4); raw `4 * 1024 * 1024`
|
|
411
411
|
// was a drift smell flagged by codebase-patterns. 4 MiB ceiling
|
|
412
412
|
// unchanged.
|
|
413
413
|
headersObj = safeJson.parse(liveRow.headers, { maxBytes: C.BYTES.mib(4) });
|
|
@@ -421,7 +421,6 @@ function dbStore(opts) {
|
|
|
421
421
|
// DELETING it would clobber another process's cache and
|
|
422
422
|
// turn a hit into a miss with potential side-effect re-
|
|
423
423
|
// execution. Treat as miss + LEAVE the row in place.
|
|
424
|
-
// Per Codex P1 on PR #45.
|
|
425
424
|
var lookedSealed = typeof liveRow.headers === "string" &&
|
|
426
425
|
(liveRow.headers.indexOf("vault:") === 0 ||
|
|
427
426
|
liveRow.headers.indexOf("vault.aad:") === 0);
|
|
@@ -456,7 +455,7 @@ function dbStore(opts) {
|
|
|
456
455
|
delete: function (rawKey) {
|
|
457
456
|
stmtDelete.run(_k(rawKey));
|
|
458
457
|
},
|
|
459
|
-
//
|
|
458
|
+
// The middleware consults this hook to HMAC the
|
|
460
459
|
// method+path+body digest under a vault-derived secret before
|
|
461
460
|
// insert + compare. Returns null when fpSeal is disabled OR the
|
|
462
461
|
// vault wasn't ready at construction; the middleware then falls
|
|
@@ -466,7 +465,7 @@ function dbStore(opts) {
|
|
|
466
465
|
return nodeCrypto.createHmac("sha3-256", fpHmacSecret)
|
|
467
466
|
.update(preimageBytes).digest("hex");
|
|
468
467
|
},
|
|
469
|
-
//
|
|
468
|
+
// Operator helper: walk the table and reseal every row
|
|
470
469
|
// under the AAD form. Existing v0.9.15-v0.9.57 rows continue to
|
|
471
470
|
// read on a per-row basis (unsealRow auto-detects shape), but
|
|
472
471
|
// operators wanting an explicit migration step call this once.
|
|
@@ -526,7 +525,7 @@ function _fingerprintRequest(req, bodyBytes, store) {
|
|
|
526
525
|
// Fingerprint preimage = method + path + body. Per the draft §4.3,
|
|
527
526
|
// a key+body mismatch is a client-side mistake; our preimage covers
|
|
528
527
|
// method + path so a client reusing a key across different
|
|
529
|
-
// endpoints is also caught.
|
|
528
|
+
// endpoints is also caught. When the store exposes a
|
|
530
529
|
// `fingerprintHmac` hook (dbStore with fingerprintSeal:true + vault
|
|
531
530
|
// ready), the preimage is HMAC'd under a vault-derived secret so a
|
|
532
531
|
// DB dump leaks neither the preimage nor a brute-forceable digest.
|
|
@@ -602,7 +601,7 @@ function _emitAudit(action, metadata, outcome) {
|
|
|
602
601
|
* requireIdempotencyKey: boolean, // default: false — refuse missing-key
|
|
603
602
|
* bodyFingerprint: function, // (req) => Buffer|string|object|null — operator-supplied body extractor
|
|
604
603
|
* maxBodyBytes: number, // default: 1 MiB — replay-cache body cap
|
|
605
|
-
* bodyFingerprintFallback: string, // default "deny"
|
|
604
|
+
* bodyFingerprintFallback: string, // default "deny" — when neither
|
|
606
605
|
* // bodyFingerprint nor req._rawBody / req.body is
|
|
607
606
|
* // available for POST/PUT/PATCH, refuse with HTTP 400
|
|
608
607
|
* // idempotency/missing-body-fingerprint instead of
|
|
@@ -669,7 +668,7 @@ function create(opts) {
|
|
|
669
668
|
opts.bodyFingerprint, "idempotencyKey.bodyFingerprint",
|
|
670
669
|
IdempotencyError, "idempotency/bad-body-fingerprint"
|
|
671
670
|
) || null;
|
|
672
|
-
//
|
|
671
|
+
// Default "deny" refuses body-bearing requests that
|
|
673
672
|
// arrive with neither req._rawBody / req.body NOR an operator-
|
|
674
673
|
// supplied bodyFingerprint hook. The silent-degrade-to-method+path
|
|
675
674
|
// path was a §4.3 violation (same key + different body returned
|
|
@@ -762,7 +761,7 @@ function create(opts) {
|
|
|
762
761
|
// Misordered-mount detector — body-bearing method reached us
|
|
763
762
|
// with neither a parsed body nor a raw-body buffer. Most likely
|
|
764
763
|
// body-parser hasn't run yet, which used to silently degrade the
|
|
765
|
-
// fingerprint to method+path;
|
|
764
|
+
// fingerprint to method+path; v0.9.58 makes that
|
|
766
765
|
// case refuse with HTTP 400 by default. The audit emit fires in
|
|
767
766
|
// both fallback modes so operator review surfaces the
|
|
768
767
|
// misconfiguration regardless of the chosen fallback.
|
|
@@ -929,8 +928,8 @@ function _redactKey(key) {
|
|
|
929
928
|
* @related b.middleware.idempotencyKey.dbStore
|
|
930
929
|
*
|
|
931
930
|
* One-shot operator helper that walks a dbStore's table and reseals
|
|
932
|
-
* every row under the AAD-bound envelope shape introduced in v0.9.58
|
|
933
|
-
*
|
|
931
|
+
* every row under the AAD-bound envelope shape introduced in v0.9.58.
|
|
932
|
+
* Existing v0.9.15-v0.9.57 rows continue to read on a
|
|
934
933
|
* per-row basis (unsealRow auto-detects shape) so a deploy without
|
|
935
934
|
* this call is correct, but operators who want to upgrade in bulk
|
|
936
935
|
* call this once after upgrading.
|
package/lib/middleware/index.js
CHANGED
|
@@ -53,6 +53,7 @@ var requireAal = require("./require-aal");
|
|
|
53
53
|
var requireAuth = require("./require-auth");
|
|
54
54
|
var requireContentType = require("./require-content-type");
|
|
55
55
|
var ageGate = require("./age-gate");
|
|
56
|
+
var requireBoundKey = require("./require-bound-key");
|
|
56
57
|
var requireMethods = require("./require-methods");
|
|
57
58
|
var requireMtls = require("./require-mtls");
|
|
58
59
|
var requireStepUp = require("./require-step-up");
|
|
@@ -85,6 +86,7 @@ module.exports = {
|
|
|
85
86
|
requireAuth: requireAuth.create,
|
|
86
87
|
requireContentType: requireContentType.create,
|
|
87
88
|
ageGate: ageGate.create,
|
|
89
|
+
requireBoundKey: requireBoundKey.create,
|
|
88
90
|
requireMethods: requireMethods.create,
|
|
89
91
|
requireMtls: requireMtls.create,
|
|
90
92
|
requireStepUp: requireStepUp.create,
|
|
@@ -145,6 +147,7 @@ module.exports = {
|
|
|
145
147
|
requireAuth: requireAuth,
|
|
146
148
|
requireContentType: requireContentType,
|
|
147
149
|
ageGate: ageGate,
|
|
150
|
+
requireBoundKey: requireBoundKey,
|
|
148
151
|
requireMethods: requireMethods,
|
|
149
152
|
requireMtls: requireMtls,
|
|
150
153
|
requireStepUp: requireStepUp,
|
|
@@ -48,6 +48,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
48
48
|
var requestHelpers = require("../request-helpers");
|
|
49
49
|
var ssrfGuard = require("../ssrf-guard");
|
|
50
50
|
var validateOpts = require("../validate-opts");
|
|
51
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
51
52
|
var { defineClass } = require("../framework-error");
|
|
52
53
|
|
|
53
54
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
@@ -98,6 +99,8 @@ function _validateCidr(cidr) {
|
|
|
98
99
|
* denyStatus: number, // default 404
|
|
99
100
|
* denyBody: string, // default "Not Found"
|
|
100
101
|
* audit: object,
|
|
102
|
+
* onDeny: function(req, res, info): void, // own the refusal; info = { status, reason, clientIp, route }
|
|
103
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
101
104
|
* }
|
|
102
105
|
*
|
|
103
106
|
* @example
|
|
@@ -113,7 +116,7 @@ function create(opts) {
|
|
|
113
116
|
opts = opts || {};
|
|
114
117
|
validateOpts(opts, [
|
|
115
118
|
"paths", "allowedCidrs", "deniedCidrs", "trustProxy",
|
|
116
|
-
"denyStatus", "denyBody", "audit",
|
|
119
|
+
"denyStatus", "denyBody", "audit", "onDeny", "problemDetails",
|
|
117
120
|
], "middleware.networkAllowlist");
|
|
118
121
|
|
|
119
122
|
if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
|
|
@@ -156,6 +159,23 @@ function create(opts) {
|
|
|
156
159
|
var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Not Found";
|
|
157
160
|
var auditOn = opts.audit !== false && opts.audit != null;
|
|
158
161
|
var auditInstance = opts.audit === true ? null : opts.audit; // null → use lazy-required default
|
|
162
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
163
|
+
var problemMode = opts.problemDetails === true;
|
|
164
|
+
|
|
165
|
+
function _deny(req, res, ip, route) {
|
|
166
|
+
_emitDeny(req, ip, route);
|
|
167
|
+
denyResponse(req, res, {
|
|
168
|
+
onDeny: onDeny,
|
|
169
|
+
problem: problemMode,
|
|
170
|
+
status: denyStatus,
|
|
171
|
+
info: { status: denyStatus, reason: "ip-not-in-allowlist", clientIp: ip, route: route },
|
|
172
|
+
problemCode: "network-gate-denied",
|
|
173
|
+
problemTitle: denyBody,
|
|
174
|
+
problemDetail: "Access to this resource is restricted by network policy.",
|
|
175
|
+
contentType: "text/plain",
|
|
176
|
+
body: denyBody,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
159
179
|
|
|
160
180
|
function _emitDeny(req, ip, route) {
|
|
161
181
|
if (!auditOn) return;
|
|
@@ -196,9 +216,7 @@ function create(opts) {
|
|
|
196
216
|
if (!ip) {
|
|
197
217
|
// Fail closed: a request we can't even derive an IP for shouldn't
|
|
198
218
|
// bypass the gate.
|
|
199
|
-
|
|
200
|
-
res.writeHead(denyStatus, { "Content-Type": "text/plain" });
|
|
201
|
-
res.end(denyBody);
|
|
219
|
+
_deny(req, res, "<unknown>", pathname);
|
|
202
220
|
return;
|
|
203
221
|
}
|
|
204
222
|
|
|
@@ -208,9 +226,7 @@ function create(opts) {
|
|
|
208
226
|
for (var dii = 0; dii < deniedCidrs.length; dii++) {
|
|
209
227
|
try {
|
|
210
228
|
if (ssrfGuard.cidrContains(deniedCidrs[dii], ip)) {
|
|
211
|
-
|
|
212
|
-
res.writeHead(denyStatus, { "Content-Type": "text/plain" });
|
|
213
|
-
res.end(denyBody);
|
|
229
|
+
_deny(req, res, ip, pathname);
|
|
214
230
|
return;
|
|
215
231
|
}
|
|
216
232
|
} catch (_e) { /* skip malformed at runtime — caught at config */ }
|
|
@@ -222,9 +238,7 @@ function create(opts) {
|
|
|
222
238
|
} catch (_e) { /* skip malformed at runtime — caught at config */ }
|
|
223
239
|
}
|
|
224
240
|
if (!allowed) {
|
|
225
|
-
|
|
226
|
-
res.writeHead(denyStatus, { "Content-Type": "text/plain" });
|
|
227
|
-
res.end(denyBody);
|
|
241
|
+
_deny(req, res, ip, pathname);
|
|
228
242
|
return;
|
|
229
243
|
}
|
|
230
244
|
return next();
|
|
@@ -94,7 +94,7 @@ function create(opts) {
|
|
|
94
94
|
"middleware/protected-resource-metadata/no-as",
|
|
95
95
|
"authorizationServers must be a non-empty array of issuer URLs");
|
|
96
96
|
}
|
|
97
|
-
//
|
|
97
|
+
// RFC 9728 §3 + RFC 8414 §3.1: authorizationServers entries
|
|
98
98
|
// are issuer URLs and MUST be https://. Pre-v0.9.x only required
|
|
99
99
|
// non-empty string, so an operator typo could ship `http://idp.test`
|
|
100
100
|
// (or, worse, `javascript:` / `data:`) to clients via the well-known
|
|
@@ -156,7 +156,7 @@ function create(opts) {
|
|
|
156
156
|
if (opts.dpopBoundAccessTokensRequired === true) doc.dpop_bound_access_tokens_required = true;
|
|
157
157
|
if (opts.mtlsBoundAccessTokensRequired === true) doc.tls_client_certificate_bound_access_tokens = true;
|
|
158
158
|
|
|
159
|
-
//
|
|
159
|
+
// RFC 9728 §3.2 signed_metadata. Operators with an
|
|
160
160
|
// anti-tamper requirement pass `signMetadata: { key, alg, kid }`;
|
|
161
161
|
// the middleware emits `application/jwt` carrying the JWS-signed
|
|
162
162
|
// metadata. Default output remains cleartext `application/json`.
|