@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/index.js +2 -0
  3. package/lib/atomic-file.js +34 -0
  4. package/lib/auth/ciba.js +32 -8
  5. package/lib/auth/dpop.js +9 -0
  6. package/lib/auth/fido-mds3.js +35 -12
  7. package/lib/auth/jwt.js +19 -3
  8. package/lib/auth/oauth.js +8 -2
  9. package/lib/auth/password.js +1 -0
  10. package/lib/auth/saml.js +30 -12
  11. package/lib/crypto-field.js +19 -1
  12. package/lib/csp.js +9 -0
  13. package/lib/daemon.js +4 -1
  14. package/lib/db-query.js +33 -2
  15. package/lib/external-db.js +131 -0
  16. package/lib/graphql-federation.js +25 -15
  17. package/lib/log-stream-cloudwatch.js +1 -0
  18. package/lib/log-stream-local.js +14 -1
  19. package/lib/log-stream-otlp.js +1 -0
  20. package/lib/log-stream-webhook.js +1 -0
  21. package/lib/mail-auth.js +93 -15
  22. package/lib/mail-bimi.js +6 -0
  23. package/lib/mail-crypto-smime.js +10 -0
  24. package/lib/mail-dkim.js +86 -20
  25. package/lib/mail.js +39 -0
  26. package/lib/middleware/api-encrypt.js +6 -2
  27. package/lib/middleware/compose-pipeline.js +39 -5
  28. package/lib/network-dns-resolver.js +61 -11
  29. package/lib/network-dns.js +47 -2
  30. package/lib/network-nts.js +16 -0
  31. package/lib/network-proxy.js +55 -2
  32. package/lib/object-store/azure-blob-bucket-ops.js +1 -0
  33. package/lib/object-store/gcs-bucket-ops.js +1 -0
  34. package/lib/object-store/http-request.js +4 -0
  35. package/lib/outbox.js +29 -0
  36. package/lib/pipl-cn.js +11 -8
  37. package/lib/queue-sqs.js +1 -0
  38. package/lib/request-helpers.js +165 -13
  39. package/lib/safe-json.js +26 -0
  40. package/lib/session-device-binding.js +46 -24
  41. package/lib/session.js +120 -145
  42. package/lib/sql.js +22 -0
  43. package/lib/ws-client.js +26 -0
  44. package/lib/x509-chain.js +71 -24
  45. package/package.json +1 -1
  46. package/sbom.cdx.json +6 -6
@@ -7,6 +7,7 @@ var nodeTls = require("node:tls");
7
7
 
8
8
  var C = require("./constants");
9
9
  var lazyRequire = require("./lazy-require");
10
+ var safeBuffer = require("./safe-buffer");
10
11
  var safeUrl = require("./safe-url");
11
12
  var validateOpts = require("./validate-opts");
12
13
  var { defineClass } = require("./framework-error");
@@ -19,6 +20,23 @@ var IPV4_PREFIX_MAX_BITS = C.BYTES.bytes(32); // RFC 791 §3.1 IPv4 address bi
19
20
  var DEFAULT_HTTPS_PORT = 443; // RFC 9110 §4.2.2
20
21
  var DEFAULT_HTTP_PORT = C.BYTES.bytes(80); // RFC 9110 §4.2.1
21
22
 
23
+ // Bound the CONNECT-reply framing buffer (mirrors ws-client's handshake
24
+ // cap). A proxy that streams bytes without ever sending the CRLFCRLF
25
+ // header terminator would otherwise grow `buf` without limit and OOM the
26
+ // process; CONNECT replies are tiny, 64 KiB is generous headroom.
27
+ var TUNNEL_HEADER_MAX_BYTES = C.BYTES.kib(64);
28
+ // Wall-clock bound on the proxy connect + CONNECT handshake. Without it a
29
+ // proxy that accepts the socket but never replies (or trickles forever)
30
+ // hangs the tunnel indefinitely.
31
+ var TUNNEL_CONNECT_TIMEOUT_MS = C.TIME.seconds(30);
32
+ // Test-only override of the connect deadline so a unit test can prove the
33
+ // absolute-timeout behavior without a 30s wall-clock wait. null = use the
34
+ // real default. Cleared by _resetForTest.
35
+ var _testConnectTimeoutMs = null;
36
+ function _connectTimeoutMs() {
37
+ return typeof _testConnectTimeoutMs === "number" ? _testConnectTimeoutMs : TUNNEL_CONNECT_TIMEOUT_MS;
38
+ }
39
+
22
40
  var observability = lazyRequire(function () { return require("./observability"); });
23
41
  // Lazy so pqc-agent's TLS/audit graph isn't pulled into every process that
24
42
  // imports network-proxy but never proxies an https upstream. Used only to audit
@@ -169,7 +187,27 @@ function _connectThroughTunnel(proxyUrl, targetHost, targetPort, callback) {
169
187
  })
170
188
  : net.connect({ host: proxyUrl.hostname, port: proxyPort });
171
189
  var settled = false;
172
- function done(err, sock) { if (settled) return; settled = true; callback(err, sock); }
190
+ var connectDeadline = null;
191
+ function done(err, sock) {
192
+ if (settled) return;
193
+ settled = true;
194
+ if (connectDeadline) { try { clearTimeout(connectDeadline); } catch (_e) { /* best-effort */ } connectDeadline = null; }
195
+ callback(err, sock);
196
+ }
197
+ // ABSOLUTE wall-clock deadline on the proxy connect + CONNECT handshake. A
198
+ // proxy that accepts the socket but never sends the CRLFCRLF terminator —
199
+ // or trickles partial bytes just inside every idle window — must not hang
200
+ // the tunnel forever. socket.setTimeout is an IDLE timer that such a trickle
201
+ // resets indefinitely, so a fixed setTimeout (cleared in done()) enforces a
202
+ // real total-time bound regardless of how the bytes arrive.
203
+ var connectTimeoutMs = _connectTimeoutMs();
204
+ connectDeadline = setTimeout(function () {
205
+ done(new ProxyError("proxy/connect-timeout",
206
+ "proxy CONNECT to " + targetHost + ":" + targetPort + " timed out after " +
207
+ connectTimeoutMs + "ms"));
208
+ try { proxySocket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
209
+ }, connectTimeoutMs);
210
+ if (connectDeadline && typeof connectDeadline.unref === "function") connectDeadline.unref();
173
211
  proxySocket.on("error", function (e) { done(e); });
174
212
  proxySocket.on(proxyUrl.protocol === "https:" ? "secureConnect" : "connect", function () {
175
213
  if (proxyUrl.protocol === "https:") {
@@ -191,7 +229,18 @@ function _connectThroughTunnel(proxyUrl, targetHost, targetPort, callback) {
191
229
  function onData(chunk) {
192
230
  buf = Buffer.concat([buf, chunk]);
193
231
  var idx = buf.indexOf("\r\n\r\n");
194
- if (idx === -1) return;
232
+ if (idx === -1) {
233
+ // No header terminator yet — bound the accumulator so a proxy
234
+ // that trickles non-CRLFCRLF bytes forever can't OOM the process.
235
+ if (safeBuffer.byteLengthOf(buf) > TUNNEL_HEADER_MAX_BYTES) {
236
+ proxySocket.removeListener("data", onData);
237
+ done(new ProxyError("proxy/connect-headers-too-large",
238
+ "proxy CONNECT reply exceeded " + TUNNEL_HEADER_MAX_BYTES +
239
+ " bytes before CRLFCRLF"));
240
+ try { proxySocket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
241
+ }
242
+ return;
243
+ }
195
244
  proxySocket.removeListener("data", onData);
196
245
  var head = buf.slice(0, idx).toString("ascii");
197
246
  var status = head.split("\r\n")[0] || "";
@@ -271,8 +320,11 @@ function snapshot() {
271
320
  function _resetForTest() {
272
321
  STATE.http = null; STATE.https = null; STATE.noProxy = []; STATE.auth = null;
273
322
  STATE.agentCache.clear();
323
+ _testConnectTimeoutMs = null;
274
324
  }
275
325
 
326
+ function _setConnectTimeoutForTest(ms) { _testConnectTimeoutMs = ms; }
327
+
276
328
  module.exports = {
277
329
  set: set,
278
330
  fromEnv: fromEnv,
@@ -281,4 +333,5 @@ module.exports = {
281
333
  snapshot: snapshot,
282
334
  ProxyError: ProxyError,
283
335
  _resetForTest: _resetForTest,
336
+ _setConnectTimeoutForTest: _setConnectTimeoutForTest,
284
337
  };
@@ -233,6 +233,7 @@ function create(config) {
233
233
  url: url,
234
234
  headers: headers,
235
235
  body: body,
236
+ timeoutMs: timeoutMs,
236
237
  idleTimeoutMs: timeoutMs,
237
238
  allowedProtocols: allowedProtocols,
238
239
  errorClass: ObjectStoreError,
@@ -182,6 +182,7 @@ function create(config) {
182
182
  url: url,
183
183
  headers: headers,
184
184
  body: body,
185
+ timeoutMs: timeoutMs,
185
186
  idleTimeoutMs: timeoutMs,
186
187
  allowedProtocols: allowedProtocols,
187
188
  errorClass: ObjectStoreError,
@@ -36,6 +36,10 @@ function request(method, url, headers, body, opts) {
36
36
  url: url,
37
37
  headers: headers,
38
38
  body: body,
39
+ // Both caps from the operator's configured timeout: timeoutMs bounds the
40
+ // whole request (no slow-trickle hold-open), idleTimeoutMs the zero-progress
41
+ // window. Undefined leaves httpClient's defaults unchanged.
42
+ timeoutMs: opts.timeoutMs,
39
43
  idleTimeoutMs: opts.timeoutMs,
40
44
  errorClass: opts.errorClass || ObjectStoreError,
41
45
  allowedProtocols: opts.allowedProtocols,
package/lib/outbox.js CHANGED
@@ -214,6 +214,35 @@ function create(opts) {
214
214
  throw new OutboxError("outbox/bad-externaldb",
215
215
  "outbox.create: externalDb must be the b.externalDb namespace (with transaction/query)");
216
216
  }
217
+ // The outbox dual-write guarantee (enqueue runs in the SAME
218
+ // transaction as the operator's business write) is VOID on a
219
+ // stateless / autocommit-per-statement backend: BEGIN / the
220
+ // business write / the enqueue INSERT / COMMIT each land on a
221
+ // different session, so a crash or rollback can drop the business
222
+ // row while keeping the event (or vice versa). Refuse at create()
223
+ // when the backend declares it cannot provide interactive
224
+ // transactions, rather than constructing an outbox whose core
225
+ // guarantee silently does not hold. supportsTransactions may be a
226
+ // boolean (custom externalDb object) or a function (the
227
+ // b.externalDb namespace probe); only an explicit false refuses —
228
+ // an absent flag preserves the historical stateful assumption.
229
+ var cap = v.supportsTransactions;
230
+ var atomic = true;
231
+ if (typeof cap === "function") {
232
+ try { atomic = !!cap(); } catch (_e) { atomic = true; }
233
+ } else if (cap === false) {
234
+ atomic = false;
235
+ }
236
+ if (!atomic) {
237
+ throw new OutboxError("outbox/non-atomic-backend",
238
+ "outbox.create: externalDb backend cannot provide interactive " +
239
+ "transactions (supportsTransactions: false — a stateless / " +
240
+ "autocommit-per-statement adapter). The outbox dual-write guarantee " +
241
+ "requires the business write and the enqueue INSERT to commit " +
242
+ "atomically in one transaction; on this backend they would not. " +
243
+ "Supply interactive beginTx / commit / rollback hooks, or a batch " +
244
+ "adapter, on the externalDb backend first.");
245
+ }
217
246
  },
218
247
  table: function (v) {
219
248
  validateOpts.requireNonEmptyString(v,
package/lib/pipl-cn.js CHANGED
@@ -108,14 +108,17 @@ function _requireRecordedAt(value, label) {
108
108
  * and determine the lawful mechanism the transfer requires. PIPL Art. 38(1)
109
109
  * permits three bases for moving personal information out of the PRC — the
110
110
  * CAC standard contract (SCC), a CAC security assessment (Art. 40), or
111
- * certification by a CAC-accredited body but the Measures for Security
112
- * Assessment of Outbound Data Transfers make the security assessment
113
- * MANDATORY (the operator may NOT self-select the standard contract or
114
- * certification) when the exporter is a critical-information-infrastructure
115
- * operator (CIIO), exports "important data", handles personal information
116
- * of more than 1,000,000 individuals, or has cumulatively exported PI of
117
- * more than 100,000 individuals or sensitive PI of more than 10,000
118
- * individuals since 1 January of the preceding year.
111
+ * certification by a CAC-accredited body. Under the CAC Provisions on
112
+ * Promoting and Regulating Cross-Border Data Flows (effective 2024, which
113
+ * relaxed the 2022 thresholds) the security assessment is MANDATORY (the
114
+ * operator may NOT self-select the standard contract or certification) when
115
+ * the exporter is a critical-information-infrastructure operator (CIIO),
116
+ * exports "important data", or counting cumulatively since 1 January of
117
+ * the current year transfers the personal information of more than
118
+ * 1,000,000 individuals (non-sensitive) or the sensitive personal
119
+ * information of more than 10,000 individuals. The 100,000–1,000,000
120
+ * non-sensitive band is the standard-contract / certification tier, NOT a
121
+ * security-assessment trigger.
119
122
  *
120
123
  * The builder validates the operator-supplied facts, computes
121
124
  * `securityAssessmentRequired` against those thresholds, resolves the
package/lib/queue-sqs.js CHANGED
@@ -153,6 +153,7 @@ function create(opts) {
153
153
  url: endpointUrl,
154
154
  headers: signed.headers,
155
155
  body: bodyBuf,
156
+ timeoutMs: timeoutMs,
156
157
  idleTimeoutMs: timeoutMs,
157
158
  allowedProtocols: allowedProtocols,
158
159
  errorClass: QueueError,
@@ -384,6 +384,153 @@ function trustedClientIp(opts) {
384
384
  };
385
385
  }
386
386
 
387
+ // IP-prefix masking constants — named so the bit-arithmetic stays readable.
388
+ // /24 IPv4 is the original IP-geolocation bucket and matches the legacy
389
+ // carrier-NAT pool stride; /64 IPv6 is the customer LAN every RIR allocates
390
+ // (RFC 4291 §2.5.4), so tightening below it punishes IPv6 privacy-extension
391
+ // address rotation.
392
+ var IP_BITS_PER_BYTE = 8; // bits per byte; protocol constant, not a byte size
393
+ var IPV4_OCTET_COUNT = 4;
394
+ var IPV4_OCTET_RANGE = 256; // 0..255 inclusive; v4 octet domain
395
+ var IPV4_TOTAL_BITS = 32; // IPv4 address width in bits
396
+ var IPV4_DEFAULT_PREFIX = 24; // /24 carrier-NAT pool stride
397
+ var IPV6_GROUP_COUNT = 8; // 8 16-bit groups in v6
398
+ var IPV6_BYTE_COUNT = 16; // 16 bytes in v6
399
+ var IPV6_DEFAULT_PREFIX = 64; // /64 customer LAN per RFC 4291 §2.5.4
400
+ var IP_BYTE_MASK = 0xff;
401
+ var IP_HEX_RADIX = 16; // base-16 radix
402
+ var V4_MAPPED_V6_PREFIX = "::ffff:";
403
+
404
+ function _maskIpv4(ip, prefix) {
405
+ // ip = "a.b.c.d"; prefix is bits to keep (1..32).
406
+ var parts = String(ip).split(".");
407
+ if (parts.length !== IPV4_OCTET_COUNT) return null;
408
+ var n = 0;
409
+ for (var i = 0; i < IPV4_OCTET_COUNT; i++) {
410
+ var oct = parseInt(parts[i], 10);
411
+ if (!Number.isInteger(oct) || oct < 0 || oct >= IPV4_OCTET_RANGE) return null;
412
+ n = (n * IPV4_OCTET_RANGE) + oct;
413
+ }
414
+ // Apply prefix mask.
415
+ var mask = prefix === 0 ? 0 : (-1 >>> (IPV4_TOTAL_BITS - prefix)) << (IPV4_TOTAL_BITS - prefix);
416
+ // Bitwise on 32-bit unsigned. JS coerces to 32-bit signed, so use
417
+ // unsigned right shift to recover.
418
+ var masked = (n & mask) >>> 0;
419
+ return ((masked >>> IP_BITS_PER_BYTE * 3) & IP_BYTE_MASK) + "." +
420
+ ((masked >>> IP_BITS_PER_BYTE * 2) & IP_BYTE_MASK) + "." +
421
+ ((masked >>> IP_BITS_PER_BYTE) & IP_BYTE_MASK) + "." +
422
+ (masked & IP_BYTE_MASK) + "/" + prefix;
423
+ }
424
+
425
+ function _maskIpv6(ip, prefix) {
426
+ // Expand to 8 16-bit groups. Accept :: shorthand. Reject if invalid.
427
+ var raw = String(ip).toLowerCase();
428
+ // Strip an embedded zone id (fe80::1%eth0); not part of the address.
429
+ var pct = raw.indexOf("%");
430
+ if (pct !== -1) raw = raw.substring(0, pct);
431
+ var doubleColonAt = raw.indexOf("::");
432
+ var groups;
433
+ if (doubleColonAt === -1) {
434
+ groups = raw.split(":");
435
+ if (groups.length !== IPV6_GROUP_COUNT) return null;
436
+ } else {
437
+ var left = raw.substring(0, doubleColonAt).split(":");
438
+ var right = raw.substring(doubleColonAt + 2).split(":");
439
+ if (left.length === 1 && left[0] === "") left = [];
440
+ if (right.length === 1 && right[0] === "") right = [];
441
+ var fillCount = IPV6_GROUP_COUNT - left.length - right.length;
442
+ if (fillCount < 0) return null;
443
+ var middle = [];
444
+ for (var fi = 0; fi < fillCount; fi++) middle.push("0");
445
+ groups = left.concat(middle).concat(right);
446
+ }
447
+ // Each group is 1–4 hex chars.
448
+ var bytes = [];
449
+ for (var gi = 0; gi < IPV6_GROUP_COUNT; gi++) {
450
+ var g = groups[gi];
451
+ if (typeof g !== "string" || g.length === 0 || g.length > 4 || /[^0-9a-f]/.test(g)) return null;
452
+ var v = parseInt(g, IP_HEX_RADIX);
453
+ if (!Number.isInteger(v) || v < 0 || v > 0xffff) return null;
454
+ bytes.push((v >> IP_BITS_PER_BYTE) & IP_BYTE_MASK);
455
+ bytes.push(v & IP_BYTE_MASK);
456
+ }
457
+ // Apply prefix in bits.
458
+ var keepBytes = Math.floor(prefix / IP_BITS_PER_BYTE);
459
+ var keepBits = prefix % IP_BITS_PER_BYTE;
460
+ for (var bi = 0; bi < IPV6_BYTE_COUNT; bi++) {
461
+ if (bi < keepBytes) continue;
462
+ if (bi === keepBytes && keepBits > 0) {
463
+ var m = (IP_BYTE_MASK << (IP_BITS_PER_BYTE - keepBits)) & IP_BYTE_MASK;
464
+ bytes[bi] = bytes[bi] & m;
465
+ } else {
466
+ bytes[bi] = 0;
467
+ }
468
+ }
469
+ // Re-emit as colon-hex (no compression — deterministic for hashing).
470
+ var out = [];
471
+ for (var oi = 0; oi < IPV6_BYTE_COUNT; oi += 2) {
472
+ out.push(((bytes[oi] << IP_BITS_PER_BYTE) | bytes[oi + 1]).toString(IP_HEX_RADIX));
473
+ }
474
+ return out.join(":") + "/" + prefix;
475
+ }
476
+
477
+ /**
478
+ * @primitive b.requestHelpers.ipPrefix
479
+ * @signature b.requestHelpers.ipPrefix(ip, opts?)
480
+ * @since 0.15.15
481
+ * @related b.requestHelpers.clientIp, b.requestHelpers.trustedClientIp
482
+ *
483
+ * Mask a client IP to its subnet bucket: a <code>/24</code> for IPv4 (the
484
+ * carrier-NAT pool stride) and a <code>/64</code> for IPv6 (the customer-LAN
485
+ * prefix RIRs allocate, RFC 4291 §2.5.4). Returns the canonical
486
+ * <code>"network/prefix"</code> string, or <code>""</code> for a non-string /
487
+ * empty / unparseable input. An IPv4-mapped IPv6 address
488
+ * (<code>::ffff:1.2.3.4</code>) folds to its dotted form so it buckets the
489
+ * same regardless of how a proxy reported it.
490
+ *
491
+ * This is the masking the session device-fingerprint's built-in
492
+ * <code>clientIpPrefix</code> field hashes (so roaming carriers that flip the
493
+ * public IP within a subnet don't log a user out). Exposed so an operator who
494
+ * drops to a function-form fingerprint field — for a custom mask width, or to
495
+ * combine the prefix with other signals — reuses this exact algorithm instead
496
+ * of re-deriving the /24 + /64 masking (and silently diverging). Pass
497
+ * <code>opts.v4Bits</code> / <code>opts.v6Bits</code> to override the mask
498
+ * widths (e.g. a device fingerprint that buckets at <code>/48</code> so a
499
+ * client roaming within its allocation but across a <code>/64</code> doesn't
500
+ * drift); an out-of-range or absent value falls back to the /24 + /64 default.
501
+ *
502
+ * @opts
503
+ * v4Bits: number, // IPv4 mask width in bits (default 24; valid 0..32)
504
+ * v6Bits: number, // IPv6 mask width in bits (default 64; valid 0..128)
505
+ *
506
+ * @example
507
+ * b.requestHelpers.ipPrefix("203.0.113.47"); // → "203.0.113.0/24"
508
+ * b.requestHelpers.ipPrefix("2001:db8::1"); // → "2001:db8:0:0/64"
509
+ */
510
+ function _resolvePrefixBits(bits, def, max) {
511
+ if (typeof bits !== "number" || !isFinite(bits) || bits < 0 || bits > max) return def;
512
+ return bits;
513
+ }
514
+ function ipPrefix(ip, opts) {
515
+ if (typeof ip !== "string" || ip.length === 0) return "";
516
+ opts = opts || {};
517
+ // Configurable mask widths (default /24 + /64); an absent or out-of-range
518
+ // value falls back to the default. This preserves a caller's documented
519
+ // prefix width (e.g. a device fingerprint bucketing at /48) instead of
520
+ // silently forcing /64 — while still canonicalizing the address.
521
+ var v4 = _resolvePrefixBits(opts.v4Bits, IPV4_DEFAULT_PREFIX, 32); // IPv4 max prefix length in bits
522
+ var v6 = _resolvePrefixBits(opts.v6Bits, IPV6_DEFAULT_PREFIX, 128); // IPv6 max prefix length in bits
523
+ // IPv4-mapped IPv6 (::ffff:1.2.3.4) — strip the wrapper so the v4 mask
524
+ // applies. Same bucket regardless of how the proxy reported it.
525
+ var lower = ip.toLowerCase();
526
+ if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
527
+ return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), v4) || "";
528
+ }
529
+ if (ip.indexOf(":") !== -1) return _maskIpv6(ip, v6) || "";
530
+ if (ip.indexOf(".") !== -1) return _maskIpv4(ip, v4) || "";
531
+ return "";
532
+ }
533
+
387
534
  /**
388
535
  * @primitive b.requestHelpers.trustedProtocol
389
536
  * @signature b.requestHelpers.trustedProtocol(opts?)
@@ -787,8 +934,6 @@ function captureResponseStatus(res, onEnd) {
787
934
  return origEnd;
788
935
  }
789
936
 
790
- var Q_VALUE_RE = /(?:^|;|\s)q\s*=\s*([0-9]*\.?[0-9]+)/i;
791
-
792
937
  /**
793
938
  * @primitive b.requestHelpers.parseQualityList
794
939
  * @signature b.requestHelpers.parseQualityList(headerValue, opts?)
@@ -831,24 +976,30 @@ function parseQualityList(headerValue, opts) {
831
976
  if (typeof headerValue !== "string" || headerValue.length === 0) return [];
832
977
  opts = opts || {};
833
978
  var caseSensitive = opts.caseSensitive === true;
834
- var parts = headerValue.split(",");
979
+ // Quote-aware split: a parameter value may be a quoted-string containing ','
980
+ // or ';' or 'q=' (RFC 7231 §5.3.1 / RFC 9110), which must not split an element
981
+ // or be mis-read as the q-value. splitUnquoted respects double-quoted runs.
982
+ var parts = structuredFields.splitUnquoted(headerValue, ",");
835
983
  var out = [];
836
984
  for (var i = 0; i < parts.length; i++) {
837
985
  var p = parts[i].trim();
838
986
  if (p.length === 0) continue;
839
- var semi = p.indexOf(";");
840
- var value, q;
841
- if (semi === -1) {
842
- value = caseSensitive ? p : p.toLowerCase();
843
- q = 1;
844
- } else {
845
- var head = p.slice(0, semi).trim();
846
- value = caseSensitive ? head : head.toLowerCase();
847
- var rest = p.slice(semi + 1).trim();
848
- var qm = rest.match(Q_VALUE_RE);
987
+ var segs = structuredFields.splitUnquoted(p, ";");
988
+ var head = segs[0].trim();
989
+ var value = caseSensitive ? head : head.toLowerCase();
990
+ var q = 1;
991
+ // The q parameter is the named token `q` separating media-type params from
992
+ // accept-ext; the FIRST `q` parameter is the quality. Only a parameter
993
+ // literally named `q` counts — never a `q=`-shaped substring of another
994
+ // parameter's name or quoted value.
995
+ for (var s = 1; s < segs.length; s++) {
996
+ var kv = structuredFields.parseKeyValuePiece(segs[s], "=", true);
997
+ if (kv.key !== "q") continue;
998
+ var qm = String(kv.value).trim().match(/^([0-9]*\.?[0-9]+)/);
849
999
  q = qm ? parseFloat(qm[1]) : 1;
850
1000
  if (isNaN(q) || q < 0) q = 0;
851
1001
  if (q > 1) q = 1;
1002
+ break;
852
1003
  }
853
1004
  out.push({ value: value, q: q });
854
1005
  }
@@ -1067,6 +1218,7 @@ module.exports = {
1067
1218
  // proxy-trust primitives (default refuses forwarded headers)
1068
1219
  clientIp: clientIp,
1069
1220
  trustedClientIp: trustedClientIp,
1221
+ ipPrefix: ipPrefix,
1070
1222
  requestProtocol: requestProtocol,
1071
1223
  trustedProtocol: trustedProtocol,
1072
1224
  appendVary: appendVary,
package/lib/safe-json.js CHANGED
@@ -969,9 +969,35 @@ function _capInt(value, defaultValue, ceiling) {
969
969
  * });
970
970
  */
971
971
 
972
+ /**
973
+ * @primitive b.safeJson.isJsonObject
974
+ * @signature b.safeJson.isJsonObject(value)
975
+ * @since 0.15.14
976
+ * @status stable
977
+ * @related b.safeJson.parse
978
+ *
979
+ * True iff <code>value</code> is a plain JSON object — not <code>null</code>,
980
+ * not an array, not a scalar. <code>safeJson.parse</code> accepts the literal
981
+ * <code>null</code> and scalars / arrays (all valid JSON documents), so a
982
+ * parsed JWS header, claims set, or document must be re-checked before its
983
+ * fields are dereferenced. This is that check, shared so the
984
+ * <code>!x || typeof x !== "object" || Array.isArray(x)</code> idiom isn't
985
+ * re-rolled (and silently varied) at every call site.
986
+ *
987
+ * @example
988
+ * var b = require("blamejs");
989
+ * b.safeJson.isJsonObject(b.safeJson.parse('{"a":1}')); // → true
990
+ * b.safeJson.isJsonObject(b.safeJson.parse("null")); // → false
991
+ * b.safeJson.isJsonObject(b.safeJson.parse("[1,2]")); // → false
992
+ */
993
+ function isJsonObject(value) {
994
+ return value !== null && typeof value === "object" && !Array.isArray(value);
995
+ }
996
+
972
997
  module.exports = {
973
998
  parse: parse,
974
999
  parseOrDefault: parseOrDefault,
1000
+ isJsonObject: isJsonObject,
975
1001
  stringify: stringify,
976
1002
  stringifyForScript: stringifyForScript,
977
1003
  canonical: canonical,
@@ -52,6 +52,14 @@
52
52
  * primitive falls back to b.session.touch metadata when the operator
53
53
  * passes session=b.session AND opts in via storeInSession=true.
54
54
  *
55
+ * No store at all: create() with neither bindingStore nor storeInSession
56
+ * still returns an instance — its stateless fingerprint(req) works (it
57
+ * touches no store), while bind/verify/unbind throw a clear "no store
58
+ * configured" error. Operators who only need the soft, store-free digest
59
+ * (sealed inside a self-validating cookie / JWT that compares it itself)
60
+ * can also skip create() entirely and call the static
61
+ * b.sessionDeviceBinding.fingerprint(req, opts).
62
+ *
55
63
  * Audit emissions:
56
64
  *
57
65
  * session.device.bound every successful bind()
@@ -61,7 +69,9 @@
61
69
  *
62
70
  * Validation policy:
63
71
  * - create() opts → throw at config time
64
- * - bind / verify → throw on bad token / req shape (operator typo)
72
+ * - bind / verify / unbind → throw on bad token / req shape (operator
73
+ * typo), and throw "no store configured" when called
74
+ * on a store-free instance
65
75
  * - storage errors → fail-CLOSED on verify (drift indistinguishable
66
76
  * from a wiped store, refuse rather than allow)
67
77
  * fail-OPEN on bind (don't lose a fresh session
@@ -135,28 +145,22 @@ function _normalizeAcceptEncoding(value) {
135
145
  .join(",");
136
146
  }
137
147
 
148
+ // Mask the client IP to its fingerprint bucket. Routes through the shared
149
+ // canonical masker (requestHelpers.ipPrefix) so a `::`-shorthand address and
150
+ // its fully-expanded equivalent (2001:db8::1 vs 2001:db8:0:0:0:0:0:1), or a
151
+ // leading-zero-folded group, collapse to ONE bucket — the hand-rolled textual
152
+ // ':'-group slice this replaced hashed them differently and logged a roaming
153
+ // user out on a false drift. `bits === 0` is the documented "skip the IP check
154
+ // entirely" escape hatch (mobile clients that switch networks), so it returns
155
+ // "" before any masking. `bits` is the family-resolved configured width (cfg
156
+ // .v4Bits for a v4 client, cfg.v6Bits for v6 — default /24 + /48); pass it as
157
+ // BOTH v4Bits and v6Bits so the canonical masker applies the configured width
158
+ // to whichever family it detects, instead of ipPrefix's bare /24 + /64 default
159
+ // (which would drop the configured width AND silently tighten v6 from /48 to /64).
138
160
  function _ipPrefix(ip, bits) {
139
161
  if (typeof ip !== "string" || ip.length === 0) return "";
140
162
  if (bits === 0) return "";
141
- // IPv6
142
- if (ip.indexOf(":") !== -1) {
143
- var v6Bits = bits;
144
- var groups = ip.split(":");
145
- // Naive expansion — keep the first ceil(v6Bits/16) groups intact
146
- // and zero the rest. Sufficient for fingerprint stability; not a
147
- // canonical IPv6 representation.
148
- var keepGroups = Math.ceil(v6Bits / 16); // IPv6 group width in bits
149
- var kept = groups.slice(0, keepGroups).join(":");
150
- return "v6:" + kept + "/" + v6Bits;
151
- }
152
- // IPv4
153
- var parts = ip.split(".");
154
- if (parts.length !== 4) return "v4:" + ip + "/" + bits;
155
- var v4Bits = bits;
156
- var keepOctets = Math.floor(v4Bits / 8); // IPv4 octet width in bits
157
- var maskedOctets = parts.slice(0, keepOctets);
158
- while (maskedOctets.length < 4) maskedOctets.push("0");
159
- return "v4:" + maskedOctets.join(".") + "/" + v4Bits;
163
+ return requestHelpers.ipPrefix(ip, { v4Bits: bits, v6Bits: bits });
160
164
  }
161
165
 
162
166
  // Resolve operator-supplied fingerprintExtras(req) to a stable string. A
@@ -195,6 +199,7 @@ function _computeDeviceFingerprint(req, cfg) {
195
199
  var ae = _normalizeAcceptEncoding(headers["accept-encoding"]);
196
200
  var ip = "";
197
201
  try { ip = requestHelpers.clientIp(req); } catch (_e) { ip = ""; }
202
+ if (typeof ip !== "string") ip = "";
198
203
  var family = ip.indexOf(":") !== -1 ? "v6" : "v4";
199
204
  var ipPart = _ipPrefix(ip, family === "v6" ? cfg.v6Bits : cfg.v4Bits);
200
205
  var keyPart = "";
@@ -291,10 +296,15 @@ function create(opts) {
291
296
  }
292
297
 
293
298
  var storeInSession = !!opts.storeInSession;
294
- if (!storeInSession && !opts.bindingStore) {
295
- throw new SessionDeviceBindingError("session-device-binding/bad-opt",
296
- "either bindingStore (b.cache-shaped) or storeInSession=true must be set");
297
- }
299
+ // A no-store instance is still useful: the stateless fingerprint() reads no
300
+ // store and is the soft device-binding building block for self-validating
301
+ // tokens (a sealed cookie / JWT carrying the fingerprint inside). Rather than
302
+ // refuse to construct (issue #330 — fingerprint() unreachable without a
303
+ // store), build the instance and let the persisted bind()/verify() lifecycle
304
+ // throw a clear "no store configured" when actually called. Operators wanting
305
+ // ONLY the stateless digest can also use the static
306
+ // b.sessionDeviceBinding.fingerprint(req, opts) with no create() at all.
307
+ var hasStore = !!(storeInSession || opts.bindingStore);
298
308
  if (opts.bindingStore) _requireBindingStore(opts.bindingStore);
299
309
  if (storeInSession && (!opts.session || typeof opts.session.touch !== "function")) {
300
310
  throw new SessionDeviceBindingError("session-device-binding/bad-opt",
@@ -351,8 +361,18 @@ function create(opts) {
351
361
  return { ok: true, fingerprint: r.fingerprint, components: r.components };
352
362
  }
353
363
 
364
+ function _requireStore(stage) {
365
+ if (!hasStore) {
366
+ throw new SessionDeviceBindingError("session-device-binding/no-store",
367
+ stage + ": no store configured — pass bindingStore (b.cache-shaped) or "
368
+ + "storeInSession=true to create(), or use the stateless "
369
+ + "b.sessionDeviceBinding.fingerprint(req, opts) for soft binding");
370
+ }
371
+ }
372
+
354
373
  async function bind(token, req) {
355
374
  _requireToken(token);
375
+ _requireStore("bind");
356
376
  var fp = _computeFingerprint(req);
357
377
  if (!fp.ok) {
358
378
  _emitObs("session.device.refused", { reason: fp.reason });
@@ -408,6 +428,7 @@ function create(opts) {
408
428
 
409
429
  async function verify(token, req) {
410
430
  _requireToken(token);
431
+ _requireStore("verify");
411
432
  var fpResult = _computeFingerprint(req);
412
433
  if (!fpResult.ok) {
413
434
  _emitObs("session.device.refused", { reason: fpResult.reason });
@@ -444,6 +465,7 @@ function create(opts) {
444
465
 
445
466
  async function unbind(token) {
446
467
  _requireToken(token);
468
+ _requireStore("unbind");
447
469
  if (bindingStore) {
448
470
  try { await bindingStore.del(token); } catch (_e) { /* drop-silent */ }
449
471
  }