@blamejs/core 0.15.14 → 0.15.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/index.js +2 -0
- package/lib/atomic-file.js +34 -0
- package/lib/auth/ciba.js +32 -8
- package/lib/auth/dpop.js +9 -0
- package/lib/auth/fido-mds3.js +35 -12
- package/lib/auth/jwt.js +19 -3
- package/lib/auth/oauth.js +8 -2
- package/lib/auth/password.js +1 -0
- package/lib/auth/saml.js +30 -12
- package/lib/crypto-field.js +19 -1
- package/lib/csp.js +9 -0
- package/lib/daemon.js +4 -1
- package/lib/db-query.js +33 -2
- package/lib/external-db.js +131 -0
- package/lib/graphql-federation.js +25 -15
- package/lib/log-stream-cloudwatch.js +1 -0
- package/lib/log-stream-local.js +14 -1
- package/lib/log-stream-otlp.js +1 -0
- package/lib/log-stream-webhook.js +1 -0
- package/lib/mail-auth.js +93 -15
- package/lib/mail-bimi.js +6 -0
- package/lib/mail-crypto-smime.js +10 -0
- package/lib/mail-dkim.js +86 -20
- package/lib/mail.js +39 -0
- package/lib/middleware/api-encrypt.js +6 -2
- package/lib/middleware/compose-pipeline.js +39 -5
- package/lib/network-dns-resolver.js +61 -11
- package/lib/network-dns.js +47 -2
- package/lib/network-nts.js +16 -0
- package/lib/network-proxy.js +55 -2
- package/lib/object-store/azure-blob-bucket-ops.js +1 -0
- package/lib/object-store/gcs-bucket-ops.js +1 -0
- package/lib/object-store/http-request.js +4 -0
- package/lib/outbox.js +29 -0
- package/lib/pipl-cn.js +11 -8
- package/lib/queue-sqs.js +1 -0
- package/lib/request-helpers.js +165 -13
- package/lib/safe-json.js +26 -0
- package/lib/session-device-binding.js +46 -24
- package/lib/session.js +120 -145
- package/lib/sql.js +22 -0
- package/lib/ws-client.js +26 -0
- package/lib/x509-chain.js +71 -24
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/session.js
CHANGED
|
@@ -182,6 +182,49 @@ function _validFromConflictRefs(dialect, table) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
// CREATE TABLE IF NOT EXISTS for the valid-from boundary, matching the
|
|
186
|
+
// framework schema in db.js (single-node) / framework-schema.js (cluster mode):
|
|
187
|
+
// subjectHash PRIMARY KEY, validFromEpoch + updatedAt NOT NULL. The pluggable
|
|
188
|
+
// store is always a dedicated node:sqlite file (b.session.stores.localDbThin —
|
|
189
|
+
// see session-stores.js), so the dialect is the literal "sqlite". Used to
|
|
190
|
+
// provision the table on demand in a store-backed-only deployment (a
|
|
191
|
+
// b.session.useStore consumer that never ran b.db.init(), so the framework db
|
|
192
|
+
// — the default home of this table — is not initialized).
|
|
193
|
+
function _validFromSchemaSql() {
|
|
194
|
+
return sql.createTable(_validFromSqlTable(), [
|
|
195
|
+
{ name: "subjectHash", type: "text", primaryKey: true },
|
|
196
|
+
{ name: "validFromEpoch", type: "int", notNull: true },
|
|
197
|
+
{ name: "updatedAt", type: "int", notNull: true },
|
|
198
|
+
], { dialect: "sqlite" }).sql;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Run a valid-from boundary operation (bump write / validFrom read / check
|
|
202
|
+
// read) against the correct backend. The boundary lives in the FRAMEWORK db
|
|
203
|
+
// (clusterStorage) — it is a stateless-token revocation primitive shared across
|
|
204
|
+
// every issuer, not per-session data — so that is always the first choice and a
|
|
205
|
+
// present db is never silently bypassed. ONLY when the framework db is not
|
|
206
|
+
// initialized (single-node, b.db.init() never awaited) AND an operator store is
|
|
207
|
+
// configured (b.session.useStore) does the boundary fall back to that store, so
|
|
208
|
+
// a store-backed-only deployment's logout-everywhere still raises (and honors)
|
|
209
|
+
// the stateless boundary instead of 500ing on db/not-initialized (#340). With
|
|
210
|
+
// neither a framework db nor a store, db/not-initialized is a real
|
|
211
|
+
// misconfiguration and propagates unchanged (fail closed — the boundary is
|
|
212
|
+
// never silently dropped). The store provisions the table on demand because a
|
|
213
|
+
// session-data store (localDbThin) does not ship the valid-from DDL.
|
|
214
|
+
async function _runValidFrom(runner) {
|
|
215
|
+
try {
|
|
216
|
+
return await runner(clusterStorage);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (e && e.code === "db/not-initialized" && _store) {
|
|
219
|
+
// Framework db absent, operator store present: route through the store,
|
|
220
|
+
// provisioning the boundary table first (idempotent CREATE IF NOT EXISTS).
|
|
221
|
+
await _store.execute(_validFromSchemaSql(), []);
|
|
222
|
+
return await runner(_store);
|
|
223
|
+
}
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
185
228
|
// Column order used for INSERT — kept as a constant so the placeholders
|
|
186
229
|
// list and the values list stay in sync. Must match the session table's
|
|
187
230
|
// schema in db.js (single-node) and framework-schema.js (cluster mode).
|
|
@@ -256,130 +299,48 @@ function _sealForInsert(row) {
|
|
|
256
299
|
var DEFAULT_FINGERPRINT_FIELDS = ["clientIp", "userAgent", "acceptLanguage"];
|
|
257
300
|
|
|
258
301
|
// Subnet binding: roaming carriers (T-Mobile / Verizon / etc.) flip the
|
|
259
|
-
// public client IP every few requests as the device hops cells, so a
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
function _maskIpv4(ip, prefix) {
|
|
287
|
-
// ip = "a.b.c.d"; prefix is bits to keep (1..32).
|
|
288
|
-
var parts = String(ip).split(".");
|
|
289
|
-
if (parts.length !== IPV4_OCTET_COUNT) return null;
|
|
290
|
-
var n = 0;
|
|
291
|
-
for (var i = 0; i < IPV4_OCTET_COUNT; i++) {
|
|
292
|
-
var oct = parseInt(parts[i], 10);
|
|
293
|
-
if (!Number.isInteger(oct) || oct < 0 || oct >= IPV4_OCTET_RANGE) return null;
|
|
294
|
-
n = (n * IPV4_OCTET_RANGE) + oct;
|
|
295
|
-
}
|
|
296
|
-
// Apply prefix mask.
|
|
297
|
-
var mask = prefix === 0 ? 0 : (-1 >>> (IPV4_TOTAL_BITS - prefix)) << (IPV4_TOTAL_BITS - prefix);
|
|
298
|
-
// Bitwise on 32-bit unsigned. JS coerces to 32-bit signed, so use
|
|
299
|
-
// unsigned right shift to recover.
|
|
300
|
-
var masked = (n & mask) >>> 0;
|
|
301
|
-
return ((masked >>> IP_BITS_PER_BYTE * 3) & BYTE_MASK) + "." +
|
|
302
|
-
((masked >>> IP_BITS_PER_BYTE * 2) & BYTE_MASK) + "." +
|
|
303
|
-
((masked >>> IP_BITS_PER_BYTE) & BYTE_MASK) + "." +
|
|
304
|
-
(masked & BYTE_MASK) + "/" + prefix;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function _maskIpv6(ip, prefix) {
|
|
308
|
-
// Expand to 8 16-bit groups. Accept :: shorthand. Reject if invalid.
|
|
309
|
-
var raw = String(ip).toLowerCase();
|
|
310
|
-
// Strip an embedded zone id (fe80::1%eth0); not part of the address.
|
|
311
|
-
var pct = raw.indexOf("%");
|
|
312
|
-
if (pct !== -1) raw = raw.substring(0, pct);
|
|
313
|
-
var doubleColonAt = raw.indexOf("::");
|
|
314
|
-
var groups;
|
|
315
|
-
if (doubleColonAt === -1) {
|
|
316
|
-
groups = raw.split(":");
|
|
317
|
-
if (groups.length !== IPV6_GROUP_COUNT) return null;
|
|
318
|
-
} else {
|
|
319
|
-
var left = raw.substring(0, doubleColonAt).split(":");
|
|
320
|
-
var right = raw.substring(doubleColonAt + 2).split(":");
|
|
321
|
-
if (left.length === 1 && left[0] === "") left = [];
|
|
322
|
-
if (right.length === 1 && right[0] === "") right = [];
|
|
323
|
-
var fillCount = IPV6_GROUP_COUNT - left.length - right.length;
|
|
324
|
-
if (fillCount < 0) return null;
|
|
325
|
-
var middle = [];
|
|
326
|
-
for (var fi = 0; fi < fillCount; fi++) middle.push("0");
|
|
327
|
-
groups = left.concat(middle).concat(right);
|
|
328
|
-
}
|
|
329
|
-
// Each group is 1–4 hex chars.
|
|
330
|
-
var bytes = [];
|
|
331
|
-
for (var gi = 0; gi < IPV6_GROUP_COUNT; gi++) {
|
|
332
|
-
var g = groups[gi];
|
|
333
|
-
if (typeof g !== "string" || g.length === 0 || g.length > 4 || /[^0-9a-f]/.test(g)) return null;
|
|
334
|
-
var v = parseInt(g, HEX_RADIX);
|
|
335
|
-
if (!Number.isInteger(v) || v < 0 || v > 0xffff) return null;
|
|
336
|
-
bytes.push((v >> IP_BITS_PER_BYTE) & BYTE_MASK);
|
|
337
|
-
bytes.push(v & BYTE_MASK);
|
|
338
|
-
}
|
|
339
|
-
// Apply prefix in bits.
|
|
340
|
-
var keepBytes = Math.floor(prefix / IP_BITS_PER_BYTE);
|
|
341
|
-
var keepBits = prefix % IP_BITS_PER_BYTE;
|
|
342
|
-
for (var bi = 0; bi < IPV6_BYTE_COUNT; bi++) {
|
|
343
|
-
if (bi < keepBytes) continue;
|
|
344
|
-
if (bi === keepBytes && keepBits > 0) {
|
|
345
|
-
var m = (BYTE_MASK << (IP_BITS_PER_BYTE - keepBits)) & BYTE_MASK;
|
|
346
|
-
bytes[bi] = bytes[bi] & m;
|
|
347
|
-
} else {
|
|
348
|
-
bytes[bi] = 0;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
// Re-emit as colon-hex (no compression — deterministic for hashing).
|
|
352
|
-
var out = [];
|
|
353
|
-
for (var oi = 0; oi < IPV6_BYTE_COUNT; oi += 2) {
|
|
354
|
-
out.push(((bytes[oi] << IP_BITS_PER_BYTE) | bytes[oi + 1]).toString(HEX_RADIX));
|
|
355
|
-
}
|
|
356
|
-
return out.join(":") + "/" + prefix;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function _ipPrefix(ip) {
|
|
360
|
-
if (typeof ip !== "string" || ip.length === 0) return "";
|
|
361
|
-
// IPv4-mapped IPv6 (::ffff:1.2.3.4) — strip the wrapper so the v4
|
|
362
|
-
// mask applies. Same bucket regardless of how the proxy reported it.
|
|
363
|
-
var lower = ip.toLowerCase();
|
|
364
|
-
if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
|
|
365
|
-
return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), IPV4_DEFAULT_PREFIX) || "";
|
|
302
|
+
// public client IP every few requests as the device hops cells, so a strict
|
|
303
|
+
// full-IP fingerprint logs out healthy mobile users. The "clientIpPrefix"
|
|
304
|
+
// field hashes the /24 (IPv4) + /64 (IPv6) subnet bucket instead — drift
|
|
305
|
+
// across the bucket is meaningfully suspicious, drift within is not. The
|
|
306
|
+
// masking lives in requestHelpers.ipPrefix (the IP-utilities home, next to
|
|
307
|
+
// clientIp / trustedClientIp); operators with stricter needs pass a
|
|
308
|
+
// function-form fingerprint field and reuse requestHelpers.ipPrefix for a
|
|
309
|
+
// custom mask width.
|
|
310
|
+
|
|
311
|
+
// Resolve the per-call client-IP function for the clientIp / clientIpPrefix
|
|
312
|
+
// fingerprint fields. With { trustedProxies } (an array/string of CIDRs) or a
|
|
313
|
+
// custom { clientIpResolver }, the IP is peer-gated through
|
|
314
|
+
// requestHelpers.trustedClientIp so a deployment behind a trusted proxy binds
|
|
315
|
+
// the session to the real client and not the proxy address (which silently
|
|
316
|
+
// defeats the IP component of the fingerprint). With neither, it falls back to
|
|
317
|
+
// the bare-socket peer — the historical default, preserved so existing
|
|
318
|
+
// fingerprints don't change and log users out. The SAME option must be passed
|
|
319
|
+
// to create / verify / rotate (exactly like fingerprintFields) or the
|
|
320
|
+
// fingerprint won't match across the session lifecycle. An invalid CIDR throws
|
|
321
|
+
// at the call (config-time entry-point validation).
|
|
322
|
+
function _clientIpResolver(opts) {
|
|
323
|
+
if (opts && (opts.trustedProxies != null || typeof opts.clientIpResolver === "function")) {
|
|
324
|
+
return requestHelpers.trustedClientIp({
|
|
325
|
+
trustedProxies: opts.trustedProxies,
|
|
326
|
+
clientIpResolver: opts.clientIpResolver,
|
|
327
|
+
}).resolve;
|
|
366
328
|
}
|
|
367
|
-
|
|
368
|
-
if (ip.indexOf(".") !== -1) return _maskIpv4(ip, IPV4_DEFAULT_PREFIX) || "";
|
|
369
|
-
return "";
|
|
329
|
+
return requestHelpers.clientIp;
|
|
370
330
|
}
|
|
371
331
|
|
|
372
|
-
function _buildFingerprintInputs(req, fields) {
|
|
332
|
+
function _buildFingerprintInputs(req, fields, resolveIp) {
|
|
373
333
|
if (!req) return null;
|
|
334
|
+
resolveIp = resolveIp || requestHelpers.clientIp;
|
|
374
335
|
var headers = req.headers || {};
|
|
375
336
|
var inputs = {};
|
|
376
337
|
for (var i = 0; i < fields.length; i++) {
|
|
377
338
|
var f = fields[i];
|
|
378
339
|
if (f === "clientIp") {
|
|
379
|
-
inputs.clientIp =
|
|
340
|
+
inputs.clientIp = resolveIp(req) || "";
|
|
380
341
|
} else if (f === "clientIpPrefix") {
|
|
381
|
-
// /24 v4 + /64 v6 — see
|
|
382
|
-
inputs.clientIpPrefix =
|
|
342
|
+
// /24 v4 + /64 v6 — see requestHelpers.ipPrefix commentary.
|
|
343
|
+
inputs.clientIpPrefix = requestHelpers.ipPrefix(resolveIp(req) || "");
|
|
383
344
|
} else if (f === "userAgent") {
|
|
384
345
|
inputs.userAgent = String(headers["user-agent"] || "");
|
|
385
346
|
} else if (f === "acceptLanguage") {
|
|
@@ -482,7 +443,7 @@ async function create(opts) {
|
|
|
482
443
|
var dataObj = opts.data ? Object.assign({}, opts.data) : null;
|
|
483
444
|
var fpFields = Array.isArray(opts.fingerprintFields) && opts.fingerprintFields.length > 0
|
|
484
445
|
? opts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
|
|
485
|
-
var fpInputs = _buildFingerprintInputs(opts.req, fpFields);
|
|
446
|
+
var fpInputs = _buildFingerprintInputs(opts.req, fpFields, _clientIpResolver(opts));
|
|
486
447
|
if (fpInputs) {
|
|
487
448
|
if (!dataObj) dataObj = {};
|
|
488
449
|
dataObj.__bj_fingerprint = _hashFingerprint(sid, fpInputs);
|
|
@@ -660,7 +621,7 @@ async function verify(token, verifyOpts) {
|
|
|
660
621
|
if (storedFingerprint && verifyOpts.req) {
|
|
661
622
|
var fpFields = Array.isArray(verifyOpts.fingerprintFields) && verifyOpts.fingerprintFields.length > 0
|
|
662
623
|
? verifyOpts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
|
|
663
|
-
var currentInputs = _buildFingerprintInputs(verifyOpts.req, fpFields);
|
|
624
|
+
var currentInputs = _buildFingerprintInputs(verifyOpts.req, fpFields, _clientIpResolver(verifyOpts));
|
|
664
625
|
var currentHash = _hashFingerprint(sid, currentInputs);
|
|
665
626
|
if (currentHash !== storedFingerprint) {
|
|
666
627
|
fingerprintDrift = true;
|
|
@@ -868,21 +829,23 @@ async function destroyAllForUser(userId) {
|
|
|
868
829
|
var result = await _currentStore().execute(built.sql, built.params);
|
|
869
830
|
// Also raise the stateless valid-from boundary so a "logout everywhere"
|
|
870
831
|
// revokes the operator's stateless tokens (sealed cookies / JWTs checked via
|
|
871
|
-
// b.session.check) too, not only the store-backed rows just deleted.
|
|
872
|
-
//
|
|
873
|
-
// (b.session.useStore)
|
|
874
|
-
//
|
|
875
|
-
//
|
|
832
|
+
// b.session.check) too, not only the store-backed rows just deleted. bump()
|
|
833
|
+
// writes to the framework db when one is initialized, otherwise to the
|
|
834
|
+
// configured store (b.session.useStore) — so a store-backed-only consumer who
|
|
835
|
+
// never ran b.db.init() still raises the boundary here instead of 500ing on
|
|
836
|
+
// db/not-initialized (#340). The only state in which bump still surfaces
|
|
837
|
+
// db/not-initialized is the default store (no useStore) with an uninitialized
|
|
838
|
+
// framework db — but the store DELETE above (also via clusterStorage) would
|
|
839
|
+
// have already thrown, so this rewrap is defensive belt-and-suspenders.
|
|
876
840
|
try {
|
|
877
841
|
await bump(userId);
|
|
878
842
|
} catch (e) {
|
|
879
843
|
if (e && e.code === "db/not-initialized") {
|
|
880
844
|
throw _err("MISCONFIGURED",
|
|
881
845
|
"session.destroyAllForUser raises the stateless valid-from boundary (so a " +
|
|
882
|
-
"logout-everywhere also revokes sealed-cookie / JWT sessions)
|
|
883
|
-
"b.db.init()
|
|
884
|
-
"
|
|
885
|
-
"after b.db.init() to also raise the stateless boundary.", true);
|
|
846
|
+
"logout-everywhere also revokes sealed-cookie / JWT sessions). No storage is " +
|
|
847
|
+
"available: call b.db.init() at boot, OR configure a session store via " +
|
|
848
|
+
"b.session.useStore. The store-backed rows were already deleted.", true);
|
|
886
849
|
}
|
|
887
850
|
throw e;
|
|
888
851
|
}
|
|
@@ -1058,7 +1021,7 @@ async function rotate(oldToken, opts) {
|
|
|
1058
1021
|
"so the device binding can be re-keyed to the new session id", true);
|
|
1059
1022
|
}
|
|
1060
1023
|
if (!newDataObj) newDataObj = {};
|
|
1061
|
-
newDataObj.__bj_fingerprint = _hashFingerprint(newSid, _buildFingerprintInputs(opts.req, fpFields));
|
|
1024
|
+
newDataObj.__bj_fingerprint = _hashFingerprint(newSid, _buildFingerprintInputs(opts.req, fpFields, _clientIpResolver(opts)));
|
|
1062
1025
|
}
|
|
1063
1026
|
|
|
1064
1027
|
var dataJson = newDataObj ? JSON.stringify(newDataObj) : null;
|
|
@@ -1422,21 +1385,27 @@ async function bump(subjectId, opts) {
|
|
|
1422
1385
|
.returning(["validFromEpoch"])
|
|
1423
1386
|
.toSql();
|
|
1424
1387
|
// The valid-from boundary is a FRAMEWORK table (FRAMEWORK_SCHEMA / cluster
|
|
1425
|
-
// DDL), NOT session data — it
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1388
|
+
// DDL), NOT session data — it executes against clusterStorage (the framework
|
|
1389
|
+
// db) whenever one is initialized, never _currentStore(). When the framework
|
|
1390
|
+
// db is NOT initialized but an operator store IS configured (a store-backed-
|
|
1391
|
+
// only b.session.useStore deployment that never ran b.db.init()), the boundary
|
|
1392
|
+
// falls back to that store — provisioned on demand — so logout-everywhere
|
|
1393
|
+
// still raises the stateless boundary instead of throwing db/not-initialized
|
|
1394
|
+
// (#340). _runValidFrom resolves the target; never silently drops the boundary
|
|
1395
|
+
// when a db is present, and propagates db/not-initialized when neither exists.
|
|
1396
|
+
var row = await _runValidFrom(async function (target) {
|
|
1397
|
+
if (built.readbackSql) {
|
|
1398
|
+
// MySQL: ON DUPLICATE KEY UPDATE has no RETURNING — run the upsert, then
|
|
1399
|
+
// the readback SELECT b.sql emits (keyed on subjectHash). MySQL only ever
|
|
1400
|
+
// runs against the framework cluster db; the localDbThin fallback store is
|
|
1401
|
+
// sqlite (RETURNING), so this branch never routes through it.
|
|
1402
|
+
await target.execute(built.sql, built.params);
|
|
1403
|
+
var readback = await target.execute(built.readbackSql.sql, built.readbackSql.params);
|
|
1404
|
+
return readback.rows && readback.rows[0];
|
|
1405
|
+
}
|
|
1406
|
+
var result = await target.execute(built.sql, built.params);
|
|
1407
|
+
return result.rows && result.rows[0];
|
|
1408
|
+
});
|
|
1440
1409
|
var effective = row ? Number(row.validFromEpoch) : epochMs;
|
|
1441
1410
|
|
|
1442
1411
|
// Best-effort audit — matches the file's emit convention (safeEmit is
|
|
@@ -1475,10 +1444,16 @@ async function validFrom(subjectId) {
|
|
|
1475
1444
|
.columns(["validFromEpoch"])
|
|
1476
1445
|
.where("subjectHash", _hashSubjectId(subjectId))
|
|
1477
1446
|
.toSql();
|
|
1478
|
-
// Framework valid-from table — read from clusterStorage (the framework db)
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
|
|
1447
|
+
// Framework valid-from table — read from clusterStorage (the framework db)
|
|
1448
|
+
// when one is initialized, falling back to the configured store only when it
|
|
1449
|
+
// is not (the same store bump() wrote the boundary to in a store-backed-only
|
|
1450
|
+
// deployment). _runValidFrom keeps the read on the SAME backend the write
|
|
1451
|
+
// chose, so a boundary raised via destroyAllForUser/bump is the one read back
|
|
1452
|
+
// here. See bump() for the full rationale (#340).
|
|
1453
|
+
var row = await _runValidFrom(async function (target) {
|
|
1454
|
+
var result = await target.execute(built.sql, built.params);
|
|
1455
|
+
return result.rows && result.rows.length > 0 ? result.rows[0] : null;
|
|
1456
|
+
});
|
|
1482
1457
|
return row ? Number(row.validFromEpoch) : 0;
|
|
1483
1458
|
}
|
|
1484
1459
|
|
package/lib/sql.js
CHANGED
|
@@ -798,6 +798,17 @@ class Predicate {
|
|
|
798
798
|
return this._add(joiner, qc + " " + op + " NULL", []);
|
|
799
799
|
}
|
|
800
800
|
|
|
801
|
+
// `col = NULL` / `col != NULL` is UNKNOWN in SQL — never true. Emitting it
|
|
802
|
+
// (e.g. from where({ col: null })) silently matches zero rows; worse, a null
|
|
803
|
+
// accidentally passed where a real value was expected (where({ ownerId }))
|
|
804
|
+
// would, if rewritten to `IS NULL`, return orphan rows — an authorization
|
|
805
|
+
// footgun. Refuse it and direct the caller to the explicit NULL predicates.
|
|
806
|
+
if (value === null && (op === "=" || op === "!=" || op === "<>")) {
|
|
807
|
+
throw _err("where(" + JSON.stringify(col) + ", '" + op + "', null) is never true in SQL " +
|
|
808
|
+
"(col " + op + " NULL is UNKNOWN); use whereNull(col) / whereNotNull(col) to test for NULL",
|
|
809
|
+
"sql-builder/null-equality");
|
|
810
|
+
}
|
|
811
|
+
|
|
801
812
|
if ((op === "LIKE" || op === "NOT LIKE") && typeof value === "string") {
|
|
802
813
|
return this._add(joiner, qc + " " + op + " ? ESCAPE '~'", [_escapeLike(value)]);
|
|
803
814
|
}
|
|
@@ -948,6 +959,17 @@ class Predicate {
|
|
|
948
959
|
if (!Array.isArray(values) || values.length === 0) {
|
|
949
960
|
throw _err("whereInArray requires a non-empty array of values", "sql-builder/empty-in");
|
|
950
961
|
}
|
|
962
|
+
// Validate each element is a bindable parameter. On the non-Postgres IN-list
|
|
963
|
+
// path every element is its own `?`, so the driver rejects an undefined at
|
|
964
|
+
// execute; the Postgres `= ANY(?)` path binds the WHOLE array as one param,
|
|
965
|
+
// where an undefined is silently coerced to NULL — diverging per dialect.
|
|
966
|
+
// Reject undefined here so every backend fails the same way, at build.
|
|
967
|
+
for (var vi = 0; vi < values.length; vi += 1) {
|
|
968
|
+
if (values[vi] === undefined) {
|
|
969
|
+
throw _err("whereInArray value[" + vi + "] is undefined (not a bindable parameter)",
|
|
970
|
+
"sql-builder/bad-in-value");
|
|
971
|
+
}
|
|
972
|
+
}
|
|
951
973
|
this._gate(col);
|
|
952
974
|
var qc = _qualifiedColumn(col, this._dialect());
|
|
953
975
|
if (this._dialect() === "postgres") {
|
package/lib/ws-client.js
CHANGED
|
@@ -585,6 +585,7 @@ class WsClient extends EventEmitter {
|
|
|
585
585
|
this._reconnectAttempt = 0;
|
|
586
586
|
this._fragmentChunks = [];
|
|
587
587
|
this._fragmentOpcode = null;
|
|
588
|
+
this._fragmentBytes = 0;
|
|
588
589
|
|
|
589
590
|
this._startHeartbeat();
|
|
590
591
|
if (this._opts.auditOn) {
|
|
@@ -623,6 +624,14 @@ class WsClient extends EventEmitter {
|
|
|
623
624
|
}
|
|
624
625
|
|
|
625
626
|
_handleFrame(frame) {
|
|
627
|
+
// Once the connection has been torn down (e.g. a prior frame in the same
|
|
628
|
+
// parsed batch tripped maxMessageBytes and called _teardown, which sets
|
|
629
|
+
// _closed synchronously), drop any remaining buffered frames — processing
|
|
630
|
+
// them would emit a spurious cascade of protocol errors (a stray
|
|
631
|
+
// continuation after the fragment state reset). A graceful close keeps
|
|
632
|
+
// _closed false until the handshake completes, so the peer's CLOSE frame
|
|
633
|
+
// is still processed and the normal close code surfaces.
|
|
634
|
+
if (this._closed) return;
|
|
626
635
|
// RFC 6455 §5.5: control frames MUST be <= 125 bytes AND non-fragmented.
|
|
627
636
|
var isControl = frame.opcode === OPCODE_PING ||
|
|
628
637
|
frame.opcode === OPCODE_PONG ||
|
|
@@ -692,6 +701,7 @@ class WsClient extends EventEmitter {
|
|
|
692
701
|
this._fragmentOpcode = frame.opcode;
|
|
693
702
|
this._fragmentRsv1 = frame.rsv1 === true;
|
|
694
703
|
this._fragmentChunks = [frame.payload];
|
|
704
|
+
this._fragmentBytes = safeBuffer.byteLengthOf(frame.payload);
|
|
695
705
|
} else if (frame.opcode === OPCODE_CONT) {
|
|
696
706
|
if (this._fragmentOpcode == null) {
|
|
697
707
|
this._handleSocketError(new WsClientError("ws-client/protocol-error",
|
|
@@ -699,6 +709,21 @@ class WsClient extends EventEmitter {
|
|
|
699
709
|
return;
|
|
700
710
|
}
|
|
701
711
|
this._fragmentChunks.push(frame.payload);
|
|
712
|
+
this._fragmentBytes += safeBuffer.byteLengthOf(frame.payload);
|
|
713
|
+
}
|
|
714
|
+
// Enforce maxMessageBytes on the RUNNING fragment total, not only at FIN:
|
|
715
|
+
// a peer that streams continuation frames and never sets FIN would
|
|
716
|
+
// otherwise grow _fragmentChunks without bound, one maxFrameBytes-sized
|
|
717
|
+
// frame at a time (CWE-770 / CWE-400). The per-frame parser cap bounds a
|
|
718
|
+
// single frame, never the sum.
|
|
719
|
+
if (this._fragmentOpcode != null && this._fragmentBytes > this._opts.maxMessageBytes) {
|
|
720
|
+
this._fragmentChunks = [];
|
|
721
|
+
this._fragmentOpcode = null;
|
|
722
|
+
this._fragmentRsv1 = false;
|
|
723
|
+
this._fragmentBytes = 0;
|
|
724
|
+
this._handleSocketError(new WsClientError("ws-client/message-too-big",
|
|
725
|
+
"incoming message exceeds maxMessageBytes (" + this._opts.maxMessageBytes + ")"));
|
|
726
|
+
return;
|
|
702
727
|
}
|
|
703
728
|
if (frame.fin) {
|
|
704
729
|
var fullPayload = Buffer.concat(this._fragmentChunks); // allow:handrolled-buffer-collect — bounded by maxMessageBytes below
|
|
@@ -712,6 +737,7 @@ class WsClient extends EventEmitter {
|
|
|
712
737
|
this._fragmentChunks = [];
|
|
713
738
|
this._fragmentOpcode = null;
|
|
714
739
|
this._fragmentRsv1 = false;
|
|
740
|
+
this._fragmentBytes = 0;
|
|
715
741
|
if (this._negotiatedDeflate && firstFrameRsv1) {
|
|
716
742
|
try {
|
|
717
743
|
var zlib = require("node:zlib"); // allow:inline-require — zlib only on deflate-negotiated path
|
package/lib/x509-chain.js
CHANGED
|
@@ -1,33 +1,80 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.x509Chain
|
|
4
|
+
* @nav Crypto
|
|
5
|
+
* @title X.509 chain (CA-bit issuer test)
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* The basicConstraints-enforcing issuer test the framework's own
|
|
9
|
+
* certificate-chain walkers route through (<code>b.tsa.verifyToken</code>,
|
|
10
|
+
* <code>b.mail.bimi</code> VMC/CMC, <code>b.mail.crypto.smime</code>,
|
|
11
|
+
* <code>b.mdoc</code>, <code>b.contentCredentials</code>,
|
|
12
|
+
* <code>b.auth.fido</code>). It exists because node:crypto's
|
|
13
|
+
* <code>X509Certificate.checkIssued()</code> validates the issuer/subject
|
|
14
|
+
* DN match, the AKI/SKI linkage, and — only when a keyUsage extension is
|
|
15
|
+
* present — keyCertSign, but it does <strong>not</strong> enforce
|
|
16
|
+
* basicConstraints cA:TRUE. A leaf / end-entity certificate (cA:FALSE)
|
|
17
|
+
* that omits keyUsage is therefore wrongly accepted as a signing CA for
|
|
18
|
+
* the next certificate in the chain — the classic basicConstraints bypass
|
|
19
|
+
* (CVE-2002-0862 class). Every in-tree walker routes its issuer test
|
|
20
|
+
* through these helpers so the cA enforcement can never be forgotten in
|
|
21
|
+
* one walker but present in another.
|
|
22
|
+
*
|
|
23
|
+
* Exposed so a consumer validating an X.509 chain <em>outside</em> a TLS
|
|
24
|
+
* handshake — an operator-uploaded CA bundle, a non-handshake PQ-signed
|
|
25
|
+
* certificate — has the same hardened, fail-closed test instead of being
|
|
26
|
+
* pushed toward the raw <code>checkIssued()</code> path this module
|
|
27
|
+
* exists to prevent. Both helpers fail closed: any malformed input or
|
|
28
|
+
* unsupported key type returns false rather than throwing.
|
|
29
|
+
*
|
|
30
|
+
* @card
|
|
31
|
+
* basicConstraints cA:TRUE-enforcing X.509 issuer test, fail-closed —
|
|
32
|
+
* the hardened alternative to node's checkIssued() for chains built
|
|
33
|
+
* outside a TLS handshake.
|
|
34
|
+
*/
|
|
2
35
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
|
|
36
|
+
/**
|
|
37
|
+
* @primitive b.x509Chain.isCaCert
|
|
38
|
+
* @signature b.x509Chain.isCaCert(cert)
|
|
39
|
+
* @since 0.15.15
|
|
40
|
+
* @status stable
|
|
41
|
+
* @related b.x509Chain.issuerValidlyIssued
|
|
42
|
+
*
|
|
43
|
+
* True only when <code>cert</code> asserts basicConstraints cA:TRUE.
|
|
44
|
+
* node's <code>X509Certificate</code> exposes <code>.ca</code> (a boolean);
|
|
45
|
+
* a certificate with no basicConstraints extension or with cA:FALSE
|
|
46
|
+
* returns false. A missing cert or a non-boolean <code>.ca</code> (parse
|
|
47
|
+
* failure / unsupported runtime) fails closed to false.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* var crypto = require("crypto");
|
|
51
|
+
* var ca = new crypto.X509Certificate(caPem);
|
|
52
|
+
* b.x509Chain.isCaCert(ca); // → true only if basicConstraints cA:TRUE
|
|
53
|
+
*/
|
|
21
54
|
function isCaCert(cert) {
|
|
22
55
|
return !!cert && cert.ca === true;
|
|
23
56
|
}
|
|
24
57
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
/**
|
|
59
|
+
* @primitive b.x509Chain.issuerValidlyIssued
|
|
60
|
+
* @signature b.x509Chain.issuerValidlyIssued(issuer, subject)
|
|
61
|
+
* @since 0.15.15
|
|
62
|
+
* @status stable
|
|
63
|
+
* @related b.x509Chain.isCaCert
|
|
64
|
+
*
|
|
65
|
+
* True when <code>issuer</code> validly issued <code>subject</code> AND is
|
|
66
|
+
* itself a CA: the DN / AKI-SKI / keyUsage linkage (checkIssued), the
|
|
67
|
+
* cryptographic signature (verify), and basicConstraints cA:TRUE
|
|
68
|
+
* (isCaCert). The cA check runs first so a non-CA certificate is rejected
|
|
69
|
+
* before the expensive signature verification. Any exception (malformed
|
|
70
|
+
* cert, unsupported key type) fails closed to false.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* var crypto = require("crypto");
|
|
74
|
+
* var issuer = new crypto.X509Certificate(issuerPem);
|
|
75
|
+
* var subject = new crypto.X509Certificate(leafPem);
|
|
76
|
+
* b.x509Chain.issuerValidlyIssued(issuer, subject); // → boolean
|
|
77
|
+
*/
|
|
31
78
|
function issuerValidlyIssued(issuer, subject) {
|
|
32
79
|
try {
|
|
33
80
|
return isCaCert(issuer) &&
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:159d260a-cc7c-4a49-a436-d1f67804241f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-22T13:53:37.425Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.16",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.16",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.16",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.16",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|