@blamejs/core 0.9.46 → 0.10.1

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 (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. package/sbom.cdx.json +6 -6
@@ -26,6 +26,8 @@
26
26
  var framework_error = require("../framework-error");
27
27
  var validateOpts = require("../validate-opts");
28
28
  var requestHelpers = require("../request-helpers");
29
+ var safeUrl = require("../safe-url");
30
+ var nodeCrypto = require("node:crypto");
29
31
 
30
32
  var H = requestHelpers.HTTP_STATUS;
31
33
 
@@ -36,6 +38,15 @@ var ProtectedResourceMetadataError = framework_error.defineClass(
36
38
 
37
39
  var ALLOWED_BEARER_METHODS = ["header", "body", "query"];
38
40
  var ALLOWED_DPOP_ALGS = ["ES256", "ES384", "RS256", "PS256", "EdDSA", "ML-DSA-65", "ML-DSA-87"];
41
+ // RFC 9728 §3.2 — signed_metadata signing algs. PQC-first per the
42
+ // framework's hard rule §2 (ML-DSA-* preferred); classical algs
43
+ // available for backwards-interop with relying parties without PQC
44
+ // libraries on hand.
45
+ var ALLOWED_SIGNED_METADATA_ALGS = ["ML-DSA-87", "ML-DSA-65", "EdDSA", "ES256", "ES384", "PS256", "PS384"];
46
+
47
+ function _b64url(buf) {
48
+ return Buffer.from(buf).toString("base64url");
49
+ }
39
50
 
40
51
  /**
41
52
  * @primitive b.middleware.protectedResourceMetadata
@@ -83,12 +94,30 @@ function create(opts) {
83
94
  "middleware/protected-resource-metadata/no-as",
84
95
  "authorizationServers must be a non-empty array of issuer URLs");
85
96
  }
97
+ // AUTH-17 — RFC 9728 §3 + RFC 8414 §3.1: authorizationServers entries
98
+ // are issuer URLs and MUST be https://. Pre-v0.9.x only required
99
+ // non-empty string, so an operator typo could ship `http://idp.test`
100
+ // (or, worse, `javascript:` / `data:`) to clients via the well-known
101
+ // document. allowHttp opts.allowHttp passes the framework's
102
+ // safe-url loopback exception through (matches b.auth.oauth's
103
+ // _validateUrl shape).
104
+ var allowHttp = opts.allowHttp === true;
86
105
  opts.authorizationServers.forEach(function (u, i) {
87
106
  if (typeof u !== "string" || u.length === 0) {
88
107
  throw new ProtectedResourceMetadataError(
89
108
  "middleware/protected-resource-metadata/bad-as",
90
109
  "authorizationServers[" + i + "] must be a non-empty string");
91
110
  }
111
+ try {
112
+ safeUrl.parse(u, {
113
+ allowedProtocols: allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
114
+ });
115
+ } catch (_e) {
116
+ throw new ProtectedResourceMetadataError(
117
+ "middleware/protected-resource-metadata/bad-as-url",
118
+ "authorizationServers[" + i + "] = '" + u + "' is not a valid " +
119
+ (allowHttp ? "http(s)" : "https") + " URL (RFC 9728 §3 / RFC 8414 §3.1)");
120
+ }
92
121
  });
93
122
 
94
123
  var bearerMethods = opts.bearerMethodsSupported || ["header"];
@@ -127,8 +156,73 @@ function create(opts) {
127
156
  if (opts.dpopBoundAccessTokensRequired === true) doc.dpop_bound_access_tokens_required = true;
128
157
  if (opts.mtlsBoundAccessTokensRequired === true) doc.tls_client_certificate_bound_access_tokens = true;
129
158
 
159
+ // AUTH-18 — RFC 9728 §3.2 signed_metadata. Operators with an
160
+ // anti-tamper requirement pass `signMetadata: { key, alg, kid }`;
161
+ // the middleware emits `application/jwt` carrying the JWS-signed
162
+ // metadata. Default output remains cleartext `application/json`.
163
+ var signedJwt = null;
164
+ var signedDoc = null;
165
+ if (opts.signMetadata) {
166
+ var sm = opts.signMetadata;
167
+ if (!sm || typeof sm !== "object") {
168
+ throw new ProtectedResourceMetadataError(
169
+ "middleware/protected-resource-metadata/bad-sign",
170
+ "signMetadata must be an object { key, alg, kid? }");
171
+ }
172
+ if (!sm.alg || ALLOWED_SIGNED_METADATA_ALGS.indexOf(sm.alg) === -1) {
173
+ throw new ProtectedResourceMetadataError(
174
+ "middleware/protected-resource-metadata/bad-sign-alg",
175
+ "signMetadata.alg '" + sm.alg + "' not in allowlist: " +
176
+ ALLOWED_SIGNED_METADATA_ALGS.join(", "));
177
+ }
178
+ if (!sm.key) {
179
+ throw new ProtectedResourceMetadataError(
180
+ "middleware/protected-resource-metadata/bad-sign-key",
181
+ "signMetadata.key is required (KeyObject, PEM string/Buffer, or JWK object)");
182
+ }
183
+ var signingKey;
184
+ try {
185
+ if (sm.key instanceof nodeCrypto.KeyObject) {
186
+ signingKey = sm.key;
187
+ } else if (typeof sm.key === "string" || Buffer.isBuffer(sm.key)) {
188
+ signingKey = nodeCrypto.createPrivateKey({ key: sm.key, format: "pem" });
189
+ } else if (typeof sm.key === "object") {
190
+ signingKey = nodeCrypto.createPrivateKey({ key: sm.key, format: "jwk" });
191
+ } else {
192
+ throw new ProtectedResourceMetadataError(
193
+ "middleware/protected-resource-metadata/bad-sign-key",
194
+ "signMetadata.key must be KeyObject, PEM string/Buffer, or JWK object");
195
+ }
196
+ } catch (e) {
197
+ if (e instanceof ProtectedResourceMetadataError) throw e;
198
+ throw new ProtectedResourceMetadataError(
199
+ "middleware/protected-resource-metadata/bad-sign-key",
200
+ "signMetadata.key parse failed: " + ((e && e.message) || String(e)));
201
+ }
202
+ // RFC 9728 §3.2 — signed_metadata is a JWS carrying the same
203
+ // metadata claims as the cleartext document plus iss + sub
204
+ // (resource URI) for identification at consume-side.
205
+ signedDoc = Object.assign({}, doc, { iss: opts.resource, sub: opts.resource });
206
+ var jwsHeader = { alg: sm.alg, typ: "oauth-protected-resource+jwt" };
207
+ if (sm.kid) jwsHeader.kid = sm.kid;
208
+ var headerEnc = _b64url(JSON.stringify(jwsHeader));
209
+ var payloadEnc = _b64url(JSON.stringify(signedDoc));
210
+ var input = headerEnc + "." + payloadEnc;
211
+ // PQC algs (ML-DSA-*) + EdDSA pass null hash; ES* / PS* / RS* use
212
+ // their RFC 7518 hash + dsaEncoding shape.
213
+ var signParams = { key: signingKey };
214
+ var signAlgo = null;
215
+ if (sm.alg === "ES256") { signAlgo = "sha256"; signParams.dsaEncoding = "ieee-p1363"; }
216
+ else if (sm.alg === "ES384") { signAlgo = "sha384"; signParams.dsaEncoding = "ieee-p1363"; }
217
+ else if (sm.alg === "PS256") { signAlgo = "sha256"; signParams.padding = nodeCrypto.constants.RSA_PKCS1_PSS_PADDING; signParams.saltLength = 32; } // allow:raw-byte-literal — RFC 7518 PS256 salt
218
+ else if (sm.alg === "PS384") { signAlgo = "sha384"; signParams.padding = nodeCrypto.constants.RSA_PKCS1_PSS_PADDING; signParams.saltLength = 48; } // allow:raw-byte-literal — RFC 7518 PS384 salt
219
+ var sig = nodeCrypto.sign(signAlgo, Buffer.from(input, "ascii"), signParams);
220
+ signedJwt = input + "." + _b64url(sig);
221
+ }
222
+
130
223
  var bodyText = JSON.stringify(doc);
131
224
  var bodyBytes = Buffer.byteLength(bodyText, "utf8");
225
+ var signedBytes = signedJwt ? Buffer.byteLength(signedJwt, "utf8") : 0;
132
226
 
133
227
  function middleware(req, res, next) {
134
228
  if (req.url !== path && req.url.split("?")[0] !== path) {
@@ -143,6 +237,22 @@ function create(opts) {
143
237
  res.end();
144
238
  return;
145
239
  }
240
+ // RFC 9728 §3.2 — operators that wired signMetadata serve the JWS
241
+ // form when the client advertises Accept: application/jwt (or via
242
+ // the *.jwt path suffix). The cleartext document is still served
243
+ // on the default path / Accept: application/json.
244
+ var accept = (req.headers && req.headers.accept) || "";
245
+ var wantsSigned = signedJwt && (accept.indexOf("application/jwt") !== -1);
246
+ if (wantsSigned) {
247
+ res.writeHead(H.OK, {
248
+ "Content-Type": "application/jwt",
249
+ "Content-Length": String(signedBytes),
250
+ "Cache-Control": "public, max-age=3600",
251
+ });
252
+ if (req.method === "HEAD") { res.end(); return; }
253
+ res.end(signedJwt);
254
+ return;
255
+ }
146
256
  res.writeHead(H.OK, {
147
257
  "Content-Type": "application/json",
148
258
  "Content-Length": String(bodyBytes),
@@ -152,8 +262,9 @@ function create(opts) {
152
262
  res.end(bodyText);
153
263
  }
154
264
 
155
- middleware.document = doc;
156
- middleware.path = path;
265
+ middleware.document = doc;
266
+ middleware.signedMetadata = signedJwt;
267
+ middleware.path = path;
157
268
  return middleware;
158
269
  }
159
270
 
@@ -162,4 +273,5 @@ module.exports = {
162
273
  ProtectedResourceMetadataError: ProtectedResourceMetadataError,
163
274
  ALLOWED_BEARER_METHODS: ALLOWED_BEARER_METHODS,
164
275
  ALLOWED_DPOP_ALGS: ALLOWED_DPOP_ALGS,
276
+ ALLOWED_SIGNED_METADATA_ALGS: ALLOWED_SIGNED_METADATA_ALGS,
165
277
  };
@@ -127,6 +127,13 @@ var DEFAULT_MAX_TTL_MS = C.TIME.hours(24);
127
127
  var DEFAULT_MIN_TTL_MS = C.TIME.seconds(60);
128
128
  var DEFAULT_STALE_WINDOW = C.TIME.hours(6);
129
129
  var DEFAULT_PROFILE = "strict";
130
+ // BUG-1 / MAIL-26 — CWE-400/770. Bound the cache so a hostile peer
131
+ // that can drive query-name selection (e.g. inbound SMTP forwarding
132
+ // DKIM `s=` / `d=` tag-controlled lookups) cannot inflate the Map to
133
+ // OOM. Default 5000 entries: a parsed-response object ~100 bytes ×
134
+ // 5000 ≈ 500 KiB, several orders below operator-relevant memory
135
+ // pressure. LRU eviction picks the oldest accessed entry on overflow.
136
+ var DEFAULT_MAX_CACHE_ENTRIES = 5000; // allow:raw-byte-literal — cache-entry count, not a byte/time value
130
137
 
131
138
  var QTYPE_BY_NAME = Object.freeze({
132
139
  A: 1,
@@ -200,9 +207,33 @@ function create(opts) {
200
207
  throw new ResolverError("resolver/bad-input",
201
208
  "create: serveStale must be a non-negative finite number or false");
202
209
  }
210
+ var maxCacheEntries = typeof opts.maxCacheEntries === "number"
211
+ ? opts.maxCacheEntries : DEFAULT_MAX_CACHE_ENTRIES;
212
+ if (!isFinite(maxCacheEntries) || maxCacheEntries < 1 ||
213
+ Math.floor(maxCacheEntries) !== maxCacheEntries) {
214
+ throw new ResolverError("resolver/bad-input",
215
+ "create: maxCacheEntries must be a positive integer");
216
+ }
203
217
 
204
218
  var cache = new Map(); // key → { response, parsed, ttl, expiresAt, staleUntil }
205
219
 
220
+ // CWE-400/770 / BUG-1 — LRU eviction on insert when the cache is at
221
+ // capacity. v8 Map preserves insertion order; oldest key is the
222
+ // first entry returned by Map.keys().next().
223
+ function _evictIfFull() {
224
+ while (cache.size >= maxCacheEntries) {
225
+ var oldest = cache.keys().next();
226
+ if (oldest.done) break;
227
+ cache.delete(oldest.value);
228
+ }
229
+ }
230
+ // Touching a hit moves it to the LRU tail — delete-then-set keeps
231
+ // active queries hot under cache pressure.
232
+ function _touch(key, entry) {
233
+ cache.delete(key);
234
+ cache.set(key, entry);
235
+ }
236
+
206
237
  function _key(name, qtype) {
207
238
  return name.toLowerCase() + "|" + qtype;
208
239
  }
@@ -244,6 +275,7 @@ function create(opts) {
244
275
  "query: validate: true but cached response was AD=0 for " +
245
276
  name + "/" + qtype);
246
277
  }
278
+ _touch(key, hit); // LRU bump
247
279
  return _result(hit.parsed, hit.ttl, true, false, hit.validated);
248
280
  }
249
281
 
@@ -297,6 +329,7 @@ function create(opts) {
297
329
  var ttlMs = Math.max(minTtlMs, Math.min(maxTtlMs, rrTtl * C.TIME.seconds(1)));
298
330
  var expiresAt = now + ttlMs;
299
331
  var staleUntil = serveStale > 0 ? expiresAt + serveStale : expiresAt;
332
+ _evictIfFull();
300
333
  cache.set(key, {
301
334
  parsed: parsed,
302
335
  ttl: ttlMs,
@@ -3048,6 +3048,51 @@ function _checkServerIdentityStrict(host, cert) {
3048
3048
  return checkServerIdentity9525(host, cert);
3049
3049
  }
3050
3050
 
3051
+ // CVE-2026-21637 — Node propagates a synchronous throw from an
3052
+ // operator-supplied SNICallback up through the TLS handshake listener;
3053
+ // the unhandled throw on an unexpected servername crashes the
3054
+ // listener. RFC 6066 §3 expects the server to abort the handshake on a
3055
+ // failed callback, NOT crash the process.
3056
+ //
3057
+ // `wrapSNICallback(operatorCb)` returns a wrapper that:
3058
+ //
3059
+ // - Calls the operator callback in a try/catch.
3060
+ // - Surface a synchronous throw via the async (err, null) callback so
3061
+ // the TLS handshake aborts cleanly. Cb is best-effort: an operator
3062
+ // callback that throws AFTER invoking the callback already (double
3063
+ // invoke) gets the throw caught here without double-invoking again.
3064
+ // - Emit an audit event so a burst of crashes-that-weren't surfaces
3065
+ // in operator review.
3066
+ // - Returns the operator's original callback unchanged if it's not a
3067
+ // function (lets the caller pass undefined through without
3068
+ // special-casing).
3069
+ //
3070
+ // router.js routes its operator-supplied tlsOptions.SNICallback through
3071
+ // this helper before handing the options off to https.createServer.
3072
+ // Any future framework primitive that takes operator SNICallback
3073
+ // values does the same.
3074
+ function wrapSNICallback(operatorCb) {
3075
+ if (typeof operatorCb !== "function") return operatorCb;
3076
+ return function _wrappedSNICallback(servername, cb) {
3077
+ try {
3078
+ operatorCb(servername, cb);
3079
+ } catch (err) {
3080
+ try {
3081
+ audit().safeEmit({
3082
+ action: "network.tls.sni_callback_threw",
3083
+ outcome: "failure",
3084
+ metadata: {
3085
+ servername: typeof servername === "string" ? servername : null,
3086
+ reason: (err && err.message) ? err.message : String(err),
3087
+ },
3088
+ });
3089
+ } catch (_auditErr) { /* drop-silent — audit best-effort */ }
3090
+ try { cb(err, null); }
3091
+ catch (_cbErr) { /* cb already invoked or unavailable */ }
3092
+ }
3093
+ };
3094
+ }
3095
+
3051
3096
  module.exports = {
3052
3097
  addCa: addCa,
3053
3098
  addCaBundle: addCaBundle,
@@ -3073,6 +3118,7 @@ module.exports = {
3073
3118
  parseEchConfigList: parseEchConfigList,
3074
3119
  connectWithEch: connectWithEch,
3075
3120
  checkServerIdentity9525: checkServerIdentity9525,
3121
+ wrapSNICallback: wrapSNICallback,
3076
3122
  TlsTrustError: TlsTrustError,
3077
3123
  NetworkTlsError: NetworkTlsError,
3078
3124
  _resetForTest: _resetForTest,
package/lib/outbox.js CHANGED
@@ -342,27 +342,77 @@ function create(opts) {
342
342
  var stopping = false;
343
343
  var inFlight = null;
344
344
 
345
+ // SUBSTRATE-23 — `FOR UPDATE SKIP LOCKED` is Postgres / MySQL 8+ only.
346
+ // SQLite (single-writer at the DB level, but WAL mode lets multiple
347
+ // processes share the file with concurrent SELECTs) doesn't support
348
+ // SKIP LOCKED — feeding it Postgres syntax silently double-publishes
349
+ // every row when two processes poll in parallel. Detect the dialect
350
+ // at runtime; only emit FOR UPDATE SKIP LOCKED when the backend
351
+ // declares postgres / mysql.
352
+ //
353
+ // Operator-visible: dialect comes from `externalDb.dialect` (set at
354
+ // `b.externalDb.create({ dialect: "postgres" | "mysql" | "sqlite" }`).
355
+ // Other backends fall back to the conservative "mark-then-update"
356
+ // path that works on every SQL dialect at the cost of a tiny race
357
+ // window between the SELECT + UPDATE (mitigated by status='in-flight'
358
+ // marker — duplicate publishes still bounded by retry visibility).
359
+ function _supportsForUpdateSkipLocked() {
360
+ var d = externalDb.dialect;
361
+ return d === "postgres" || d === "mysql";
362
+ }
363
+
345
364
  async function _claimBatch() {
365
+ var supportsSkipLocked = _supportsForUpdateSkipLocked();
346
366
  return await externalDb.transaction(async function (xdb) {
347
367
  var nowExpr = _utcNowExpr(externalDb);
348
- var rows = await xdb.query(
368
+ var selectSql =
349
369
  "SELECT id, topic, payload, key, headers, attempts" +
350
370
  " FROM " + quotedTable +
351
371
  " WHERE status = 'pending' AND next_attempt_at <= $1" +
352
372
  " ORDER BY next_attempt_at" +
353
- " LIMIT $2" +
354
- " FOR UPDATE SKIP LOCKED",
355
- [nowExpr, batchSize]
356
- );
373
+ " LIMIT $2";
374
+ if (supportsSkipLocked) {
375
+ selectSql += " FOR UPDATE SKIP LOCKED";
376
+ }
377
+ var rows = await xdb.query(selectSql, [nowExpr, batchSize]);
357
378
  if (!rows || !rows.rows || rows.rows.length === 0) return [];
358
379
  var ids = rows.rows.map(function (r) { return r.id; });
359
- // Mark as 'in-flight' so a parallel publisher won't re-claim them
360
- // when the row lock releases (after the SELECT-for-update txn).
361
- await xdb.query(
362
- "UPDATE " + quotedTable + " SET status = 'in-flight' WHERE id = ANY($1)",
363
- [ids]
364
- );
365
- return rows.rows.map(function (r) {
380
+ // Atomic claim: when the dialect lacks SKIP LOCKED, the UPDATE
381
+ // WHERE status='pending' AND id IN (...) ensures only ONE publisher
382
+ // sees each row transition from 'pending' to 'in-flight' — the
383
+ // other publisher's UPDATE matches zero rows and its batch shrinks.
384
+ // We re-select after the UPDATE to know which IDs we actually
385
+ // claimed (sqlite UPDATE doesn't return affected rows the same
386
+ // way Postgres does).
387
+ var actuallyClaimed;
388
+ if (supportsSkipLocked) {
389
+ // Postgres/MySQL: row lock held; ANY($1) update is safe.
390
+ await xdb.query(
391
+ "UPDATE " + quotedTable + " SET status = 'in-flight' WHERE id = ANY($1)",
392
+ [ids]
393
+ );
394
+ actuallyClaimed = rows.rows;
395
+ } else {
396
+ // SQLite (or "other") path: emit a portable UPDATE that
397
+ // refuses overlap by gating on status='pending'. After the
398
+ // update we re-read the in-flight rows we own; rows that
399
+ // another publisher beat us to are skipped.
400
+ // Use placeholders so the SQL stays parameterized regardless
401
+ // of dialect array semantics.
402
+ var placeholders = ids.map(function (_, i) { return "$" + (i + 1); }).join(",");
403
+ await xdb.query(
404
+ "UPDATE " + quotedTable +
405
+ " SET status = 'in-flight' WHERE status = 'pending' AND id IN (" + placeholders + ")",
406
+ ids
407
+ );
408
+ var afterRows = await xdb.query(
409
+ "SELECT id, topic, payload, key, headers, attempts FROM " + quotedTable +
410
+ " WHERE status = 'in-flight' AND id IN (" + placeholders + ")",
411
+ ids
412
+ );
413
+ actuallyClaimed = (afterRows && afterRows.rows) || [];
414
+ }
415
+ return actuallyClaimed.map(function (r) {
366
416
  return {
367
417
  id: r.id,
368
418
  topic: r.topic,
package/lib/pqc-agent.js CHANGED
@@ -253,13 +253,21 @@ function _getDefaultAgent() {
253
253
  * logger.info("pqc-agent reloaded", res);
254
254
  */
255
255
  function reload() {
256
- var hadAgent = _defaultAgent !== null;
257
- if (hadAgent) {
258
- try { _defaultAgent.destroy(); }
256
+ // CRYPTO-9 null the cached agent BEFORE calling destroy. The
257
+ // previous order let a concurrent _getDefaultAgent() see the
258
+ // destroyed-not-null agent and hand it to a caller; the caller
259
+ // then tries to issue a request through a torn-down keep-alive
260
+ // pool and surfaces a "socket destroyed" error. Null-first means
261
+ // every concurrent _getDefaultAgent() either sees the live agent
262
+ // (request lands on the about-to-be-torn-down pool — natural
263
+ // graceful drain) or the null sentinel (builds fresh).
264
+ var prior = _defaultAgent;
265
+ _defaultAgent = null;
266
+ if (prior) {
267
+ try { prior.destroy(); }
259
268
  catch (_e) { /* destroy is best-effort */ }
260
- _defaultAgent = null;
261
269
  }
262
- return { destroyed: hadAgent };
270
+ return { destroyed: prior !== null };
263
271
  }
264
272
 
265
273
  module.exports = {
package/lib/retry.js CHANGED
@@ -39,7 +39,6 @@
39
39
 
40
40
  var C = require("./constants");
41
41
  var lazyRequire = require("./lazy-require");
42
- var nodeCrypto = require("node:crypto");
43
42
  var numericChecks = require("./numeric-checks");
44
43
  // safe-async re-exports withRetry + CircuitBreaker from this module, so a
45
44
  // direct top-level require would create a cycle. Lazy-require defers the
@@ -239,10 +238,19 @@ function isRetryable(err) {
239
238
  *
240
239
  * Compute the backoff in milliseconds for a given (1-based) `attempt`
241
240
  * number. Exponential growth `baseDelayMs * 2^(attempt-1)` capped at
242
- * `maxDelayMs`, then subtract a cryptographic jitter sample scaled by
243
- * `jitterFactor` so retrying clients do not realign on the same
244
- * boundary. Throws TypeError when `attempt` is not a positive integer.
245
- * `opts` defaults to `b.retry.DEFAULT_RETRY` when absent.
241
+ * `maxDelayMs`, then subtract a Math.random-sourced jitter sample
242
+ * scaled by `jitterFactor` so retrying clients spread across the
243
+ * millisecond window instead of realigning on the same boundary
244
+ * (thundering-herd avoidance). Throws TypeError when `attempt` is
245
+ * not a positive integer. `opts` defaults to `b.retry.DEFAULT_RETRY`
246
+ * when absent.
247
+ *
248
+ * Jitter is intentionally NOT a CSPRNG sample — the per-request delay
249
+ * is observable to every peer client by construction (the request
250
+ * that comes in carries its own arrival timing), so there is no
251
+ * confidentiality property a stronger random source would protect.
252
+ * Math.random is the right tool for thundering-herd avoidance and
253
+ * costs ~50x less than a CSPRNG randomInt() under a retry storm.
246
254
  *
247
255
  * @opts
248
256
  * baseDelayMs: number, // initial backoff (default 100)
@@ -263,10 +271,16 @@ function backoffDelay(attempt, opts) {
263
271
  opts = opts || DEFAULT_RETRY;
264
272
  var base = opts.baseDelayMs * Math.pow(2, attempt - 1);
265
273
  var capped = Math.min(base, opts.maxDelayMs);
266
- // Cryptographically-strong jitter so a timing-attack mitigation isn't
267
- // undermined by Math.random's predictable PRNG.
268
- var jitterDenom = 1_000_000;
269
- var jitter = capped * opts.jitterFactor * (nodeCrypto.randomInt(0, jitterDenom) / jitterDenom);
274
+ // CRYPTO-12 jitter exists to spread retry storms across the
275
+ // millisecond window so N peer clients waking from the same
276
+ // upstream outage don't all hit the recovering service at the same
277
+ // tick. The value is observable to every client by construction
278
+ // (the request that comes in carries its own timing); there's no
279
+ // confidentiality property to protect, so a CSPRNG would burn
280
+ // 30-50K randomInt/sec under a retry storm without any security
281
+ // payoff. Math.random's PRNG is the right tool for
282
+ // thundering-herd avoidance.
283
+ var jitter = capped * opts.jitterFactor * Math.random(); // allow:math-random-noncrypto — jitter for thundering-herd, not a confidentiality primitive
270
284
  return Math.floor(capped - jitter);
271
285
  }
272
286
 
package/lib/router.js CHANGED
@@ -463,6 +463,14 @@ class Router {
463
463
  }
464
464
 
465
465
  _registerRoute(method, pattern, args) {
466
+ // CVE-2026-4923 — refuse pattern with more than 3 consecutive `*`
467
+ // metacharacters. The framework's segment matcher doesn't compile
468
+ // regex from operator input, but the policy stays crisp at the
469
+ // registration boundary.
470
+ if (typeof pattern === "string" && /\*{4,}/.test(pattern)) {
471
+ throw new Error(method + " " + pattern + ": route pattern refused " +
472
+ "(CVE-2026-4923) — more than 3 consecutive '*' metacharacters");
473
+ }
466
474
  var split = this._splitArgs(args);
467
475
  if (split.spec) _validateRouteSpec(split.spec, method, pattern);
468
476
  var handlers = split.handlers;
@@ -656,7 +664,21 @@ class Router {
656
664
  allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
657
665
  });
658
666
  req.pathname = parsed.pathname;
659
- req.query = Object.fromEntries(parsed.searchParams);
667
+ // CVE-2026-21717 V8 HashDoS defense — cap distinct query keys
668
+ // before forming the dense object. Integer-shaped keys past 1000
669
+ // entries degrade V8 hidden-class transitions to O(n²).
670
+ var queryEntries = [];
671
+ var queryKeyCount = 0;
672
+ for (var pair of parsed.searchParams) {
673
+ queryKeyCount += 1;
674
+ if (queryKeyCount > 1000) { // allow:raw-byte-literal — CVE-2026-21717 V8 HashDoS query-key cap
675
+ res.statusCode = 400;
676
+ res.end("400 Bad Request: too many query keys");
677
+ return;
678
+ }
679
+ queryEntries.push(pair);
680
+ }
681
+ req.query = Object.fromEntries(queryEntries);
660
682
 
661
683
  // Run middleware
662
684
  for (var mw of this.middleware) {