@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
|
@@ -37,21 +37,34 @@
|
|
|
37
37
|
var lazyRequire = require("../lazy-require");
|
|
38
38
|
var requestHelpers = require("../request-helpers");
|
|
39
39
|
var validateOpts = require("../validate-opts");
|
|
40
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
40
41
|
var { AuthError } = require("../framework-error");
|
|
41
42
|
|
|
42
43
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
43
44
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
// Shared 401/403 refusal writer for every bearer-auth deny path —
|
|
47
|
+
// routes through denyResponse so a consumer can override via onDeny
|
|
48
|
+
// or emit RFC 9457 application/problem+json via problemDetails.
|
|
49
|
+
function _refuse(req, res, status, challenge, bodyObj, reason, problemExt, onDeny, problemMode) {
|
|
50
|
+
denyResponse(req, res, {
|
|
51
|
+
onDeny: onDeny,
|
|
52
|
+
problem: problemMode,
|
|
53
|
+
status: status,
|
|
54
|
+
info: Object.assign({ status: status, reason: reason }, problemExt || {}),
|
|
55
|
+
problemCode: "bearer-" + reason,
|
|
56
|
+
problemTitle: status === 403 ? "Forbidden" : "Unauthorized",
|
|
57
|
+
problemDetail: typeof bodyObj.error === "string" ? bodyObj.error : ("bearer authentication failed: " + reason),
|
|
58
|
+
problemExt: problemExt || null,
|
|
59
|
+
headers: { "WWW-Authenticate": challenge },
|
|
60
|
+
contentType: "application/json; charset=utf-8",
|
|
61
|
+
body: JSON.stringify(bodyObj),
|
|
53
62
|
});
|
|
54
|
-
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _writeUnauthorized(req, res, scheme, message, realm, onDeny, problemMode) {
|
|
66
|
+
var challenge = scheme + (realm ? ' realm="' + realm + '"' : "");
|
|
67
|
+
_refuse(req, res, 401, challenge, { error: message }, "unauthorized", null, onDeny, problemMode);
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
// Three-state extractor: { state: "absent" } when no Authorization
|
|
@@ -102,10 +115,13 @@ function _extractToken(req, scheme) {
|
|
|
102
115
|
* verify: async function(token): user|null, // required
|
|
103
116
|
* scheme: string, // default "Bearer"; some ops use "Token"
|
|
104
117
|
* realm: string,
|
|
118
|
+
* requiredScopes: string[], // RFC 6750 §3 — refuse 403 insufficient_scope when the verified token lacks one
|
|
105
119
|
* errorMessage: string,
|
|
106
120
|
* tokenAttachKey: string,
|
|
107
121
|
* userAttachKey: string,
|
|
108
122
|
* audit: boolean, // default true
|
|
123
|
+
* onDeny: function(req, res, info): void, // own the 401/403; info = { status, reason, ... }
|
|
124
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
109
125
|
* }
|
|
110
126
|
*
|
|
111
127
|
* @example
|
|
@@ -122,7 +138,7 @@ function create(opts) {
|
|
|
122
138
|
opts = opts || {};
|
|
123
139
|
validateOpts(opts, [
|
|
124
140
|
"verify", "audit", "scheme", "errorMessage", "realm",
|
|
125
|
-
"tokenAttachKey", "userAttachKey",
|
|
141
|
+
"tokenAttachKey", "userAttachKey", "requiredScopes", "onDeny", "problemDetails",
|
|
126
142
|
], "middleware.bearerAuth");
|
|
127
143
|
|
|
128
144
|
if (typeof opts.verify !== "function") {
|
|
@@ -131,6 +147,8 @@ function create(opts) {
|
|
|
131
147
|
"the verification path (b.apiKey.verify / b.auth.jwt.verifyExternal / custom)");
|
|
132
148
|
}
|
|
133
149
|
var auditOn = opts.audit !== false;
|
|
150
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
151
|
+
var problemMode = opts.problemDetails === true;
|
|
134
152
|
var scheme = opts.scheme || "Bearer";
|
|
135
153
|
var errorMessage = opts.errorMessage || "Bearer token required.";
|
|
136
154
|
var realm = opts.realm || null;
|
|
@@ -197,13 +215,8 @@ function create(opts) {
|
|
|
197
215
|
if (!res.headersSent) {
|
|
198
216
|
var malformedChallenge = scheme + ' error="invalid_request"' +
|
|
199
217
|
(realm ? ', realm="' + realm + '"' : "");
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
203
|
-
"Content-Length": Buffer.byteLength(malformedBody),
|
|
204
|
-
"WWW-Authenticate": malformedChallenge,
|
|
205
|
-
});
|
|
206
|
-
res.end(malformedBody);
|
|
218
|
+
_refuse(req, res, 401, malformedChallenge, { error: errorMessage }, // HTTP 401 status
|
|
219
|
+
"malformed-authorization", null, onDeny, problemMode);
|
|
207
220
|
}
|
|
208
221
|
return;
|
|
209
222
|
}
|
|
@@ -221,13 +234,8 @@ function create(opts) {
|
|
|
221
234
|
var challenge = scheme + ' error="invalid_token"' +
|
|
222
235
|
(realm ? ', realm="' + realm + '"' : "");
|
|
223
236
|
if (!res.headersSent) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
227
|
-
"Content-Length": Buffer.byteLength(body),
|
|
228
|
-
"WWW-Authenticate": challenge,
|
|
229
|
-
});
|
|
230
|
-
res.end(body);
|
|
237
|
+
_refuse(req, res, 401, challenge, { error: errorMessage }, // HTTP 401 status
|
|
238
|
+
"invalid-token", null, onDeny, problemMode);
|
|
231
239
|
}
|
|
232
240
|
return;
|
|
233
241
|
}
|
|
@@ -235,7 +243,7 @@ function create(opts) {
|
|
|
235
243
|
if (!user) {
|
|
236
244
|
_emitAudit("auth.bearer.failure", "failure", req, "verifier-returned-null");
|
|
237
245
|
_emitObs("auth.bearer.rejected", 1, { reason: "verifier-null" });
|
|
238
|
-
_writeUnauthorized(res, scheme, errorMessage, realm);
|
|
246
|
+
_writeUnauthorized(req, res, scheme, errorMessage, realm, onDeny, problemMode);
|
|
239
247
|
return;
|
|
240
248
|
}
|
|
241
249
|
|
|
@@ -260,16 +268,9 @@ function create(opts) {
|
|
|
260
268
|
var scopeChallenge = scheme + ' error="insufficient_scope"' +
|
|
261
269
|
', scope="' + opts.requiredScopes.join(" ") + '"' +
|
|
262
270
|
(realm ? ', realm="' + realm + '"' : "");
|
|
263
|
-
|
|
264
|
-
error: "insufficient_scope",
|
|
265
|
-
required: opts.requiredScopes.slice(),
|
|
266
|
-
});
|
|
267
|
-
res.writeHead(403, { // HTTP 403 status
|
|
268
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
269
|
-
"Content-Length": Buffer.byteLength(scopeBody),
|
|
270
|
-
"WWW-Authenticate": scopeChallenge,
|
|
271
|
-
});
|
|
272
|
-
res.end(scopeBody);
|
|
271
|
+
_refuse(req, res, 403, scopeChallenge, // HTTP 403 status
|
|
272
|
+
{ error: "insufficient_scope", required: opts.requiredScopes.slice() },
|
|
273
|
+
"insufficient-scope", { required: opts.requiredScopes.slice() }, onDeny, problemMode);
|
|
273
274
|
}
|
|
274
275
|
return;
|
|
275
276
|
}
|
|
@@ -51,6 +51,7 @@ var DEFAULT_BLOCKED_AGENTS = [
|
|
|
51
51
|
var lazyRequire = require("../lazy-require");
|
|
52
52
|
var requestHelpers = require("../request-helpers");
|
|
53
53
|
var validateOpts = require("../validate-opts");
|
|
54
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
54
55
|
var { defineClass } = require("../framework-error");
|
|
55
56
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
56
57
|
|
|
@@ -112,6 +113,8 @@ function _xffIpFor(trustProxy) {
|
|
|
112
113
|
* skipPaths: string[],
|
|
113
114
|
* statusOnBlock: number, // default 403
|
|
114
115
|
* bodyOnBlock: string,
|
|
116
|
+
* onDeny: function(req, res, info): void, // own the block response; info = { status, reason }
|
|
117
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
115
118
|
* trustProxy: boolean|number,
|
|
116
119
|
* }
|
|
117
120
|
*
|
|
@@ -128,7 +131,7 @@ function create(opts) {
|
|
|
128
131
|
opts = opts || {};
|
|
129
132
|
validateOpts(opts, [
|
|
130
133
|
"mode", "onlyForHtml", "allowedAgents", "blockedAgents",
|
|
131
|
-
"skipPaths", "statusOnBlock", "bodyOnBlock", "trustProxy",
|
|
134
|
+
"skipPaths", "statusOnBlock", "bodyOnBlock", "onDeny", "problemDetails", "trustProxy",
|
|
132
135
|
], "middleware.botGuard");
|
|
133
136
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
134
137
|
? opts.trustProxy : false;
|
|
@@ -144,6 +147,8 @@ function create(opts) {
|
|
|
144
147
|
var skipPaths = opts.skipPaths || [];
|
|
145
148
|
var statusOnBlock = opts.statusOnBlock || 403;
|
|
146
149
|
var bodyOnBlock = opts.bodyOnBlock !== undefined ? opts.bodyOnBlock : "Forbidden";
|
|
150
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
151
|
+
var problemMode = opts.problemDetails === true;
|
|
147
152
|
|
|
148
153
|
function _shouldSkip(req) {
|
|
149
154
|
var path = req.pathname || req.url || "/";
|
|
@@ -240,10 +245,17 @@ function create(opts) {
|
|
|
240
245
|
} catch (_e) { /* audit best-effort */ }
|
|
241
246
|
|
|
242
247
|
if (res.writableEnded) return;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
248
|
+
denyResponse(req, res, {
|
|
249
|
+
onDeny: onDeny,
|
|
250
|
+
problem: problemMode,
|
|
251
|
+
status: statusOnBlock,
|
|
252
|
+
info: { status: statusOnBlock, reason: hit },
|
|
253
|
+
problemCode: "bot-blocked",
|
|
254
|
+
problemTitle: "Forbidden",
|
|
255
|
+
problemDetail: "The request was identified as automated traffic and refused.",
|
|
256
|
+
contentType: "text/plain",
|
|
257
|
+
body: bodyOnBlock,
|
|
258
|
+
});
|
|
247
259
|
// Don't call next() — terminate the chain
|
|
248
260
|
};
|
|
249
261
|
}
|
package/lib/middleware/cors.js
CHANGED
|
@@ -55,6 +55,7 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
55
55
|
var requestHelpers = require("../request-helpers");
|
|
56
56
|
var safeUrl = require("../safe-url");
|
|
57
57
|
var validateOpts = require("../validate-opts");
|
|
58
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
58
59
|
var { defineClass } = require("../framework-error");
|
|
59
60
|
|
|
60
61
|
// CORS audit events use the proxy-aware client IP only when the
|
|
@@ -185,6 +186,8 @@ function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy, str
|
|
|
185
186
|
* refuseUnknown: boolean, // default true
|
|
186
187
|
* strictNullOrigin: boolean, // default true
|
|
187
188
|
* trustProxy: boolean|number,
|
|
189
|
+
* onDeny: function(req, res, info): void, // own every refusal; info = { status, reason, origin, header? }
|
|
190
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
|
|
188
191
|
* }
|
|
189
192
|
*
|
|
190
193
|
* @example
|
|
@@ -202,7 +205,7 @@ function create(opts) {
|
|
|
202
205
|
validateOpts(opts, [
|
|
203
206
|
"origins", "siteOrigin", "methods", "headers", "exposeHeaders",
|
|
204
207
|
"credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
|
|
205
|
-
"strictNullOrigin", "allowPrivateNetwork",
|
|
208
|
+
"strictNullOrigin", "allowPrivateNetwork", "onDeny", "problemDetails",
|
|
206
209
|
], "middleware.cors");
|
|
207
210
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
208
211
|
? opts.trustProxy : false;
|
|
@@ -269,6 +272,22 @@ function create(opts) {
|
|
|
269
272
|
// header). Operators with a no-referrer page producing legitimate
|
|
270
273
|
// Origin: null on same-origin POSTs flip to false explicitly.
|
|
271
274
|
var strictNullOrigin = opts.strictNullOrigin !== false;
|
|
275
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
276
|
+
var problemMode = opts.problemDetails === true;
|
|
277
|
+
|
|
278
|
+
function _refuse(req, res, reason, body, ext) {
|
|
279
|
+
denyResponse(req, res, {
|
|
280
|
+
onDeny: onDeny,
|
|
281
|
+
problem: problemMode,
|
|
282
|
+
status: requestHelpers.HTTP_STATUS.FORBIDDEN,
|
|
283
|
+
info: Object.assign({ status: 403, reason: reason }, ext || {}),
|
|
284
|
+
problemCode: "cors-refused",
|
|
285
|
+
problemTitle: "Forbidden",
|
|
286
|
+
problemDetail: body,
|
|
287
|
+
contentType: "text/plain",
|
|
288
|
+
body: body,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
272
291
|
|
|
273
292
|
return function cors(req, res, next) {
|
|
274
293
|
var origin = req.headers && req.headers.origin;
|
|
@@ -302,9 +321,8 @@ function create(opts) {
|
|
|
302
321
|
requestId: req.requestId,
|
|
303
322
|
});
|
|
304
323
|
} catch (_e) { /* audit best-effort */ }
|
|
305
|
-
if (typeof res.writeHead === "function") {
|
|
306
|
-
|
|
307
|
-
res.end("CORS: origin not allowed");
|
|
324
|
+
if (typeof res.writeHead === "function" || onDeny) {
|
|
325
|
+
_refuse(req, res, "origin-not-allowed", "CORS: origin not allowed", { origin: origin });
|
|
308
326
|
return;
|
|
309
327
|
}
|
|
310
328
|
}
|
|
@@ -333,10 +351,9 @@ function create(opts) {
|
|
|
333
351
|
var asked = requestHelpers.parseListHeader(requestedHdrs, { lowercase: true });
|
|
334
352
|
for (var ah = 0; ah < asked.length; ah++) {
|
|
335
353
|
if (allowedHeadersSet.indexOf(asked[ah]) === -1) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
354
|
+
_refuse(req, res, "requested-header-not-allowed",
|
|
355
|
+
"CORS: requested header '" + asked[ah] + "' not in allow-list",
|
|
356
|
+
{ origin: origin, header: asked[ah] });
|
|
340
357
|
return;
|
|
341
358
|
}
|
|
342
359
|
}
|
|
@@ -357,10 +374,9 @@ function create(opts) {
|
|
|
357
374
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
358
375
|
}
|
|
359
376
|
} else {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
377
|
+
_refuse(req, res, "private-network-not-permitted",
|
|
378
|
+
"CORS: Private Network Access not permitted (set allowPrivateNetwork:true with audited reason to opt in)",
|
|
379
|
+
{ origin: origin });
|
|
364
380
|
return;
|
|
365
381
|
}
|
|
366
382
|
}
|
|
@@ -70,6 +70,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
70
70
|
var forms = require("../forms");
|
|
71
71
|
var requestHelpers = require("../request-helpers");
|
|
72
72
|
var validateOpts = require("../validate-opts");
|
|
73
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
73
74
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
74
75
|
|
|
75
76
|
var DEFAULT_FIELD_NAME = "_csrf";
|
|
@@ -218,15 +219,18 @@ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn, requireOrigin) {
|
|
|
218
219
|
return null;
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
function _writeReject(res, message) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
function _writeReject(req, res, message, reason, onDeny, problemMode) {
|
|
223
|
+
denyResponse(req, res, {
|
|
224
|
+
onDeny: onDeny,
|
|
225
|
+
problem: problemMode,
|
|
226
|
+
status: requestHelpers.HTTP_STATUS.FORBIDDEN,
|
|
227
|
+
info: { status: 403, reason: reason },
|
|
228
|
+
problemCode: "csrf-refused",
|
|
229
|
+
problemTitle: "Forbidden",
|
|
230
|
+
problemDetail: message,
|
|
231
|
+
contentType: "application/json; charset=utf-8",
|
|
232
|
+
body: JSON.stringify({ error: message }),
|
|
233
|
+
});
|
|
230
234
|
}
|
|
231
235
|
|
|
232
236
|
/**
|
|
@@ -262,6 +266,8 @@ function _writeReject(res, message) {
|
|
|
262
266
|
* trustProxy: boolean|number,
|
|
263
267
|
* audit: boolean,
|
|
264
268
|
* skipStateless: boolean, // default false — skip validation for Authorization-header / cookieless (not-CSRF-able) requests
|
|
269
|
+
* onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
|
|
270
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
265
271
|
* }
|
|
266
272
|
*
|
|
267
273
|
* @example
|
|
@@ -279,8 +285,10 @@ function create(opts) {
|
|
|
279
285
|
validateOpts(opts, [
|
|
280
286
|
"cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
|
|
281
287
|
"trustProxy", "checkOrigin", "allowedOrigins", "requireJsonContentType",
|
|
282
|
-
"requireOrigin", "skipStateless",
|
|
288
|
+
"requireOrigin", "skipStateless", "onDeny", "problemDetails",
|
|
283
289
|
], "middleware.csrfProtect");
|
|
290
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
|
|
291
|
+
var problemMode = opts.problemDetails === true;
|
|
284
292
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
285
293
|
? opts.trustProxy : false;
|
|
286
294
|
var _isHttps = _isHttpsFor(trustProxy);
|
|
@@ -308,7 +316,7 @@ function create(opts) {
|
|
|
308
316
|
// refuse before the token check.
|
|
309
317
|
//
|
|
310
318
|
// Default: enabled (defense-in-depth — same shape as bot-guard /
|
|
311
|
-
// rate-limit / CSP nonce — every default ON
|
|
319
|
+
// rate-limit / CSP nonce — every default ON).
|
|
312
320
|
// Operator opt-out: opts.checkOrigin = false.
|
|
313
321
|
// Operator allowlist: opts.allowedOrigins = ["https://app.example.com"].
|
|
314
322
|
var checkOrigin = opts.checkOrigin !== false;
|
|
@@ -477,7 +485,7 @@ function create(opts) {
|
|
|
477
485
|
var bare = (typeof ct === "string" ? ct.split(";")[0].trim().toLowerCase() : "");
|
|
478
486
|
if (bare !== "application/json") {
|
|
479
487
|
_emitDenied(req, "non-JSON content-type: " + (bare || "<absent>"));
|
|
480
|
-
return _writeReject(res, "CSRF: state-changing requests require Content-Type: application/json.");
|
|
488
|
+
return _writeReject(req, res, "CSRF: state-changing requests require Content-Type: application/json.", "content-type-required", onDeny, problemMode);
|
|
481
489
|
}
|
|
482
490
|
}
|
|
483
491
|
|
|
@@ -489,7 +497,7 @@ function create(opts) {
|
|
|
489
497
|
var originReason = _checkOriginAllowed(req, allowedOrigins, _isHttps, requireOriginOpt);
|
|
490
498
|
if (originReason !== null) {
|
|
491
499
|
_emitDenied(req, "origin/referer: " + originReason);
|
|
492
|
-
return _writeReject(res, "CSRF cross-origin request refused.");
|
|
500
|
+
return _writeReject(req, res, "CSRF cross-origin request refused.", "cross-origin-refused", onDeny, problemMode);
|
|
493
501
|
}
|
|
494
502
|
}
|
|
495
503
|
|
|
@@ -499,7 +507,7 @@ function create(opts) {
|
|
|
499
507
|
}
|
|
500
508
|
if (!expected) {
|
|
501
509
|
_emitDenied(req, cookieCfg ? "no token cookie issued yet" : "no expected token in session");
|
|
502
|
-
return _writeReject(res, "CSRF token mismatch.");
|
|
510
|
+
return _writeReject(req, res, "CSRF token mismatch.", "token-mismatch", onDeny, problemMode);
|
|
503
511
|
}
|
|
504
512
|
|
|
505
513
|
// Header path first — covers JSON / AJAX / multipart cases.
|
|
@@ -517,7 +525,7 @@ function create(opts) {
|
|
|
517
525
|
|
|
518
526
|
if (!forms.verifyCsrfToken(submitted || "", expected)) {
|
|
519
527
|
_emitDenied(req, "submitted token does not match expected");
|
|
520
|
-
return _writeReject(res, "CSRF token mismatch.");
|
|
528
|
+
return _writeReject(req, res, "CSRF token mismatch.", "token-mismatch", onDeny, problemMode);
|
|
521
529
|
}
|
|
522
530
|
|
|
523
531
|
return next();
|
|
@@ -41,6 +41,7 @@ var defineClass = require("../framework-error").defineClass;
|
|
|
41
41
|
var lazyRequire = require("../lazy-require");
|
|
42
42
|
var networkByteQuota = require("../network-byte-quota");
|
|
43
43
|
var validateOpts = require("../validate-opts");
|
|
44
|
+
var denyResponse = require("./deny-response").denyResponse;
|
|
44
45
|
|
|
45
46
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
46
47
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
@@ -76,7 +77,9 @@ function _defaultGetKey(req) {
|
|
|
76
77
|
* bytesPerDay: number, // required, positive, finite
|
|
77
78
|
* getKey: function(req): string|null, // default: req client IP
|
|
78
79
|
* cache: object, // null = in-memory single-node
|
|
79
|
-
*
|
|
80
|
+
* onDeny: function(req, res, info): void, // own the 429; info = { status, reason, quota, total, retryAfterSec }
|
|
81
|
+
* onExceeded: function(req, res, info): void, // legacy alias for onDeny
|
|
82
|
+
* problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
|
|
80
83
|
* skipPaths: string[],
|
|
81
84
|
* now: function(): number,
|
|
82
85
|
* audit: boolean, // default true
|
|
@@ -94,7 +97,7 @@ function create(opts) {
|
|
|
94
97
|
opts = opts || {};
|
|
95
98
|
validateOpts(opts, [
|
|
96
99
|
"bytesPerDay", "cache", "getKey", "audit",
|
|
97
|
-
"onExceeded", "skipPaths", "now",
|
|
100
|
+
"onDeny", "onExceeded", "problemDetails", "skipPaths", "now",
|
|
98
101
|
], "middleware.dailyByteQuota");
|
|
99
102
|
|
|
100
103
|
if (typeof opts.bytesPerDay !== "number" || !isFinite(opts.bytesPerDay) || opts.bytesPerDay <= 0) {
|
|
@@ -105,7 +108,11 @@ function create(opts) {
|
|
|
105
108
|
var bytesPerDay = opts.bytesPerDay;
|
|
106
109
|
var getKey = typeof opts.getKey === "function" ? opts.getKey : _defaultGetKey;
|
|
107
110
|
var auditOn = opts.audit !== false;
|
|
108
|
-
|
|
111
|
+
// onDeny is the canonical hook across the deny-path middleware
|
|
112
|
+
// family; onExceeded is the original name kept working as an alias.
|
|
113
|
+
var onDeny = typeof opts.onDeny === "function" ? opts.onDeny
|
|
114
|
+
: (typeof opts.onExceeded === "function" ? opts.onExceeded : null);
|
|
115
|
+
var problemMode = opts.problemDetails === true;
|
|
109
116
|
var skipPaths = Array.isArray(opts.skipPaths) ? opts.skipPaths.slice() : [];
|
|
110
117
|
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
111
118
|
|
|
@@ -169,22 +176,29 @@ function create(opts) {
|
|
|
169
176
|
_emitMetric("refused", 1, { reason: "quota-exceeded" });
|
|
170
177
|
_emitAudit("refused", "denied", { key: key, total: total, quota: bytesPerDay });
|
|
171
178
|
var info = {
|
|
179
|
+
status: 429,
|
|
180
|
+
reason: "quota-exceeded",
|
|
172
181
|
quota: bytesPerDay,
|
|
173
182
|
total: total,
|
|
174
183
|
retryAfterSec: Math.ceil(C.TIME.hours(1) / C.TIME.seconds(1)),
|
|
175
184
|
};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
denyResponse(req, res, {
|
|
186
|
+
onDeny: onDeny,
|
|
187
|
+
problem: problemMode,
|
|
188
|
+
status: 429,
|
|
189
|
+
info: info,
|
|
190
|
+
problemCode: "daily-byte-quota-exceeded",
|
|
191
|
+
problemTitle: "Too Many Requests",
|
|
192
|
+
problemDetail: "Daily byte quota exceeded; retry after the indicated interval.",
|
|
193
|
+
problemExt: { quota: bytesPerDay, total: total, retryAfter: info.retryAfterSec },
|
|
194
|
+
headers: {
|
|
183
195
|
"Retry-After": String(info.retryAfterSec),
|
|
184
196
|
"Cache-Control": "no-store",
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
},
|
|
198
|
+
contentType: "application/json; charset=utf-8",
|
|
199
|
+
body: JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }),
|
|
200
|
+
onThrow: function (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); },
|
|
201
|
+
});
|
|
188
202
|
return;
|
|
189
203
|
}
|
|
190
204
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared deny-path response writer for the request-lifecycle
|
|
4
|
+
* middlewares that refuse a request (401 / 403 / 405 / 415 / 429 /
|
|
5
|
+
* 451 / misdirected-request). Every deny-path middleware routes its
|
|
6
|
+
* refusal through `denyResponse` so a consumer gets one uniform way
|
|
7
|
+
* to shape it, instead of each middleware hardcoding its own body +
|
|
8
|
+
* Content-Type.
|
|
9
|
+
*
|
|
10
|
+
* Three resolution modes, checked in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. `onDeny(req, res, info)` operator hook — when supplied, the
|
|
13
|
+
* consumer owns the response. `info` carries the machine
|
|
14
|
+
* `status` / `reason` plus the middleware-specific fields. The
|
|
15
|
+
* hook is wrapped so a throw is audited (via `ctx.onThrow`) and
|
|
16
|
+
* then falls through to the default write rather than crashing
|
|
17
|
+
* the request that triggered the refusal. A hook that returns
|
|
18
|
+
* without writing also falls through — the response can never
|
|
19
|
+
* hang on a no-op hook.
|
|
20
|
+
*
|
|
21
|
+
* 2. `problem: true` — emit RFC 9457 `application/problem+json` by
|
|
22
|
+
* composing `b.problemDetails`. The middleware supplies the
|
|
23
|
+
* `type` / `title` / `detail` and any extension members; the
|
|
24
|
+
* deny-path response headers (`Allow` / `WWW-Authenticate` /
|
|
25
|
+
* `Retry-After` / `Accept`) are merged onto the problem
|
|
26
|
+
* response so content negotiation does not drop them.
|
|
27
|
+
*
|
|
28
|
+
* 3. Default — the middleware's existing body + Content-Type. No
|
|
29
|
+
* behavior change when neither knob is set.
|
|
30
|
+
*
|
|
31
|
+
* This is an internal helper (no public `b.*` surface); the consumer
|
|
32
|
+
* contract is the `onDeny` / `problemDetails` opts documented on each
|
|
33
|
+
* middleware that composes it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var problemDetails = require("../problem-details");
|
|
37
|
+
|
|
38
|
+
function _isFn(x) { return typeof x === "function"; }
|
|
39
|
+
|
|
40
|
+
function _mergeInto(target, extra) {
|
|
41
|
+
if (!extra || typeof extra !== "object") return target;
|
|
42
|
+
var keys = Object.keys(extra);
|
|
43
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
44
|
+
target[keys[i]] = extra[keys[i]];
|
|
45
|
+
}
|
|
46
|
+
return target;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a deny-path refusal through the uniform hook / problem+json
|
|
51
|
+
* / default chain. Returns whatever the `onDeny` hook returns when it
|
|
52
|
+
* owns the response, otherwise `undefined`.
|
|
53
|
+
*
|
|
54
|
+
* ctx fields:
|
|
55
|
+
* onDeny: function (req, res, info) | null — operator hook
|
|
56
|
+
* problem: boolean — emit application/problem+json
|
|
57
|
+
* status: number — HTTP status (100..599)
|
|
58
|
+
* info: object — passed verbatim to onDeny; also seeds
|
|
59
|
+
* the problem document (status / reason)
|
|
60
|
+
* problemType: string? — RFC 9457 `type` (URI reference); when
|
|
61
|
+
* absent, built from `problemCode`
|
|
62
|
+
* problemCode: string? — type-URI suffix; resolves to
|
|
63
|
+
* `<problemDetails base>/<code>` (the same
|
|
64
|
+
* `<base>/<code>` convention as fromError)
|
|
65
|
+
* problemTitle: string? — RFC 9457 `title`
|
|
66
|
+
* problemDetail: string? — RFC 9457 `detail`
|
|
67
|
+
* problemExt: object? — extra problem members (reserved names
|
|
68
|
+
* dropped); siblings per RFC 9457 §3.2
|
|
69
|
+
* headers: object? — extra response headers (Allow /
|
|
70
|
+
* WWW-Authenticate / Retry-After / Accept
|
|
71
|
+
* / Cache-Control)
|
|
72
|
+
* contentType: string — default-mode Content-Type
|
|
73
|
+
* body: string|Buffer — default-mode body
|
|
74
|
+
* onThrow: function (err) ? — audit sink when onDeny throws
|
|
75
|
+
*/
|
|
76
|
+
function denyResponse(req, res, ctx) {
|
|
77
|
+
var info = (ctx.info && typeof ctx.info === "object") ? ctx.info : {};
|
|
78
|
+
|
|
79
|
+
if (_isFn(ctx.onDeny)) {
|
|
80
|
+
try {
|
|
81
|
+
var returned = ctx.onDeny(req, res, info);
|
|
82
|
+
if (res.writableEnded) return returned;
|
|
83
|
+
// Hook ran but did not write — fall through to the default so
|
|
84
|
+
// the response can never hang on a no-op hook.
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (_isFn(ctx.onThrow)) {
|
|
87
|
+
try { ctx.onThrow(e); } catch (_e) { /* drop-silent */ }
|
|
88
|
+
}
|
|
89
|
+
if (res.writableEnded) return undefined;
|
|
90
|
+
// Hook threw before writing — fall through to the default.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (res.writableEnded || !_isFn(res.writeHead)) return undefined;
|
|
95
|
+
|
|
96
|
+
var extra = (ctx.headers && typeof ctx.headers === "object") ? ctx.headers : null;
|
|
97
|
+
|
|
98
|
+
if (ctx.problem) {
|
|
99
|
+
var fields = { status: ctx.status };
|
|
100
|
+
if (ctx.problemType) fields.type = ctx.problemType;
|
|
101
|
+
if (ctx.problemTitle) fields.title = ctx.problemTitle;
|
|
102
|
+
if (ctx.problemDetail) fields.detail = ctx.problemDetail;
|
|
103
|
+
if (ctx.problemExt && typeof ctx.problemExt === "object") {
|
|
104
|
+
var ek = Object.keys(ctx.problemExt);
|
|
105
|
+
for (var i = 0; i < ek.length; i += 1) {
|
|
106
|
+
if (problemDetails.RESERVED_FIELDS.indexOf(ek[i]) === -1) {
|
|
107
|
+
fields[ek[i]] = ctx.problemExt[ek[i]];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
var problem;
|
|
112
|
+
try {
|
|
113
|
+
problem = problemDetails.create(fields);
|
|
114
|
+
} catch (_e) {
|
|
115
|
+
// A bad extension shape (prototype-pollution key, out-of-range
|
|
116
|
+
// status) must not turn a refusal into a 500 — degrade to the
|
|
117
|
+
// bare status document.
|
|
118
|
+
problem = problemDetails.create({ status: ctx.status });
|
|
119
|
+
}
|
|
120
|
+
// Set the deny-path headers before respond() so content
|
|
121
|
+
// negotiation does not lose Allow / WWW-Authenticate / Retry-After.
|
|
122
|
+
if (extra) {
|
|
123
|
+
var hk = Object.keys(extra);
|
|
124
|
+
for (var h = 0; h < hk.length; h += 1) {
|
|
125
|
+
res.setHeader(hk[h], extra[hk[h]]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
problemDetails.respond(res, problem);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
var head = _mergeInto({ "Content-Type": ctx.contentType }, extra);
|
|
133
|
+
res.writeHead(ctx.status, head);
|
|
134
|
+
res.end((ctx.body === undefined || ctx.body === null) ? "" : ctx.body);
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
denyResponse: denyResponse,
|
|
140
|
+
};
|