@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.
- package/CHANGELOG.md +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- 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
|
|
156
|
-
middleware.
|
|
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,
|
package/lib/network-tls.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
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
|
|
243
|
-
* `jitterFactor` so retrying clients
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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) {
|