@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
@@ -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 = { // HTTP 401 status
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
- res.writeHead(401, headers);
65
- res.end(body);
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
- // AUTH-36 — hot-reload coexistence. Operators redeploying without
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. (Audit 2026-05-11.)
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 (audit 2026-05-11); operators
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
- // AUTH-15 — RFC 9449 §4.1 single-value invariant. node:http
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
- // AUTH-36 — surface the nonce manager's lifecycle hooks on the
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
- if (res.headersSent) return;
47
- var body = JSON.stringify({ error: message });
48
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, {
49
- "Content-Type": "application/json; charset=utf-8",
50
- "Content-Length": Buffer.byteLength(body),
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, denyStatus, denyBody);
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, denyStatus, denyBody);
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, status, body) {
166
+ function _deny(req, res, reason, host) {
162
167
  if (res.headersSent) return;
163
- res.writeHead(status, {
164
- "Content-Type": "text/plain; charset=utf-8",
165
- "Content-Length": Buffer.byteLength(body),
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 — CRYPTO-1) — sealed columns are bound
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 — CRYPTO-4) — the request
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 — SUBSTRATE-13) —
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 (CRYPTO-1)
232
- * fingerprintSeal?: boolean, // default true — HMAC fingerprint under a vault-derived secret instead of bare sha3-256 (CRYPTO-4)
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
- // CRYPTO-1 — AAD-bind sealing to (table, k, column) by default.
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
- // CRYPTO-4 — HMAC the fingerprint under a vault-derived secret by
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. CRYPTO-1: when aad is on,
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
- // CRYPTO-4 — derive a per-vault HMAC secret for fingerprint sealing.
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 (CRYPTO-1).
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
- // CRYPTO-4 — emit / compare HMAC-shape fingerprints. The store
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
- // CRYPTO-13 — decryption failure used to delete the row,
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
- // CRYPTO-22 — route through C.BYTES.mib(4); raw `4 * 1024 * 1024`
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
- // CRYPTO-4 — the middleware consults this hook to HMAC the
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
- // CRYPTO-1 — operator helper: walk the table and reseal every row
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. CRYPTO-4: when the store exposes a
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" (SUBSTRATE-13) — when neither
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
- // SUBSTRATE-13 — default "deny" refuses body-bearing requests that
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; SUBSTRATE-13 (v0.9.58) makes that
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
- * (CRYPTO-1). Existing v0.9.15-v0.9.57 rows continue to read on a
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.
@@ -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
- _emitDeny(req, "<unknown>", pathname);
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
- _emitDeny(req, ip, pathname);
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
- _emitDeny(req, ip, pathname);
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
- // AUTH-17 — RFC 9728 §3 + RFC 8414 §3.1: authorizationServers entries
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
- // AUTH-18 — RFC 9728 §3.2 signed_metadata. Operators with an
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`.