@blamejs/core 0.8.0 → 0.8.5
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 +26 -0
- package/lib/audit-sign.js +1 -1
- package/lib/audit.js +62 -2
- package/lib/auth/jwt.js +13 -0
- package/lib/auth/lockout.js +16 -3
- package/lib/auth/oauth.js +15 -1
- package/lib/auth/password.js +22 -2
- package/lib/auth/sd-jwt-vc-issuer.js +2 -2
- package/lib/auth/sd-jwt-vc.js +7 -2
- package/lib/break-glass.js +53 -14
- package/lib/cache-redis.js +1 -1
- package/lib/cache.js +6 -1
- package/lib/cli.js +3 -3
- package/lib/cluster.js +24 -1
- package/lib/compliance-ai-act-logging.js +7 -3
- package/lib/compliance.js +10 -2
- package/lib/config-drift.js +2 -2
- package/lib/crypto-field.js +21 -1
- package/lib/crypto.js +82 -1
- package/lib/db.js +35 -4
- package/lib/dev.js +30 -3
- package/lib/dual-control.js +19 -1
- package/lib/external-db.js +10 -0
- package/lib/file-upload.js +30 -3
- package/lib/flag.js +1 -1
- package/lib/guard-all.js +33 -16
- package/lib/guard-csv.js +16 -2
- package/lib/guard-html.js +35 -0
- package/lib/guard-svg.js +20 -0
- package/lib/http-client.js +57 -11
- package/lib/inbox.js +34 -10
- package/lib/log-stream-syslog.js +8 -0
- package/lib/log-stream.js +1 -1
- package/lib/mail.js +40 -0
- package/lib/middleware/attach-user.js +25 -2
- package/lib/middleware/bearer-auth.js +71 -6
- package/lib/middleware/body-parser.js +13 -0
- package/lib/middleware/cors.js +10 -0
- package/lib/middleware/csrf-protect.js +34 -3
- package/lib/middleware/dpop.js +3 -3
- package/lib/middleware/host-allowlist.js +1 -1
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/require-aal.js +2 -2
- package/lib/middleware/require-mtls.js +179 -0
- package/lib/middleware/trace-propagate.js +1 -1
- package/lib/mtls-ca.js +23 -29
- package/lib/mtls-engine-default.js +21 -1
- package/lib/network-tls.js +21 -6
- package/lib/object-store/sigv4-bucket-ops.js +41 -0
- package/lib/observability-otlp-exporter.js +35 -2
- package/lib/outbox.js +3 -3
- package/lib/permissions.js +10 -1
- package/lib/pqc-agent.js +22 -1
- package/lib/pubsub.js +8 -4
- package/lib/redact.js +26 -1
- package/lib/retention.js +26 -0
- package/lib/router.js +1 -0
- package/lib/scheduler.js +57 -1
- package/lib/session.js +3 -3
- package/lib/ssrf-guard.js +19 -4
- package/lib/static.js +12 -0
- package/lib/totp.js +16 -0
- package/lib/ws-client.js +158 -9
- package/package.json +3 -2
- package/sbom.cyclonedx.json +6 -6
package/lib/retention.js
CHANGED
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
var C = require("./constants");
|
|
59
59
|
var lazyRequire = require("./lazy-require");
|
|
60
60
|
var validateOpts = require("./validate-opts");
|
|
61
|
+
var safeSql = require("./safe-sql");
|
|
61
62
|
var { defineClass } = require("./framework-error");
|
|
62
63
|
|
|
63
64
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
@@ -66,6 +67,21 @@ var cryptoField = require("./crypto-field");
|
|
|
66
67
|
var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
|
|
67
68
|
var _err = RetentionError.factory;
|
|
68
69
|
|
|
70
|
+
// Identifier-level SQLi defense: every operator-supplied table name,
|
|
71
|
+
// column name, and cascade FK must pass safeSql.validateIdentifier
|
|
72
|
+
// before reaching SQL string concatenation. Without this gate a
|
|
73
|
+
// rule registered with `table: 'users"; DROP TABLE audit_log;--'`
|
|
74
|
+
// would break out of the quoted-identifier wrap and execute the
|
|
75
|
+
// embedded statement.
|
|
76
|
+
function _validateRuleIdentifier(value, label) {
|
|
77
|
+
try {
|
|
78
|
+
safeSql.validateIdentifier(value, { allowReserved: true });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw _err("BAD_RULE",
|
|
81
|
+
label + " is not a safe SQL identifier: " + (e && e.message || String(e)));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
function _validateRule(rule) {
|
|
70
86
|
if (!rule || typeof rule !== "object") {
|
|
71
87
|
throw _err("BAD_RULE", "rule must be an object");
|
|
@@ -76,9 +92,11 @@ function _validateRule(rule) {
|
|
|
76
92
|
if (typeof rule.table !== "string" || rule.table.length === 0) {
|
|
77
93
|
throw _err("BAD_RULE", "rule.table (string) is required");
|
|
78
94
|
}
|
|
95
|
+
_validateRuleIdentifier(rule.table, "rule.table");
|
|
79
96
|
if (typeof rule.ageField !== "string" || rule.ageField.length === 0) {
|
|
80
97
|
throw _err("BAD_RULE", "rule.ageField (string) is required");
|
|
81
98
|
}
|
|
99
|
+
_validateRuleIdentifier(rule.ageField, "rule.ageField");
|
|
82
100
|
if (typeof rule.ttlMs !== "number" || !isFinite(rule.ttlMs) || rule.ttlMs <= 0) {
|
|
83
101
|
throw _err("BAD_RULE", "rule.ttlMs must be a positive finite number");
|
|
84
102
|
}
|
|
@@ -99,10 +117,16 @@ function _validateRule(rule) {
|
|
|
99
117
|
(typeof rule.softDeleteField !== "string" || rule.softDeleteField.length === 0)) {
|
|
100
118
|
throw _err("BAD_RULE", "rule.softDeleteField must be a non-empty string");
|
|
101
119
|
}
|
|
120
|
+
if (rule.softDeleteField !== undefined) {
|
|
121
|
+
_validateRuleIdentifier(rule.softDeleteField, "rule.softDeleteField");
|
|
122
|
+
}
|
|
102
123
|
if (rule.legalHoldField !== undefined &&
|
|
103
124
|
(typeof rule.legalHoldField !== "string" || rule.legalHoldField.length === 0)) {
|
|
104
125
|
throw _err("BAD_RULE", "rule.legalHoldField must be a non-empty string");
|
|
105
126
|
}
|
|
127
|
+
if (rule.legalHoldField !== undefined) {
|
|
128
|
+
_validateRuleIdentifier(rule.legalHoldField, "rule.legalHoldField");
|
|
129
|
+
}
|
|
106
130
|
if (rule.cascade !== undefined) {
|
|
107
131
|
if (!Array.isArray(rule.cascade) || rule.cascade.length === 0) {
|
|
108
132
|
throw _err("BAD_RULE", "rule.cascade must be a non-empty array of { table, foreignKey } entries");
|
|
@@ -113,6 +137,8 @@ function _validateRule(rule) {
|
|
|
113
137
|
typeof c.foreignKey !== "string" || c.foreignKey.length === 0) {
|
|
114
138
|
throw _err("BAD_RULE", "rule.cascade[" + ci + "] must be { table: string, foreignKey: string }");
|
|
115
139
|
}
|
|
140
|
+
_validateRuleIdentifier(c.table, "rule.cascade[" + ci + "].table");
|
|
141
|
+
_validateRuleIdentifier(c.foreignKey, "rule.cascade[" + ci + "].foreignKey");
|
|
116
142
|
}
|
|
117
143
|
}
|
|
118
144
|
if (rule.stages !== undefined) {
|
package/lib/router.js
CHANGED
|
@@ -651,6 +651,7 @@ class Router {
|
|
|
651
651
|
maxHeaderListPairs: 100, // allow:raw-byte-literal — CVE-2024-27983 CONTINUATION-flood cap
|
|
652
652
|
maxSettings: 32, // allow:raw-byte-literal — SETTINGS-frame entry ceiling
|
|
653
653
|
peerMaxConcurrentStreams: 100, // allow:raw-byte-literal — peer-side stream cap
|
|
654
|
+
maxOutstandingPings: 10, // allow:raw-byte-literal — CVE-2019-9512 ping-flood cap (pin to Node default rather than letting it drift)
|
|
654
655
|
unknownProtocolTimeout: C.TIME.seconds(10),
|
|
655
656
|
}, tlsOptions), requestHandler);
|
|
656
657
|
} else {
|
package/lib/scheduler.js
CHANGED
|
@@ -682,8 +682,63 @@ function create(opts) {
|
|
|
682
682
|
started = false;
|
|
683
683
|
}
|
|
684
684
|
|
|
685
|
-
|
|
685
|
+
// Shorthand for the common interval-based registration shape:
|
|
686
|
+
// register("rotate-keys", C.TIME.minutes(5), runFn)
|
|
687
|
+
// is equivalent to schedule({ name, every: 300000, run: runFn }).
|
|
688
|
+
// Operators wanting cron expressions or job-queue dispatch keep
|
|
689
|
+
// using schedule() — register() is the every-N-ms direct-function
|
|
690
|
+
// path. Returns the scheduler instance for method chaining.
|
|
691
|
+
function register(name, intervalMs, fn) {
|
|
692
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
693
|
+
throw _err("INVALID_NAME", "scheduler.register: name must be a non-empty string", true);
|
|
694
|
+
}
|
|
695
|
+
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs < C.TIME.seconds(1)) {
|
|
696
|
+
throw _err("INVALID_SPEC",
|
|
697
|
+
"scheduler.register: intervalMs must be a finite number ≥ 1000", true);
|
|
698
|
+
}
|
|
699
|
+
if (typeof fn !== "function") {
|
|
700
|
+
throw _err("INVALID_SPEC", "scheduler.register: fn must be a function", true);
|
|
701
|
+
}
|
|
702
|
+
schedule({ name: name, every: intervalMs, run: fn });
|
|
703
|
+
return facade;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Operator-facing health surface — every task with its lifecycle
|
|
707
|
+
// counters plus an aggregate. Probes / dashboards / readiness gates
|
|
708
|
+
// get a single object they can serialize. This is `list()` plus
|
|
709
|
+
// started state and aggregate stats.
|
|
710
|
+
function getStatus() {
|
|
711
|
+
var taskList = list();
|
|
712
|
+
var aggregate = {
|
|
713
|
+
total: taskList.length,
|
|
714
|
+
running: 0,
|
|
715
|
+
withErrors: 0,
|
|
716
|
+
totalFires: 0,
|
|
717
|
+
totalMisses: 0,
|
|
718
|
+
nonLeaderSkips: 0,
|
|
719
|
+
tickClaimLost: 0,
|
|
720
|
+
};
|
|
721
|
+
for (var i = 0; i < taskList.length; i++) {
|
|
722
|
+
var t = taskList[i];
|
|
723
|
+
if (t.running) aggregate.running += 1;
|
|
724
|
+
if (t.lastError) aggregate.withErrors += 1;
|
|
725
|
+
aggregate.totalFires += t.fires || 0;
|
|
726
|
+
aggregate.totalMisses += t.misses || 0;
|
|
727
|
+
aggregate.nonLeaderSkips += t.nonLeaderSkips || 0;
|
|
728
|
+
aggregate.tickClaimLost += t.tickClaimLost || 0;
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
started: started,
|
|
732
|
+
isLeader: _isLeaderHere(),
|
|
733
|
+
tasks: taskList,
|
|
734
|
+
aggregate: aggregate,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
var facade = {
|
|
686
739
|
schedule: schedule,
|
|
740
|
+
register: register,
|
|
741
|
+
getStatus: getStatus,
|
|
687
742
|
start: start,
|
|
688
743
|
stop: stop,
|
|
689
744
|
list: list,
|
|
@@ -695,6 +750,7 @@ function create(opts) {
|
|
|
695
750
|
},
|
|
696
751
|
_resetForTest: _resetForTest,
|
|
697
752
|
};
|
|
753
|
+
return facade;
|
|
698
754
|
}
|
|
699
755
|
|
|
700
756
|
module.exports = {
|
package/lib/session.js
CHANGED
|
@@ -238,7 +238,7 @@ async function verify(token, verifyOpts) {
|
|
|
238
238
|
if ((nowMs - lastActivity) > idleMs) {
|
|
239
239
|
try {
|
|
240
240
|
audit.safeEmit({
|
|
241
|
-
action: "auth.session.expired_idle", outcome: "
|
|
241
|
+
action: "auth.session.expired_idle", outcome: "success",
|
|
242
242
|
metadata: { idleMs: nowMs - lastActivity, threshold: idleMs },
|
|
243
243
|
});
|
|
244
244
|
} catch (_ignored) { /* audit best-effort */ }
|
|
@@ -253,7 +253,7 @@ async function verify(token, verifyOpts) {
|
|
|
253
253
|
if ((nowMs - createdAt) > absMs) {
|
|
254
254
|
try {
|
|
255
255
|
audit.safeEmit({
|
|
256
|
-
action: "auth.session.expired_absolute", outcome: "
|
|
256
|
+
action: "auth.session.expired_absolute", outcome: "success",
|
|
257
257
|
metadata: { ageMs: nowMs - createdAt, threshold: absMs },
|
|
258
258
|
});
|
|
259
259
|
} catch (_ignored) { /* audit best-effort */ }
|
|
@@ -334,7 +334,7 @@ async function verify(token, verifyOpts) {
|
|
|
334
334
|
try {
|
|
335
335
|
audit.safeEmit({
|
|
336
336
|
action: "auth.session.fingerprint_drift",
|
|
337
|
-
outcome: "
|
|
337
|
+
outcome: "success",
|
|
338
338
|
metadata: { hasUserId: !!unsealed.userId,
|
|
339
339
|
anomalyScore: fingerprintAnomalyScore },
|
|
340
340
|
});
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -140,10 +140,25 @@ var CLOUD_METADATA_IPS = [
|
|
|
140
140
|
|
|
141
141
|
function _ipv4ToInt(ip) {
|
|
142
142
|
var parts = ip.split(".");
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
if (parts.length !== 4) return NaN;
|
|
144
|
+
var nums = [0, 0, 0, 0];
|
|
145
|
+
for (var i = 0; i < 4; i += 1) {
|
|
146
|
+
var s = parts[i];
|
|
147
|
+
// Strict octet validation: each segment must be 1-3 ASCII digits
|
|
148
|
+
// representing 0-255. The previous `parts[i] | 0` coerced
|
|
149
|
+
// anything non-numeric to 0 silently — exposed via cidrContains
|
|
150
|
+
// (network-allowlist) where a typo'd CIDR could collapse to
|
|
151
|
+
// 0.0.0.0/16 with no signal.
|
|
152
|
+
if (typeof s !== "string" || s.length === 0 || s.length > 3) return NaN;
|
|
153
|
+
if (!/^\d{1,3}$/.test(s)) return NaN;
|
|
154
|
+
var n = parseInt(s, 10);
|
|
155
|
+
if (n < 0 || n > 255) return NaN;
|
|
156
|
+
nums[i] = n;
|
|
157
|
+
}
|
|
158
|
+
return ((nums[0] << 24) >>> 0) +
|
|
159
|
+
(nums[1] << 16) +
|
|
160
|
+
(nums[2] << 8) +
|
|
161
|
+
nums[3];
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
function _ipv6ToBytes(ip) {
|
package/lib/static.js
CHANGED
|
@@ -100,6 +100,12 @@ var DEFAULT_CONTENT_TYPES = {
|
|
|
100
100
|
var DEFAULTS = Object.freeze({
|
|
101
101
|
defaultMaxAge: DEFAULT_MAX_AGE_SEC,
|
|
102
102
|
acceptRanges: true,
|
|
103
|
+
// Per-range byte cap — slowloris-range defense. A single Range
|
|
104
|
+
// request that asks for 1 GiB pins a worker on a long read; many
|
|
105
|
+
// concurrent requests asking for the same exhaust the process pool.
|
|
106
|
+
// The cap rejects ranges larger than maxRangeBytes with 416. Set to
|
|
107
|
+
// Infinity to opt out (audited reason).
|
|
108
|
+
maxRangeBytes: C.BYTES.mib(64),
|
|
103
109
|
// Empty array = no MIME allowlist gate.
|
|
104
110
|
allowedFileTypes: Object.freeze([]),
|
|
105
111
|
// Bandwidth + concurrency caps default to 0 = "no cap". Operators opt
|
|
@@ -844,6 +850,12 @@ function create(opts) {
|
|
|
844
850
|
return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
|
|
845
851
|
"Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
|
|
846
852
|
}
|
|
853
|
+
if (range && cfg.maxRangeBytes !== Infinity && range.length > cfg.maxRangeBytes) {
|
|
854
|
+
stats.failures += 1;
|
|
855
|
+
_emitObs("staticServe.range_too_large", 1, { route: urlPath });
|
|
856
|
+
return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_too_large",
|
|
857
|
+
"Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
|
|
858
|
+
}
|
|
847
859
|
if (range) {
|
|
848
860
|
stats.rangeRequests += 1;
|
|
849
861
|
_emitObs("staticServe.range_requests", 1, { route: urlPath });
|
package/lib/totp.js
CHANGED
|
@@ -134,6 +134,22 @@ function _resolveOpts(opts) {
|
|
|
134
134
|
throw new AuthError("auth-totp/bad-alg",
|
|
135
135
|
"algorithm must be one of " + SUPPORTED_ALGORITHMS.join(", ") + " (got: " + alg + ")");
|
|
136
136
|
}
|
|
137
|
+
// SHA-256 is supported for back-compat with authenticator apps that
|
|
138
|
+
// don't yet honor SHA-512. Emit an audit signal each time it's
|
|
139
|
+
// selected so operator compliance dashboards see which accounts run
|
|
140
|
+
// on the weaker hash and can plan the migration.
|
|
141
|
+
if (alg === "sha256") {
|
|
142
|
+
setImmediate(function () {
|
|
143
|
+
try {
|
|
144
|
+
var auditMod = require("./audit"); // allow:inline-require — circular-load defense
|
|
145
|
+
auditMod.safeEmit({
|
|
146
|
+
action: "auth.totp.algorithm_downgraded",
|
|
147
|
+
outcome: "success",
|
|
148
|
+
metadata: { algorithm: alg, frameworkDefault: DEFAULT_ALGORITHM },
|
|
149
|
+
});
|
|
150
|
+
} catch (_e) { /* drop-silent */ }
|
|
151
|
+
});
|
|
152
|
+
}
|
|
137
153
|
var digits = opts.digits != null ? opts.digits : DEFAULT_DIGITS;
|
|
138
154
|
if (typeof digits !== "number" || digits < 6 || digits > 10) {
|
|
139
155
|
throw new AuthError("auth-totp/bad-digits", "digits must be 6–10 (got: " + digits + ")");
|
package/lib/ws-client.js
CHANGED
|
@@ -58,6 +58,8 @@ var fwCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
|
58
58
|
var websocket = lazyRequire(function () { return require("./websocket"); });
|
|
59
59
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
60
60
|
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
61
|
+
var safeJson = lazyRequire(function () { return require("./safe-json"); });
|
|
62
|
+
var ssrfGuard = require("./ssrf-guard");
|
|
61
63
|
var C = require("./constants");
|
|
62
64
|
var { defineClass } = require("./framework-error");
|
|
63
65
|
|
|
@@ -164,9 +166,27 @@ function connect(target, opts) {
|
|
|
164
166
|
"maxMessageBytes", "maxFrameBytes",
|
|
165
167
|
"handshakeTimeoutMs", "reconnect",
|
|
166
168
|
"permessageDeflate", "audit", "origin",
|
|
167
|
-
"handshakeGuid",
|
|
169
|
+
"handshakeGuid", "allowInternal",
|
|
170
|
+
"parse", "parser",
|
|
171
|
+
"urlFor", "tlsOptsFor",
|
|
168
172
|
], "wsClient.connect");
|
|
169
173
|
|
|
174
|
+
// Per-dial state-injection callbacks. Both fire on every connect
|
|
175
|
+
// attempt (initial + each reconnect) so operators can rotate
|
|
176
|
+
// bearer tokens, DNS targets, or TLS material between hops without
|
|
177
|
+
// tearing the client down.
|
|
178
|
+
// urlFor(attempt) -> string — overrides target URL
|
|
179
|
+
// tlsOptsFor(attempt) -> object — overrides TLS opts
|
|
180
|
+
// attempt is a 0-based hop index; 0 is the initial dial.
|
|
181
|
+
if (opts.urlFor != null && typeof opts.urlFor !== "function") {
|
|
182
|
+
throw new WsClientError("ws-client/bad-url-for",
|
|
183
|
+
"wsClient.connect: urlFor must be a function");
|
|
184
|
+
}
|
|
185
|
+
if (opts.tlsOptsFor != null && typeof opts.tlsOptsFor !== "function") {
|
|
186
|
+
throw new WsClientError("ws-client/bad-tls-opts-for",
|
|
187
|
+
"wsClient.connect: tlsOptsFor must be a function");
|
|
188
|
+
}
|
|
189
|
+
|
|
170
190
|
// Operators with a non-RFC-6455 GUID (private protocols on top of
|
|
171
191
|
// the WebSocket framing layer, framework-specific handshake variants)
|
|
172
192
|
// pass a custom handshakeGuid. The server-side b.websocket already
|
|
@@ -219,8 +239,29 @@ function connect(target, opts) {
|
|
|
219
239
|
permessageDeflate: permessageDeflate,
|
|
220
240
|
auditOn: auditOn,
|
|
221
241
|
handshakeGuid: handshakeGuid,
|
|
242
|
+
allowInternal: opts.allowInternal,
|
|
243
|
+
parse: opts.parse || null,
|
|
244
|
+
parser: typeof opts.parser === "function" ? opts.parser : null,
|
|
245
|
+
urlFor: typeof opts.urlFor === "function" ? opts.urlFor : null,
|
|
246
|
+
tlsOptsFor: typeof opts.tlsOptsFor === "function" ? opts.tlsOptsFor : null,
|
|
247
|
+
});
|
|
248
|
+
// SSRF gate — refuse private / loopback / link-local / cloud-metadata /
|
|
249
|
+
// reserved IP destinations by default. Symmetric to b.httpClient. The
|
|
250
|
+
// returned `ips` are pinned through tls.connect / net.connect so the
|
|
251
|
+
// actual TCP connect targets the validated address (closes the DNS-
|
|
252
|
+
// rebinding TOCTOU window). Cloud-metadata IPs are unconditional
|
|
253
|
+
// hard-deny — `allowInternal: true` does not bypass them.
|
|
254
|
+
var hostnameForUrl = parsed.protocol === "wss:" ? "https:" : "http:";
|
|
255
|
+
var probeUrl = new url.URL(hostnameForUrl + "//" + parsed.host + parsed.pathname + parsed.search);
|
|
256
|
+
ssrfGuard.checkUrl(probeUrl, {
|
|
257
|
+
allowInternal: opts.allowInternal,
|
|
258
|
+
errorClass: WsClientError,
|
|
259
|
+
}).then(function (result) {
|
|
260
|
+
client._ssrfPinnedIps = result && result.ips;
|
|
261
|
+
client._dial();
|
|
262
|
+
}).catch(function (e) {
|
|
263
|
+
setImmediate(function () { client._handleSocketError(e); });
|
|
222
264
|
});
|
|
223
|
-
client._dial();
|
|
224
265
|
return client;
|
|
225
266
|
}
|
|
226
267
|
|
|
@@ -278,13 +319,74 @@ class WsClient extends EventEmitter {
|
|
|
278
319
|
var opts = this._opts;
|
|
279
320
|
this._readyState = "connecting";
|
|
280
321
|
|
|
281
|
-
|
|
322
|
+
// Per-dial overrides — urlFor swaps the target URL, tlsOptsFor
|
|
323
|
+
// overrides TLS material. Both fire every dial including reconnects
|
|
324
|
+
// so callers can rotate state. urlFor's result is re-validated
|
|
325
|
+
// through ssrfGuard so a hostile upstream can't direct the client
|
|
326
|
+
// at a private address mid-reconnect.
|
|
327
|
+
var attempt = this._reconnectAttempt || 0;
|
|
328
|
+
var dialTarget = opts.target;
|
|
329
|
+
var dialParsed = opts.parsedUrl;
|
|
330
|
+
if (typeof opts.urlFor === "function") {
|
|
331
|
+
try {
|
|
332
|
+
var nextTarget = opts.urlFor(attempt);
|
|
333
|
+
if (typeof nextTarget === "string" && nextTarget.length > 0 && nextTarget !== dialTarget) {
|
|
334
|
+
dialParsed = _parseUrl(nextTarget);
|
|
335
|
+
dialTarget = nextTarget;
|
|
336
|
+
var probeProto = dialParsed.protocol === "wss:" ? "https:" : "http:";
|
|
337
|
+
var probeUrl = new url.URL(probeProto + "//" + dialParsed.host + dialParsed.pathname + dialParsed.search);
|
|
338
|
+
var probe = ssrfGuard.checkUrl(probeUrl, {
|
|
339
|
+
allowInternal: opts.allowInternal,
|
|
340
|
+
errorClass: WsClientError,
|
|
341
|
+
});
|
|
342
|
+
this._ssrfPinnedIps = probe && probe.ips ? probe.ips : null;
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return self._handleSocketError(e);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
var dialTlsOpts = opts.tlsOpts;
|
|
350
|
+
if (typeof opts.tlsOptsFor === "function") {
|
|
351
|
+
try {
|
|
352
|
+
var override = opts.tlsOptsFor(attempt);
|
|
353
|
+
if (override && typeof override === "object") {
|
|
354
|
+
dialTlsOpts = Object.assign({}, opts.tlsOpts || {}, override);
|
|
355
|
+
}
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return self._handleSocketError(e);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
var parsed = dialParsed;
|
|
282
362
|
var port = parsed.port ? parseInt(parsed.port, 10) :
|
|
283
363
|
(parsed.protocol === "wss:" ? 443 : 80); // allow:raw-byte-literal — TLS / HTTP default port
|
|
284
364
|
var host = parsed.hostname;
|
|
285
365
|
|
|
286
366
|
function _onError(err) { self._handleSocketError(err); }
|
|
287
367
|
|
|
368
|
+
// Pin the connect to the SSRF-validated IPs returned by
|
|
369
|
+
// ssrfGuard.checkUrl — closes the DNS-rebinding TOCTOU window where
|
|
370
|
+
// the gate resolves a public IP and the kernel re-resolves to a
|
|
371
|
+
// private one between the check and the connect.
|
|
372
|
+
var pinnedIps = self._ssrfPinnedIps || null;
|
|
373
|
+
var lookup = null;
|
|
374
|
+
if (pinnedIps && pinnedIps.length > 0) {
|
|
375
|
+
lookup = function (h, lookupOpts, cb) {
|
|
376
|
+
// Node's lookup callback signatures:
|
|
377
|
+
// (err, address, family) for legacy { all: false } (default)
|
|
378
|
+
// (err, addresses) for { all: true }
|
|
379
|
+
if (typeof lookupOpts === "function") { cb = lookupOpts; lookupOpts = {}; }
|
|
380
|
+
var first = pinnedIps[0];
|
|
381
|
+
if (lookupOpts && lookupOpts.all) {
|
|
382
|
+
return cb(null, pinnedIps.map(function (ip) {
|
|
383
|
+
return { address: ip.address, family: ip.family };
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
return cb(null, first.address, first.family);
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
288
390
|
var socket;
|
|
289
391
|
if (parsed.protocol === "wss:") {
|
|
290
392
|
var tls = require("tls"); // allow:inline-require — node:tls only on TLS path
|
|
@@ -294,7 +396,8 @@ class WsClient extends EventEmitter {
|
|
|
294
396
|
servername: host,
|
|
295
397
|
rejectUnauthorized: true,
|
|
296
398
|
minVersion: "TLSv1.3",
|
|
297
|
-
},
|
|
399
|
+
}, dialTlsOpts || {});
|
|
400
|
+
if (lookup) tlsOpts.lookup = lookup;
|
|
298
401
|
try {
|
|
299
402
|
var pqcShares = networkTls().pqc.getKeyShares();
|
|
300
403
|
if (Array.isArray(pqcShares) && pqcShares.length > 0 && !tlsOpts.curves) {
|
|
@@ -303,7 +406,9 @@ class WsClient extends EventEmitter {
|
|
|
303
406
|
} catch (_e) { /* drop-silent — tls module pre-init or non-Node */ }
|
|
304
407
|
socket = tls.connect(tlsOpts);
|
|
305
408
|
} else {
|
|
306
|
-
|
|
409
|
+
var netOpts = { host: host, port: port };
|
|
410
|
+
if (lookup) netOpts.lookup = lookup;
|
|
411
|
+
socket = net.connect(netOpts);
|
|
307
412
|
}
|
|
308
413
|
this._socket = socket;
|
|
309
414
|
socket.on("error", _onError);
|
|
@@ -406,8 +511,17 @@ class WsClient extends EventEmitter {
|
|
|
406
511
|
}
|
|
407
512
|
var status = parseInt(match[1], 10);
|
|
408
513
|
if (status !== 101) { // allow:raw-byte-literal — HTTP 101
|
|
409
|
-
|
|
410
|
-
|
|
514
|
+
// Body bytes after the header section are the server's
|
|
515
|
+
// explanation. Surface them on the error so callers can branch
|
|
516
|
+
// on the status code and inspect the body without re-parsing
|
|
517
|
+
// the message string.
|
|
518
|
+
var bodyText = "";
|
|
519
|
+
try { bodyText = rest.toString("utf8"); } catch (_e) { /* drop-silent */ }
|
|
520
|
+
var statusErr = new WsClientError("ws-client/bad-status",
|
|
521
|
+
"handshake response status was " + status + " (expected 101 Switching Protocols)");
|
|
522
|
+
statusErr.status = status;
|
|
523
|
+
statusErr.body = bodyText;
|
|
524
|
+
this._handleSocketError(statusErr);
|
|
411
525
|
return;
|
|
412
526
|
}
|
|
413
527
|
|
|
@@ -624,7 +738,29 @@ class WsClient extends EventEmitter {
|
|
|
624
738
|
return;
|
|
625
739
|
}
|
|
626
740
|
}
|
|
627
|
-
|
|
741
|
+
// Auto-parse text frames as JSON when the operator opted in via
|
|
742
|
+
// `parse: "json"` or supplied `parser: fn`. JSON-only protocols
|
|
743
|
+
// (most modern WS APIs) get a typed message argument without a
|
|
744
|
+
// wrapper layer; parse failures surface as 'error' events
|
|
745
|
+
// rather than crashing the message handler.
|
|
746
|
+
var parsed = data;
|
|
747
|
+
var parsedOk = true;
|
|
748
|
+
if (!isBinary && this._opts.parse === "json") {
|
|
749
|
+
try { parsed = safeJson().parse(data, { maxBytes: this._opts.maxMessageBytes }); }
|
|
750
|
+
catch (e) {
|
|
751
|
+
parsedOk = false;
|
|
752
|
+
this.emit("error", new WsClientError("ws-client/json-parse",
|
|
753
|
+
"text frame is not valid JSON: " + ((e && e.message) || String(e))));
|
|
754
|
+
}
|
|
755
|
+
} else if (!isBinary && typeof this._opts.parser === "function") {
|
|
756
|
+
try { parsed = this._opts.parser(data); }
|
|
757
|
+
catch (e) {
|
|
758
|
+
parsedOk = false;
|
|
759
|
+
this.emit("error", new WsClientError("ws-client/parser-failed",
|
|
760
|
+
"operator parser threw: " + ((e && e.message) || String(e))));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (parsedOk) this.emit("message", parsed, isBinary);
|
|
628
764
|
}
|
|
629
765
|
}
|
|
630
766
|
|
|
@@ -741,12 +877,25 @@ class WsClient extends EventEmitter {
|
|
|
741
877
|
}
|
|
742
878
|
|
|
743
879
|
_handleSocketError(err) {
|
|
880
|
+
// Swallow post-close socket errors. After a consumer-initiated
|
|
881
|
+
// close() the framework waits for the server to send back its
|
|
882
|
+
// close frame; if the server tears down its end with an
|
|
883
|
+
// ECONNRESET / EPIPE / "premature close" instead of a graceful
|
|
884
|
+
// close frame, the underlying socket emits an error that bubbles
|
|
885
|
+
// up here. From the consumer's perspective the close already
|
|
886
|
+
// happened — surfacing a "socket error" event after `close` was
|
|
887
|
+
// called is noise that hides real bugs.
|
|
888
|
+
if (this._closed && err && (
|
|
889
|
+
err.code === "ECONNRESET" || err.code === "EPIPE" ||
|
|
890
|
+
err.code === "ECONNABORTED" || err.code === "ERR_STREAM_PREMATURE_CLOSE")) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
744
893
|
var permanent = _isPermanentError(err);
|
|
745
894
|
if (this._opts.auditOn) {
|
|
746
895
|
try {
|
|
747
896
|
audit().safeEmit({
|
|
748
897
|
action: "wsclient.error",
|
|
749
|
-
outcome: "
|
|
898
|
+
outcome: "failure",
|
|
750
899
|
actor: null,
|
|
751
900
|
metadata: {
|
|
752
901
|
host: this._opts.parsedUrl.host,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "The Node framework that owns its stack.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -70,7 +70,8 @@
|
|
|
70
70
|
],
|
|
71
71
|
"scripts": {
|
|
72
72
|
"test": "node test/smoke.js",
|
|
73
|
-
"prepack": "node scripts/check-pack-against-gitignore.js"
|
|
73
|
+
"prepack": "node scripts/check-pack-against-gitignore.js",
|
|
74
|
+
"check:vendor-currency": "node scripts/check-vendor-currency.js"
|
|
74
75
|
},
|
|
75
76
|
"dependencies": {},
|
|
76
77
|
"devDependencies": {}
|
package/sbom.cyclonedx.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:1f60d33c-fc3b-4da1-a753-bb1c98de9968",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-06T22:33:10.715Z",
|
|
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.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.5",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.5",
|
|
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.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.5",
|
|
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.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.5",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|