@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -2
  3. package/lib/agent-event-bus.js +4 -4
  4. package/lib/agent-idempotency.js +6 -6
  5. package/lib/agent-orchestrator.js +9 -9
  6. package/lib/agent-posture-chain.js +10 -10
  7. package/lib/agent-saga.js +6 -7
  8. package/lib/agent-snapshot.js +8 -8
  9. package/lib/agent-stream.js +3 -3
  10. package/lib/agent-tenant.js +4 -4
  11. package/lib/agent-trace.js +5 -5
  12. package/lib/ai-disclosure.js +3 -3
  13. package/lib/app.js +2 -2
  14. package/lib/archive-read.js +1 -1
  15. package/lib/archive-tar-read.js +1 -1
  16. package/lib/archive-wrap.js +5 -5
  17. package/lib/audit-tools.js +65 -5
  18. package/lib/audit.js +2 -2
  19. package/lib/auth/ciba.js +1 -1
  20. package/lib/auth/dpop.js +1 -1
  21. package/lib/auth/fal.js +1 -1
  22. package/lib/auth/fido-mds3.js +2 -3
  23. package/lib/auth/jwt-external.js +2 -2
  24. package/lib/auth/oauth.js +9 -9
  25. package/lib/auth/oid4vci.js +7 -7
  26. package/lib/auth/oid4vp.js +1 -1
  27. package/lib/auth/openid-federation.js +5 -5
  28. package/lib/auth/passkey.js +6 -6
  29. package/lib/auth/saml.js +1 -1
  30. package/lib/auth/sd-jwt-vc.js +3 -6
  31. package/lib/backup/index.js +18 -18
  32. package/lib/cache.js +4 -4
  33. package/lib/calendar.js +5 -5
  34. package/lib/circuit-breaker.js +1 -1
  35. package/lib/cms-codec.js +2 -2
  36. package/lib/compliance.js +14 -14
  37. package/lib/cra-report.js +3 -3
  38. package/lib/crypto-field.js +58 -21
  39. package/lib/crypto.js +5 -6
  40. package/lib/db-query.js +131 -9
  41. package/lib/db.js +106 -22
  42. package/lib/external-db.js +64 -16
  43. package/lib/framework-schema.js +4 -4
  44. package/lib/guard-list-id.js +2 -2
  45. package/lib/guard-list-unsubscribe.js +1 -2
  46. package/lib/incident-report.js +150 -0
  47. package/lib/mail-crypto-smime.js +1 -1
  48. package/lib/mail-deploy.js +3 -3
  49. package/lib/mail-server-managesieve.js +2 -2
  50. package/lib/mail-server-pop3.js +2 -2
  51. package/lib/mail-store.js +1 -1
  52. package/lib/metrics.js +8 -8
  53. package/lib/middleware/age-gate.js +20 -7
  54. package/lib/middleware/bearer-auth.js +36 -35
  55. package/lib/middleware/bot-guard.js +17 -5
  56. package/lib/middleware/cors.js +28 -12
  57. package/lib/middleware/csrf-protect.js +23 -15
  58. package/lib/middleware/daily-byte-quota.js +27 -13
  59. package/lib/middleware/deny-response.js +140 -0
  60. package/lib/middleware/dpop.js +37 -24
  61. package/lib/middleware/fetch-metadata.js +21 -12
  62. package/lib/middleware/host-allowlist.js +19 -8
  63. package/lib/middleware/idempotency-key.js +21 -22
  64. package/lib/middleware/index.js +3 -0
  65. package/lib/middleware/network-allowlist.js +24 -10
  66. package/lib/middleware/protected-resource-metadata.js +2 -2
  67. package/lib/middleware/rate-limit.js +22 -5
  68. package/lib/middleware/require-aal.js +25 -10
  69. package/lib/middleware/require-auth.js +32 -16
  70. package/lib/middleware/require-bound-key.js +49 -18
  71. package/lib/middleware/require-content-type.js +19 -8
  72. package/lib/middleware/require-methods.js +17 -7
  73. package/lib/middleware/require-mtls.js +27 -14
  74. package/lib/network-dns-resolver.js +2 -2
  75. package/lib/network-dns.js +1 -2
  76. package/lib/network-tls.js +0 -1
  77. package/lib/network.js +4 -4
  78. package/lib/outbox.js +1 -1
  79. package/lib/pqc-agent.js +1 -1
  80. package/lib/retention.js +1 -1
  81. package/lib/retry.js +1 -1
  82. package/lib/safe-archive.js +2 -2
  83. package/lib/safe-ical.js +2 -2
  84. package/lib/safe-mime.js +1 -1
  85. package/lib/self-update-standalone-verifier.js +1 -1
  86. package/lib/self-update.js +2 -2
  87. package/lib/static.js +1 -1
  88. package/lib/subject.js +2 -2
  89. package/lib/vault/index.js +64 -1
  90. package/lib/vault/rotate.js +19 -0
  91. package/lib/vendor-data.js +1 -1
  92. package/package.json +1 -1
  93. 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
- function _writeUnauthorized(res, scheme, message, realm) {
46
- if (res.headersSent) return;
47
- var body = JSON.stringify({ error: message });
48
- var challenge = scheme + (realm ? ' realm="' + realm + '"' : "");
49
- res.writeHead(401, { // HTTP 401 status
50
- "Content-Type": "application/json; charset=utf-8",
51
- "Content-Length": Buffer.byteLength(body),
52
- "WWW-Authenticate": challenge,
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
- res.end(body);
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
- var malformedBody = JSON.stringify({ error: errorMessage });
201
- res.writeHead(401, { // HTTP 401 status
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
- var body = JSON.stringify({ error: errorMessage });
225
- res.writeHead(401, { // HTTP 401 status
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
- var scopeBody = JSON.stringify({
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
- if (typeof res.writeHead === "function") {
244
- res.writeHead(statusOnBlock, { "Content-Type": "text/plain" });
245
- res.end(bodyOnBlock);
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
  }
@@ -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
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
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
- if (typeof res.writeHead === "function") {
337
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
338
- res.end("CORS: requested header '" + asked[ah] + "' not in allow-list");
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
- if (typeof res.writeHead === "function") {
361
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
362
- res.end("CORS: Private Network Access not permitted (set allowPrivateNetwork:true with audited reason to opt in)");
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
- if (typeof res.writeHead === "function") {
223
- var body = JSON.stringify({ error: message });
224
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, {
225
- "Content-Type": "application/json; charset=utf-8",
226
- "Content-Length": Buffer.byteLength(body),
227
- });
228
- res.end(body);
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 per Core Rule §3).
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
- * onExceeded: function(req, res, info): void,
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
- var onExceeded = typeof opts.onExceeded === "function" ? opts.onExceeded : null;
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
- if (onExceeded) {
177
- try { return onExceeded(req, res, info); }
178
- catch (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); }
179
- }
180
- if (!res.writableEnded) {
181
- res.writeHead(429, {
182
- "Content-Type": "application/json; charset=utf-8",
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
- res.end(JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }));
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
+ };