@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/network-proxy.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
};
|
|
@@ -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
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* operator (CIIO),
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* individuals
|
|
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
package/lib/request-helpers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
840
|
-
var
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
var
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
}
|