@blamejs/core 0.6.7 → 0.6.12
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 +17 -11
- package/README.md +1 -1
- package/lib/api-key.js +9 -9
- package/lib/archive.js +5 -5
- package/lib/audit.js +2 -2
- package/lib/auth/jwt.js +4 -4
- package/lib/backup/index.js +21 -5
- package/lib/break-glass.js +7 -6
- package/lib/cache.js +9 -9
- package/lib/constants.js +6 -6
- package/lib/credential-hash.js +9 -9
- package/lib/db-declare-view.js +2 -2
- package/lib/external-db-migrate.js +1 -1
- package/lib/http-client.js +3 -3
- package/lib/i18n.js +12 -12
- package/lib/log-stream-webhook.js +22 -4
- package/lib/mail.js +16 -1
- package/lib/middleware/body-parser.js +32 -0
- package/lib/middleware/cors.js +1 -1
- package/lib/middleware/csrf-protect.js +2 -2
- package/lib/middleware/rate-limit.js +2 -2
- package/lib/middleware/require-auth.js +9 -4
- package/lib/notify.js +2 -2
- package/lib/object-store/sigv4-bucket-ops.js +6 -5
- package/lib/object-store/sigv4.js +3 -3
- package/lib/observability.js +39 -13
- package/lib/otel-export.js +2 -2
- package/lib/pagination.js +6 -6
- package/lib/permissions.js +7 -7
- package/lib/protocol-dispatcher.js +2 -2
- package/lib/queue.js +11 -7
- package/lib/request-helpers.js +3 -3
- package/lib/restore.js +104 -0
- package/lib/retry.js +20 -19
- package/lib/safe-url.js +16 -0
- package/lib/seeders.js +9 -9
- package/lib/session.js +5 -2
- package/lib/slug.js +6 -6
- package/lib/testing.js +5 -5
- package/lib/validate-opts.js +2 -1
- package/lib/webhook.js +7 -7
- package/package.json +1 -1
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* (e.g. req.session.data.csrfToken). Issuance + req.csrfToken
|
|
35
35
|
* exposure are the operator's responsibility in this mode.
|
|
36
36
|
*
|
|
37
|
-
* If neither is supplied, the middleware throws at create() —
|
|
37
|
+
* If neither is supplied, the middleware throws at create() —
|
|
38
38
|
* config-time validation, no silent passthrough.
|
|
39
39
|
*
|
|
40
40
|
* Submitted-token sources tried in order on state-changing requests:
|
|
@@ -177,7 +177,7 @@ function create(opts) {
|
|
|
177
177
|
? opts.trustProxy : false;
|
|
178
178
|
var _isHttps = _isHttpsFor(trustProxy);
|
|
179
179
|
|
|
180
|
-
//
|
|
180
|
+
// Throw at create() — exactly one issuance source allowed.
|
|
181
181
|
var hasCookie = opts.cookie != null && opts.cookie !== false;
|
|
182
182
|
var hasLookup = typeof opts.tokenLookup === "function";
|
|
183
183
|
if (hasCookie && hasLookup) {
|
|
@@ -256,8 +256,8 @@ function create(opts) {
|
|
|
256
256
|
var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
|
|
257
257
|
var emitHeaders = opts.header !== false;
|
|
258
258
|
var skipPaths = opts.skipPaths || [];
|
|
259
|
-
//
|
|
260
|
-
// else would crash _shouldSkip with TypeError on the first request.
|
|
259
|
+
// Throw at create(): each entry must be a string prefix or a RegExp.
|
|
260
|
+
// Anything else would crash _shouldSkip with TypeError on the first request.
|
|
261
261
|
for (var sp = 0; sp < skipPaths.length; sp++) {
|
|
262
262
|
if (typeof skipPaths[sp] !== "string" && !(skipPaths[sp] instanceof RegExp)) {
|
|
263
263
|
throw new Error("middleware.rateLimit: skipPaths[" + sp +
|
|
@@ -5,14 +5,21 @@
|
|
|
5
5
|
* either lets the request through or rejects it.
|
|
6
6
|
*
|
|
7
7
|
* Rejection shape:
|
|
8
|
-
* - JSON-preferring caller (Accept
|
|
9
|
-
*
|
|
8
|
+
* - JSON-preferring caller (Accept includes application/json, or
|
|
9
|
+
* X-Requested-With: XMLHttpRequest):
|
|
10
10
|
* 401 application/json with { error: "Authentication required." }
|
|
11
11
|
* - Browser-preferring caller, when opts.redirectTo is set:
|
|
12
12
|
* 302 with Location header
|
|
13
13
|
* - Otherwise:
|
|
14
14
|
* 401 text/plain
|
|
15
15
|
*
|
|
16
|
+
* Note: the Content-Type of the REQUEST is intentionally NOT a signal.
|
|
17
|
+
* A server-to-server POST with `Content-Type: application/json` and no
|
|
18
|
+
* `Accept` header should get the same response shape as any other
|
|
19
|
+
* unauthenticated request — Content-Type describes what the client
|
|
20
|
+
* SENT, not what they want back. Operators with a non-default
|
|
21
|
+
* preference contract supply opts.prefersJson.
|
|
22
|
+
*
|
|
16
23
|
* Always emits `auth.required.denied` audit event on rejection (when
|
|
17
24
|
* opts.audit !== false). The event records request method + path +
|
|
18
25
|
* client IP — keys-only, no body content.
|
|
@@ -37,8 +44,6 @@ function _defaultPrefersJson(req) {
|
|
|
37
44
|
var h = req.headers || {};
|
|
38
45
|
if (typeof h.accept === "string" && h.accept.indexOf("application/json") !== -1) return true;
|
|
39
46
|
if (h["x-requested-with"] === "XMLHttpRequest") return true;
|
|
40
|
-
if (typeof h["content-type"] === "string" &&
|
|
41
|
-
h["content-type"].indexOf("application/json") === 0) return true;
|
|
42
47
|
return false;
|
|
43
48
|
}
|
|
44
49
|
|
package/lib/notify.js
CHANGED
|
@@ -74,7 +74,7 @@ var DEFAULTS = Object.freeze({
|
|
|
74
74
|
// we get the framework's policy without forking the constants.
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
// ----
|
|
77
|
+
// ---- Call-site validation (throw on bad input) ----
|
|
78
78
|
|
|
79
79
|
function _isFiniteNonNegative(n) {
|
|
80
80
|
return typeof n === "number" && isFinite(n) && n >= 0;
|
|
@@ -360,7 +360,7 @@ function create(opts) {
|
|
|
360
360
|
|
|
361
361
|
function _emitObs(name, labels) {
|
|
362
362
|
try { observability().event(name, 1, labels || {}); }
|
|
363
|
-
catch (_e) { /*
|
|
363
|
+
catch (_e) { /* drop-silent — observability sink must not crash send() */ }
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
function _emitAudit(action, info) {
|
|
@@ -44,13 +44,14 @@
|
|
|
44
44
|
* maxAgeSeconds: 3600,
|
|
45
45
|
* }]);
|
|
46
46
|
*
|
|
47
|
-
* Validation
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* Validation rejects every bad input shape at the call site rather
|
|
48
|
+
* than producing a server-side 400. Errors surface as ObjectStoreError
|
|
49
|
+
* with codes (BUCKET_INVALID_NAME, INVALID_LIFECYCLE, INVALID_CORS_RULE,
|
|
50
|
+
* BUCKET_ALREADY_OWNED, BUCKET_NOT_EMPTY, etc.).
|
|
51
51
|
*/
|
|
52
52
|
var { URL } = require("url");
|
|
53
53
|
var nodeCrypto = require("crypto");
|
|
54
|
+
var C = require("../constants");
|
|
54
55
|
var sigv4 = require("./sigv4");
|
|
55
56
|
var safeXml = require("../parsers/safe-xml");
|
|
56
57
|
var safeUrl = require("../safe-url");
|
|
@@ -261,7 +262,7 @@ function _buildCorsXml(rules) {
|
|
|
261
262
|
body += "</CORSRule>";
|
|
262
263
|
}
|
|
263
264
|
body += "</CORSConfiguration>";
|
|
264
|
-
if (Buffer.byteLength(body, "utf8") > 64
|
|
265
|
+
if (Buffer.byteLength(body, "utf8") > C.BYTES.kib(64)) {
|
|
265
266
|
throw _err("INVALID_CORS_RULE",
|
|
266
267
|
"CORS configuration exceeds 64 KB (S3 spec)", true);
|
|
267
268
|
}
|
|
@@ -214,17 +214,17 @@ function _request(method, url, headers, body, opts) {
|
|
|
214
214
|
// CompleteMultipartUpload with EntityTooSmall. The framework refuses
|
|
215
215
|
// configurations below this floor at create() time so operators don't
|
|
216
216
|
// see surprising failures only on large uploads.
|
|
217
|
-
var MIN_PART_SIZE_BYTES = 5
|
|
217
|
+
var MIN_PART_SIZE_BYTES = C.BYTES.mib(5);
|
|
218
218
|
// S3 spec ceiling on part count. CompleteMultipartUpload rejects
|
|
219
219
|
// uploads with more than 10000 parts.
|
|
220
220
|
var MAX_PARTS = 10000;
|
|
221
221
|
// Auto-multipart trigger: buffered bodies under this stay single-PUT.
|
|
222
222
|
// Streams always go multipart since size isn't known up-front.
|
|
223
|
-
var DEFAULT_MULTIPART_THRESHOLD_BYTES = 64
|
|
223
|
+
var DEFAULT_MULTIPART_THRESHOLD_BYTES = C.BYTES.mib(64);
|
|
224
224
|
// Conservative default part size — large enough to keep round-trip
|
|
225
225
|
// overhead small relative to payload, small enough to fit comfortably
|
|
226
226
|
// in a 4-way-concurrent upload's memory footprint.
|
|
227
|
-
var DEFAULT_PART_SIZE_BYTES = 16
|
|
227
|
+
var DEFAULT_PART_SIZE_BYTES = C.BYTES.mib(16);
|
|
228
228
|
var DEFAULT_PART_CONCURRENCY = 4;
|
|
229
229
|
|
|
230
230
|
// ---- SSE option handling ----
|
package/lib/observability.js
CHANGED
|
@@ -59,18 +59,43 @@ var lazyRequire = require("./lazy-require");
|
|
|
59
59
|
var tracing = lazyRequire(function () { return require("./tracing"); });
|
|
60
60
|
var metrics = lazyRequire(function () { return require("./metrics"); });
|
|
61
61
|
|
|
62
|
+
// Operator-installed tap handler — wired via setTap(). When non-null,
|
|
63
|
+
// every observability event/tap dispatch routes here in addition to
|
|
64
|
+
// the framework's metrics module. Used by b.otelExport.create() so an
|
|
65
|
+
// OTLP/HTTP exporter receives the same hot-path counters the framework
|
|
66
|
+
// emits internally.
|
|
67
|
+
var _externalTap = null;
|
|
68
|
+
|
|
62
69
|
function _safeMetricsTap(name, value, labels) {
|
|
63
70
|
try { metrics().tap(name, value, labels); }
|
|
64
71
|
catch (_e) { /* boot-order tolerance — metrics may not be loaded */ }
|
|
72
|
+
if (_externalTap !== null) {
|
|
73
|
+
try { _externalTap(name, value, labels); }
|
|
74
|
+
catch (_e) { /* operator-installed handler — drop-silent on its throws */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// setTap — install an external tap handler. Operators wire this from
|
|
79
|
+
// `b.otelExport.create({...}).tapHandler` so every framework counter
|
|
80
|
+
// also lands in the operator's metrics pipeline.
|
|
81
|
+
//
|
|
82
|
+
// The handler signature mirrors metrics.tap: (name, value, labels).
|
|
83
|
+
// Pass null to remove the previously-installed handler.
|
|
84
|
+
function setTap(handler) {
|
|
85
|
+
if (handler !== null && typeof handler !== "function") {
|
|
86
|
+
throw new TypeError("observability.setTap: handler must be a function or null, got " +
|
|
87
|
+
typeof handler);
|
|
88
|
+
}
|
|
89
|
+
_externalTap = handler;
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
function tap(name, attrs, fn) {
|
|
68
93
|
if (typeof attrs === "function") { fn = attrs; attrs = null; }
|
|
69
|
-
//
|
|
70
|
-
// in the name (e.g. variable holding undefined) silently corrupts
|
|
71
|
-
// the span tree AND the metrics counter route, with no obvious
|
|
72
|
-
// until somebody opens a dashboard. Throw at first call so
|
|
73
|
-
// operator catches it.
|
|
94
|
+
// Throw on bad input: tap is called from many call sites and a typo
|
|
95
|
+
// in the name (e.g. variable holding undefined) silently corrupts
|
|
96
|
+
// both the span tree AND the metrics counter route, with no obvious
|
|
97
|
+
// symptom until somebody opens a dashboard. Throw at first call so
|
|
98
|
+
// the operator catches it.
|
|
74
99
|
if (typeof name !== "string" || name.length === 0) {
|
|
75
100
|
throw new TypeError("observability.tap: name must be a non-empty string, got " +
|
|
76
101
|
(typeof name) + " " + JSON.stringify(name));
|
|
@@ -97,18 +122,19 @@ function tap(name, attrs, fn) {
|
|
|
97
122
|
});
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
//
|
|
101
|
-
// hot paths where throwing would crash the request
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
125
|
+
// Drop-silent on bad input by design: event is the fire-and-forget
|
|
126
|
+
// shape called from hot paths where throwing would crash the request
|
|
127
|
+
// that triggered it. Operators with a misnamed event see the missing
|
|
128
|
+
// counter, not a 500. metrics.tap performs its own label-name regex
|
|
129
|
+
// validation; an invalid call surfaces in the metrics module log, not
|
|
130
|
+
// via a thrown exception.
|
|
106
131
|
function event(name, value, labels) {
|
|
107
132
|
if (typeof name !== "string" || name.length === 0) return;
|
|
108
133
|
_safeMetricsTap(name, value, labels);
|
|
109
134
|
}
|
|
110
135
|
|
|
111
136
|
module.exports = {
|
|
112
|
-
tap:
|
|
113
|
-
event:
|
|
137
|
+
tap: tap,
|
|
138
|
+
event: event,
|
|
139
|
+
setTap: setTap,
|
|
114
140
|
};
|
package/lib/otel-export.js
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Wiring with `b.observability`:
|
|
29
29
|
*
|
|
30
|
-
* // Option A:
|
|
31
|
-
* b.observability.
|
|
30
|
+
* // Option A: install as an external tap on b.observability
|
|
31
|
+
* b.observability.setTap(otel.tapHandler);
|
|
32
32
|
*
|
|
33
33
|
* // Option B: alongside b.metrics — operators write their own
|
|
34
34
|
* // multi-tap fan-out (or pick one or the other for v1).
|
package/lib/pagination.js
CHANGED
|
@@ -215,10 +215,10 @@ async function cursor(query, opts) {
|
|
|
215
215
|
}
|
|
216
216
|
var limit = _resolveLimit(opts);
|
|
217
217
|
var orderBy = typeof opts.orderBy === "string" && opts.orderBy.length > 0 ? opts.orderBy : "_id";
|
|
218
|
-
//
|
|
219
|
-
// for the keyset where-clause. Restrict to
|
|
220
|
-
// so a careless caller piping
|
|
221
|
-
// create an SQL-injection vector.
|
|
218
|
+
// Throw at call site on bad orderBy — the value is interpolated into
|
|
219
|
+
// a raw SQL fragment for the keyset where-clause. Restrict to
|
|
220
|
+
// identifier-safe characters so a careless caller piping
|
|
221
|
+
// `req.query.orderBy` through doesn't create an SQL-injection vector.
|
|
222
222
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(orderBy)) {
|
|
223
223
|
throw new PaginationError("pagination/bad-orderby",
|
|
224
224
|
"cursor: orderBy must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
|
|
@@ -342,8 +342,8 @@ async function offset(query, opts) {
|
|
|
342
342
|
var page = parseInt(opts.page, 10);
|
|
343
343
|
if (isNaN(page) || page < 1) page = 1;
|
|
344
344
|
var orderBy = typeof opts.orderBy === "string" && opts.orderBy.length > 0 ? opts.orderBy : "_id";
|
|
345
|
-
// Same
|
|
346
|
-
// db Query;
|
|
345
|
+
// Same identifier-only check on offset() as cursor() — orderBy passes
|
|
346
|
+
// through to the db Query; throw at call site to prevent SQL injection.
|
|
347
347
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(orderBy)) {
|
|
348
348
|
throw new PaginationError("pagination/bad-orderby",
|
|
349
349
|
"offset: orderBy must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
|
package/lib/permissions.js
CHANGED
|
@@ -31,14 +31,14 @@
|
|
|
31
31
|
* "users:*:read" matches "users:foo:read" (per-segment *)
|
|
32
32
|
* "users:read" matches "users:read" only — no implicit sub-resource grant
|
|
33
33
|
*
|
|
34
|
-
* Validation
|
|
34
|
+
* Validation policy:
|
|
35
35
|
*
|
|
36
|
-
* - create() role table / scope formats →
|
|
37
|
-
* - require(scope) registration arg →
|
|
38
|
-
* - check(actor, scope) bad actor →
|
|
36
|
+
* - create() role table / scope formats → throw at app init
|
|
37
|
+
* - require(scope) registration arg → throw at route declaration
|
|
38
|
+
* - check(actor, scope) bad actor → return false (tolerant read)
|
|
39
39
|
* - resolver returns null in middleware → 401 (missingActorStatus)
|
|
40
40
|
* - actor lacks scope in middleware → 403 (denyStatus)
|
|
41
|
-
* - audit/observability emit failures →
|
|
41
|
+
* - audit/observability emit failures → drop silent (hot-path sink)
|
|
42
42
|
*
|
|
43
43
|
* Audit defaults follow the framework's security-defaults stance
|
|
44
44
|
* default: `auditFailures: true`
|
|
@@ -57,7 +57,7 @@ var observability = lazyRequire(function () { return require("./observability");
|
|
|
57
57
|
|
|
58
58
|
function _emitEvent(name, value, labels) {
|
|
59
59
|
try { observability().event(name, value, labels || {}); }
|
|
60
|
-
catch (_e) { /*
|
|
60
|
+
catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Lowercase tokens, digits, dash, underscore, and `*` allowed per
|
|
@@ -331,7 +331,7 @@ function create(opts) {
|
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
// Middleware factory. `mode` is "single" | "all" | "any"; `requested`
|
|
334
|
-
// is the scope or scope list.
|
|
334
|
+
// is the scope or scope list. Throw at registration time on bad shape.
|
|
335
335
|
function _middleware(mode, requested) {
|
|
336
336
|
if (mode === "single") {
|
|
337
337
|
_validateScopePattern(requested, "permissions.require");
|
|
@@ -51,8 +51,8 @@ var { defineClass } = require("./framework-error");
|
|
|
51
51
|
// supply their own get this generic shape with the same code namespace.
|
|
52
52
|
var ProtocolDispatcherError = defineClass("ProtocolDispatcherError", { withStatusCode: true });
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// Throw at create-time on bad input so operators catch typos at app
|
|
55
|
+
// boot, not first request.
|
|
56
56
|
function _validateConfig(opts) {
|
|
57
57
|
if (!opts || typeof opts !== "object") {
|
|
58
58
|
throw new Error("protocolDispatcher.create: opts is required");
|
package/lib/queue.js
CHANGED
|
@@ -188,16 +188,20 @@ function consume(queueName, handler, opts) {
|
|
|
188
188
|
// accounting keeps it cheap (just a sliding deque of timestamps).
|
|
189
189
|
var rateLimit = null;
|
|
190
190
|
if (opts.rateLimit) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
var rlMax = opts.rateLimit.max;
|
|
192
|
+
var rlPer = opts.rateLimit.perSeconds;
|
|
193
|
+
// Strict positive-finite on both: NaN, Infinity, 0, and negatives
|
|
194
|
+
// all produce undefined throttling math (NaN deque comparisons,
|
|
195
|
+
// perma-locked queues, perma-open windows). Reject at config time.
|
|
196
|
+
if (typeof rlMax !== "number" || !isFinite(rlMax) || rlMax <= 0 || Math.floor(rlMax) !== rlMax ||
|
|
197
|
+
typeof rlPer !== "number" || !isFinite(rlPer) || rlPer <= 0) {
|
|
194
198
|
throw _err("BAD_RATE_LIMIT",
|
|
195
|
-
"consume({ rateLimit }): expected { max:
|
|
199
|
+
"consume({ rateLimit }): expected { max: positive integer, perSeconds: positive finite number }, got " +
|
|
196
200
|
JSON.stringify(opts.rateLimit), true);
|
|
197
201
|
}
|
|
198
202
|
rateLimit = {
|
|
199
|
-
max:
|
|
200
|
-
windowMs: C.TIME.seconds(
|
|
203
|
+
max: rlMax,
|
|
204
|
+
windowMs: C.TIME.seconds(rlPer),
|
|
201
205
|
timestamps: [],
|
|
202
206
|
};
|
|
203
207
|
}
|
|
@@ -505,7 +509,7 @@ function _resetForTest() {
|
|
|
505
509
|
// ],
|
|
506
510
|
// });
|
|
507
511
|
//
|
|
508
|
-
// Cycle detection runs at registration (
|
|
512
|
+
// Cycle detection runs at registration (throws at call site). Each child enters
|
|
509
513
|
// the queue with availableAt = MAX_SAFE_INTEGER until parent completion
|
|
510
514
|
// bumps it. Returns { flowId, jobs: [{ name, jobId }, ...] }.
|
|
511
515
|
function enqueueFlow(spec) {
|
package/lib/request-helpers.js
CHANGED
|
@@ -175,9 +175,9 @@ function requestProtocol(req, opts) {
|
|
|
175
175
|
// parseListHeader(undefined) → []
|
|
176
176
|
// parseListHeader("") → []
|
|
177
177
|
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
178
|
+
// Tolerant read: non-string input returns [] — these are read from
|
|
179
|
+
// request headers that the network might omit. Callers needing stricter
|
|
180
|
+
// checks layer their own validation on the result.
|
|
181
181
|
function parseListHeader(value, opts) {
|
|
182
182
|
if (value == null) return [];
|
|
183
183
|
opts = opts || {};
|
package/lib/restore.js
CHANGED
|
@@ -89,6 +89,7 @@ function create(opts) {
|
|
|
89
89
|
opts = opts || {};
|
|
90
90
|
validateOpts(opts, [
|
|
91
91
|
"dataDir", "storage", "passphrase", "rollbackRoot", "audit",
|
|
92
|
+
"maxPulledBytes", "maxPulledFiles",
|
|
92
93
|
], "restore");
|
|
93
94
|
if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
|
|
94
95
|
throw new RestoreError("restore/no-datadir",
|
|
@@ -106,6 +107,47 @@ function create(opts) {
|
|
|
106
107
|
var rollbackRoot = opts.rollbackRoot || (dataDir + ".rollbacks");
|
|
107
108
|
var auditOn = opts.audit !== false;
|
|
108
109
|
|
|
110
|
+
// Preflight footprint caps. Defended against storage that returns a
|
|
111
|
+
// tampered or oversized bundle: we cap both the storage-reported size
|
|
112
|
+
// (cheap, before pull) AND the actually-pulled bytes/file-count
|
|
113
|
+
// (defense-in-depth in case the backend lied). Default 4 GiB / 100K
|
|
114
|
+
// files keeps the small-bundle path uncapped while bounding the
|
|
115
|
+
// pathological case.
|
|
116
|
+
var maxPulledBytes = typeof opts.maxPulledBytes === "number" && isFinite(opts.maxPulledBytes) && opts.maxPulledBytes > 0
|
|
117
|
+
? opts.maxPulledBytes : 4 * 1024 * 1024 * 1024;
|
|
118
|
+
var maxPulledFiles = typeof opts.maxPulledFiles === "number" && isFinite(opts.maxPulledFiles) && opts.maxPulledFiles > 0
|
|
119
|
+
? opts.maxPulledFiles : 100000;
|
|
120
|
+
|
|
121
|
+
function _walkPullDirFootprint(dir) {
|
|
122
|
+
var totalBytes = 0, fileCount = 0;
|
|
123
|
+
var stack = [dir];
|
|
124
|
+
while (stack.length > 0) {
|
|
125
|
+
var current = stack.pop();
|
|
126
|
+
var entries;
|
|
127
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); }
|
|
128
|
+
catch (_e) { continue; }
|
|
129
|
+
for (var i = 0; i < entries.length; i++) {
|
|
130
|
+
var entry = entries[i];
|
|
131
|
+
var full = path.join(current, entry.name);
|
|
132
|
+
if (entry.isDirectory()) {
|
|
133
|
+
stack.push(full);
|
|
134
|
+
} else if (entry.isFile()) {
|
|
135
|
+
fileCount++;
|
|
136
|
+
if (fileCount > maxPulledFiles) {
|
|
137
|
+
return { tooManyFiles: true, fileCount: fileCount };
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
totalBytes += fs.statSync(full).size;
|
|
141
|
+
if (totalBytes > maxPulledBytes) {
|
|
142
|
+
return { tooManyBytes: true, totalBytes: totalBytes };
|
|
143
|
+
}
|
|
144
|
+
} catch (_e) { /* file vanished mid-walk */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { totalBytes: totalBytes, fileCount: fileCount };
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
function _emitAudit(action, info, outcome) {
|
|
110
152
|
if (!auditOn) return;
|
|
111
153
|
audit().safeEmit({
|
|
@@ -118,6 +160,32 @@ function create(opts) {
|
|
|
118
160
|
|
|
119
161
|
async function list() { return await storage.listBundles(); }
|
|
120
162
|
|
|
163
|
+
// Find a bundle in storage.listBundles() output and check its
|
|
164
|
+
// reported size against maxPulledBytes BEFORE pulling. listBundles()
|
|
165
|
+
// returns the storage-reported size; cheap to scan and rejects an
|
|
166
|
+
// oversized object before any bytes hit local disk. Returns the
|
|
167
|
+
// bundle metadata when within bounds, throws when oversized, returns
|
|
168
|
+
// null when listBundles doesn't surface the bundle (e.g. listing is
|
|
169
|
+
// truncated by the backend).
|
|
170
|
+
async function _preflightBundleSize(bundleId) {
|
|
171
|
+
var listed;
|
|
172
|
+
try { listed = await storage.listBundles(); }
|
|
173
|
+
catch (_e) { return null; }
|
|
174
|
+
if (!Array.isArray(listed)) return null;
|
|
175
|
+
for (var i = 0; i < listed.length; i++) {
|
|
176
|
+
var entry = listed[i];
|
|
177
|
+
if (entry && entry.bundleId === bundleId) {
|
|
178
|
+
if (typeof entry.size === "number" && entry.size > maxPulledBytes) {
|
|
179
|
+
throw new RestoreError("restore/bundle-too-large",
|
|
180
|
+
"bundle '" + bundleId + "' reports size " + entry.size +
|
|
181
|
+
" bytes, exceeds maxPulledBytes " + maxPulledBytes);
|
|
182
|
+
}
|
|
183
|
+
return entry;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
121
189
|
async function inspect(bundleId) {
|
|
122
190
|
if (typeof bundleId !== "string" || bundleId.length === 0) {
|
|
123
191
|
throw new RestoreError("restore/bad-bundle-id", "inspect: bundleId is required");
|
|
@@ -127,10 +195,22 @@ function create(opts) {
|
|
|
127
195
|
throw new RestoreError("restore/bundle-not-found",
|
|
128
196
|
"inspect: bundle '" + bundleId + "' not in storage");
|
|
129
197
|
}
|
|
198
|
+
await _preflightBundleSize(bundleId);
|
|
130
199
|
var pullDir = path.join(os.tmpdir(),
|
|
131
200
|
"blamejs-restore-inspect-" + crypto.generateToken(4));
|
|
132
201
|
try {
|
|
133
202
|
await storage.readBundle(bundleId, pullDir);
|
|
203
|
+
var pulled = _walkPullDirFootprint(pullDir);
|
|
204
|
+
if (pulled.tooManyBytes) {
|
|
205
|
+
throw new RestoreError("restore/pulled-too-large",
|
|
206
|
+
"bundle '" + bundleId + "' pulled " + pulled.totalBytes +
|
|
207
|
+
" bytes (caught mid-pull), exceeds maxPulledBytes " + maxPulledBytes);
|
|
208
|
+
}
|
|
209
|
+
if (pulled.tooManyFiles) {
|
|
210
|
+
throw new RestoreError("restore/pulled-too-many-files",
|
|
211
|
+
"bundle '" + bundleId + "' pulled " + pulled.fileCount +
|
|
212
|
+
" files, exceeds maxPulledFiles " + maxPulledFiles);
|
|
213
|
+
}
|
|
134
214
|
return restoreBundle.inspect({ bundleDir: pullDir });
|
|
135
215
|
} finally {
|
|
136
216
|
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
@@ -160,6 +240,17 @@ function create(opts) {
|
|
|
160
240
|
}
|
|
161
241
|
|
|
162
242
|
// 1. Pull bundle out of storage
|
|
243
|
+
// Preflight: reject an oversized bundle BEFORE pulling bytes to
|
|
244
|
+
// disk. Cheap when the backend lists size; no-op when it doesn't.
|
|
245
|
+
try {
|
|
246
|
+
await _preflightBundleSize(bundleId);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
_cleanupTmp();
|
|
249
|
+
_emitAudit("restore.failure",
|
|
250
|
+
{ bundleId: bundleId, reason: (e && e.message) || String(e) },
|
|
251
|
+
"failure");
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
163
254
|
try {
|
|
164
255
|
await storage.readBundle(bundleId, pullDir);
|
|
165
256
|
} catch (e) {
|
|
@@ -170,6 +261,19 @@ function create(opts) {
|
|
|
170
261
|
throw new RestoreError("restore/storage-read-failed",
|
|
171
262
|
"pulling bundle from storage failed: " + ((e && e.message) || String(e)));
|
|
172
263
|
}
|
|
264
|
+
// Defense-in-depth: walk the pulled bundle and re-check footprint.
|
|
265
|
+
// Catches a backend that under-reported size in listBundles or that
|
|
266
|
+
// doesn't surface size at all.
|
|
267
|
+
var pulled = _walkPullDirFootprint(pullDir);
|
|
268
|
+
if (pulled.tooManyBytes || pulled.tooManyFiles) {
|
|
269
|
+
_cleanupTmp();
|
|
270
|
+
var capCode = pulled.tooManyBytes ? "restore/pulled-too-large" : "restore/pulled-too-many-files";
|
|
271
|
+
var capMsg = pulled.tooManyBytes
|
|
272
|
+
? "bundle '" + bundleId + "' pulled " + pulled.totalBytes + " bytes, exceeds maxPulledBytes " + maxPulledBytes
|
|
273
|
+
: "bundle '" + bundleId + "' pulled " + pulled.fileCount + " files, exceeds maxPulledFiles " + maxPulledFiles;
|
|
274
|
+
_emitAudit("restore.failure", { bundleId: bundleId, reason: capMsg }, "failure");
|
|
275
|
+
throw new RestoreError(capCode, capMsg);
|
|
276
|
+
}
|
|
173
277
|
|
|
174
278
|
// 2. Decrypt + verify into stagingDir
|
|
175
279
|
var extracted;
|
package/lib/retry.js
CHANGED
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
* compose freely with `safeAsync.withTimeout`, AbortSignals, and any
|
|
19
19
|
* caller-side instrumentation.
|
|
20
20
|
*
|
|
21
|
-
* Validation
|
|
21
|
+
* Validation policy:
|
|
22
22
|
*
|
|
23
|
-
* - withRetry opts at first call →
|
|
24
|
-
* - CircuitBreaker constructor opts →
|
|
25
|
-
* - backoffDelay(attempt) attempt argument →
|
|
26
|
-
* - isRetryable(err) defensive read →
|
|
27
|
-
* - onRetry callback throw →
|
|
28
|
-
* - breaker internal _onSuccess/_onFailure →
|
|
23
|
+
* - withRetry opts at first call → throw at call site
|
|
24
|
+
* - CircuitBreaker constructor opts → throw at call site
|
|
25
|
+
* - backoffDelay(attempt) attempt argument → throw at call site
|
|
26
|
+
* - isRetryable(err) defensive read → tolerant (return defaults)
|
|
27
|
+
* - onRetry callback throw → drop silent (hot-path sink)
|
|
28
|
+
* - breaker internal _onSuccess/_onFailure → drop silent (hot-path sink)
|
|
29
29
|
*
|
|
30
30
|
* HTTP-client auto-retry is intentionally NOT provided here. Callers
|
|
31
31
|
* wrap their own outbound calls in `b.retry.withRetry(...)` to keep
|
|
@@ -40,13 +40,13 @@ var lazyRequire = require("./lazy-require");
|
|
|
40
40
|
// are fully loaded.
|
|
41
41
|
var safeAsync = lazyRequire(function () { return require("./safe-async"); });
|
|
42
42
|
// observability is also lazy-required because the metrics + tracing
|
|
43
|
-
// registry boots after this file loads. event() is
|
|
44
|
-
// throw inside the metrics sink is swallowed by observability itself.
|
|
43
|
+
// registry boots after this file loads. event() is fire-and-forget —
|
|
44
|
+
// any throw inside the metrics sink is swallowed by observability itself.
|
|
45
45
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
46
46
|
|
|
47
47
|
function _emitEvent(name, value, labels) {
|
|
48
48
|
try { observability().event(name, value, labels || {}); }
|
|
49
|
-
catch (_e) { /*
|
|
49
|
+
catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// ---- Defaults ----
|
|
@@ -80,7 +80,7 @@ var DEFAULT_BREAKER = Object.freeze({
|
|
|
80
80
|
successThreshold: 2, // consecutive HALF probes that close it
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
// ----
|
|
83
|
+
// ---- Call-site validation helpers (throw on bad input) ----
|
|
84
84
|
|
|
85
85
|
function _isPositiveInt(n) {
|
|
86
86
|
return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
|
|
@@ -147,7 +147,7 @@ function _validateBreakerOpts(name, opts) {
|
|
|
147
147
|
|
|
148
148
|
// ---- Public surface ----
|
|
149
149
|
|
|
150
|
-
//
|
|
150
|
+
// Tolerant read of err shape; missing fields → false.
|
|
151
151
|
function isRetryable(err) {
|
|
152
152
|
if (!err) return false;
|
|
153
153
|
if (err.isObjectStoreError && err.permanent) return false;
|
|
@@ -162,11 +162,12 @@ function isRetryable(err) {
|
|
|
162
162
|
return false; // default: not retryable (avoid masking bugs)
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
//
|
|
166
|
-
// have non-neg-finite baseDelayMs/maxDelayMs and finite jitterFactor
|
|
167
|
-
// We don't full-validate opts here every call (hot path) —
|
|
168
|
-
// frozen, so the only way a bad opts reaches here is via
|
|
169
|
-
// already validated, OR a caller using backoffDelay
|
|
165
|
+
// Throw on bad input: attempt must be a positive int; opts (when supplied)
|
|
166
|
+
// must have non-neg-finite baseDelayMs/maxDelayMs and finite jitterFactor
|
|
167
|
+
// in [0,1]. We don't full-validate opts here every call (hot path) —
|
|
168
|
+
// defaults are frozen, so the only way a bad opts reaches here is via
|
|
169
|
+
// withRetry which already validated, OR a caller using backoffDelay
|
|
170
|
+
// directly. For that
|
|
170
171
|
// direct case we still validate the attempt arg loudly.
|
|
171
172
|
function backoffDelay(attempt, opts) {
|
|
172
173
|
if (!_isPositiveInt(attempt)) {
|
|
@@ -212,8 +213,8 @@ async function withRetry(fn, opts) {
|
|
|
212
213
|
var delay = backoffDelay(attempt, opts);
|
|
213
214
|
_emitEvent("retry.attempt", 1, { attempt: attempt });
|
|
214
215
|
if (typeof opts.onRetry === "function") {
|
|
215
|
-
//
|
|
216
|
-
//
|
|
216
|
+
// Hot-path observability sink — drops silent on observer throw
|
|
217
|
+
// so a thrown observer can't crash the retry loop.
|
|
217
218
|
try { opts.onRetry({ attempt: attempt, delay: delay, error: err }); } catch (_e) {}
|
|
218
219
|
}
|
|
219
220
|
// Honor opts.signal during the backoff sleep — a caller who aborts
|
package/lib/safe-url.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* log-stream, http-client) surface their
|
|
24
24
|
* own decorated error class. Default:
|
|
25
25
|
* SafeUrlError.
|
|
26
|
+
* allowUserinfo — accept URLs that carry user:pass@ credentials
|
|
27
|
+
* in the authority. Default: false. Userinfo in
|
|
28
|
+
* outbound URLs leaks into request logs, error
|
|
29
|
+
* messages, metric labels, and trace spans;
|
|
30
|
+
* credential placement belongs in headers /
|
|
31
|
+
* cookies / a credential store, not the URL.
|
|
32
|
+
* Operators with a legacy endpoint that
|
|
33
|
+
* REQUIRES userinfo opt in explicitly per call.
|
|
26
34
|
*
|
|
27
35
|
* Constants — pre-baked allowlists for the common caller cases:
|
|
28
36
|
*
|
|
@@ -95,6 +103,14 @@ function parse(url, opts) {
|
|
|
95
103
|
"]. Pass opts.allowedProtocols to override (e.g. safeUrl.ALLOW_HTTP_ALL for cleartext endpoints).");
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
if (opts.allowUserinfo !== true && (parsed.username !== "" || parsed.password !== "")) {
|
|
107
|
+
throw _makeError(errClass, "safe-url/userinfo-disallowed",
|
|
108
|
+
"URL contains user:pass@ credentials in the authority. These leak into " +
|
|
109
|
+
"request logs / error messages / metric labels / trace spans. Move the " +
|
|
110
|
+
"credential to an Authorization header (or a credential store the client " +
|
|
111
|
+
"reads at call time), or pass opts.allowUserinfo: true to opt this URL in.");
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
return parsed;
|
|
99
115
|
}
|
|
100
116
|
|