@blamejs/core 0.9.14 → 0.9.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 +2 -0
- package/lib/a2a-tasks.js +2 -2
- package/lib/a2a.js +11 -11
- package/lib/acme.js +5 -5
- package/lib/ai-input.js +2 -2
- package/lib/api-key.js +4 -4
- package/lib/api-snapshot.js +6 -6
- package/lib/app-shutdown.js +2 -2
- package/lib/app.js +5 -5
- package/lib/archive.js +8 -8
- package/lib/argon2-builtin.js +2 -2
- package/lib/atomic-file.js +53 -53
- package/lib/audit-sign.js +8 -8
- package/lib/audit-tools.js +22 -22
- package/lib/auth/dpop.js +3 -3
- package/lib/auth/elevation-grant.js +3 -3
- package/lib/auth/fido-mds3.js +6 -6
- package/lib/auth/jwt-external.js +2 -2
- package/lib/auth/sd-jwt-vc.js +2 -2
- package/lib/backup/bundle.js +17 -17
- package/lib/backup/index.js +36 -36
- package/lib/budr.js +3 -3
- package/lib/bundler.js +20 -20
- package/lib/circuit-breaker.js +4 -4
- package/lib/cli.js +25 -26
- package/lib/cluster.js +2 -2
- package/lib/compliance-sanctions.js +2 -2
- package/lib/compliance.js +6 -7
- package/lib/config-drift.js +15 -15
- package/lib/config.js +6 -6
- package/lib/content-credentials.js +4 -4
- package/lib/credential-hash.js +7 -7
- package/lib/crypto-field.js +9 -9
- package/lib/daemon.js +19 -19
- package/lib/db-file-lifecycle.js +24 -24
- package/lib/db-schema.js +2 -2
- package/lib/db.js +34 -34
- package/lib/dev.js +10 -10
- package/lib/dr-runbook.js +5 -5
- package/lib/dual-control.js +2 -2
- package/lib/external-db-migrate.js +17 -17
- package/lib/external-db.js +2 -2
- package/lib/fdx.js +2 -2
- package/lib/file-upload.js +30 -30
- package/lib/flag-evaluation-context.js +2 -2
- package/lib/flag-providers.js +4 -4
- package/lib/gate-contract.js +5 -5
- package/lib/graphql-federation.js +4 -7
- package/lib/honeytoken.js +6 -6
- package/lib/http-client-cookie-jar.js +6 -6
- package/lib/http-client.js +18 -18
- package/lib/i18n.js +5 -5
- package/lib/keychain.js +5 -5
- package/lib/legal-hold.js +2 -2
- package/lib/local-db-thin.js +9 -9
- package/lib/log-stream-local.js +17 -17
- package/lib/log-stream-syslog.js +2 -2
- package/lib/log-stream.js +3 -3
- package/lib/log.js +2 -2
- package/lib/mail-bounce.js +2 -2
- package/lib/mail-mdn.js +2 -2
- package/lib/mail-srs.js +2 -2
- package/lib/mail.js +7 -7
- package/lib/mcp-tool-registry.js +6 -6
- package/lib/mcp.js +2 -2
- package/lib/metrics.js +2 -2
- package/lib/middleware/api-encrypt.js +16 -16
- package/lib/middleware/body-parser.js +18 -18
- package/lib/middleware/compression.js +3 -3
- package/lib/middleware/csp-nonce.js +4 -4
- package/lib/middleware/health.js +7 -7
- package/lib/middleware/idempotency-key.js +163 -63
- package/lib/middleware/require-bound-key.js +4 -4
- package/lib/middleware/require-mtls.js +4 -4
- package/lib/migrations.js +5 -5
- package/lib/mtls-ca.js +26 -26
- package/lib/mtls-engine-default.js +5 -5
- package/lib/network-byte-quota.js +2 -2
- package/lib/network-dns.js +2 -2
- package/lib/network-nts.js +2 -2
- package/lib/network-proxy.js +3 -3
- package/lib/network-smtp-policy.js +2 -2
- package/lib/network-tls.js +17 -17
- package/lib/network.js +25 -25
- package/lib/notify.js +11 -11
- package/lib/object-store/gcs-bucket-ops.js +2 -2
- package/lib/object-store/gcs.js +5 -5
- package/lib/object-store/index.js +6 -6
- package/lib/object-store/local.js +19 -19
- package/lib/object-store/sigv4.js +3 -3
- package/lib/observability-tracer.js +4 -4
- package/lib/otel-export.js +3 -3
- package/lib/pagination.js +5 -5
- package/lib/parsers/safe-env.js +3 -3
- package/lib/parsers/safe-xml.js +3 -3
- package/lib/pqc-gate.js +5 -5
- package/lib/pubsub-redis.js +2 -2
- package/lib/queue-local.js +3 -3
- package/lib/queue.js +2 -2
- package/lib/redis-client.js +4 -4
- package/lib/restore-bundle.js +17 -17
- package/lib/restore-rollback.js +34 -34
- package/lib/restore.js +16 -16
- package/lib/router.js +25 -25
- package/lib/sandbox.js +8 -8
- package/lib/sec-cyber.js +3 -3
- package/lib/security-assert.js +2 -2
- package/lib/seeders.js +6 -6
- package/lib/self-update.js +18 -18
- package/lib/session-device-binding.js +2 -2
- package/lib/static.js +22 -22
- package/lib/template.js +19 -19
- package/lib/testing.js +9 -9
- package/lib/tls-exporter.js +5 -5
- package/lib/tracing.js +3 -3
- package/lib/vault/index.js +11 -11
- package/lib/vault/passphrase-ops.js +37 -37
- package/lib/vault/passphrase-source.js +2 -2
- package/lib/vault/rotate.js +64 -64
- package/lib/vault/seal-pem-file.js +26 -26
- package/lib/vault-aad.js +5 -5
- package/lib/watcher.js +22 -22
- package/lib/webhook.js +10 -10
- package/lib/worker-pool.js +6 -6
- package/lib/ws-client.js +6 -6
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
* dots collapsed, control characters stripped, length capped at 255.
|
|
101
101
|
* Tmp file path is generated by the framework, never derived from
|
|
102
102
|
* the operator-supplied filename — so a malicious filename can't
|
|
103
|
-
* collide with a sensitive
|
|
103
|
+
* collide with a sensitive nodePath.
|
|
104
104
|
* - Multipart parser refuses fields whose `name` is in POISONED_KEYS
|
|
105
105
|
* (consistent with the JSON path).
|
|
106
106
|
* - Tmp files set with mode 0o600, parent dir created with 0o700.
|
|
@@ -108,12 +108,12 @@
|
|
|
108
108
|
* error) so a crashing handler doesn't leak files.
|
|
109
109
|
*/
|
|
110
110
|
|
|
111
|
-
var
|
|
111
|
+
var nodeFs = require("fs");
|
|
112
112
|
var os = require("os");
|
|
113
|
-
var
|
|
113
|
+
var nodePath = require("path");
|
|
114
114
|
var nodeCrypto = require("node:crypto");
|
|
115
115
|
var atomicFile = require("../atomic-file");
|
|
116
|
-
var
|
|
116
|
+
var bCrypto = require("../crypto");
|
|
117
117
|
var lazyRequire = require("../lazy-require");
|
|
118
118
|
var requestHelpers = require("../request-helpers");
|
|
119
119
|
var safeBuffer = require("../safe-buffer");
|
|
@@ -123,7 +123,7 @@ var validateOpts = require("../validate-opts");
|
|
|
123
123
|
var C = require("../constants");
|
|
124
124
|
var { defineClass } = require("../framework-error");
|
|
125
125
|
|
|
126
|
-
var
|
|
126
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
127
127
|
|
|
128
128
|
// Node's HTTP parser surfaces malformed chunked-transfer-encoding via a
|
|
129
129
|
// stable family of HPE_* codes. RFC 9112 §7.1 — when a server rejects a
|
|
@@ -682,7 +682,7 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
682
682
|
}
|
|
683
683
|
// Resolve tmpDir per-request so directory-creation failure surfaces as a
|
|
684
684
|
// structured error rather than a deferred fs throw.
|
|
685
|
-
var tmpDir = opts.tmpDir ||
|
|
685
|
+
var tmpDir = opts.tmpDir || nodePath.join(os.tmpdir(), "blamejs-uploads");
|
|
686
686
|
try { atomicFile.ensureDir(tmpDir, 0o700); }
|
|
687
687
|
catch (e) {
|
|
688
688
|
throw new BodyParserError(
|
|
@@ -733,7 +733,7 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
733
733
|
currentFilename = null;
|
|
734
734
|
currentMime = null;
|
|
735
735
|
currentTmpPath = null;
|
|
736
|
-
if (currentFd !== null) { try {
|
|
736
|
+
if (currentFd !== null) { try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
|
|
737
737
|
currentSize = 0;
|
|
738
738
|
currentHash = null;
|
|
739
739
|
currentBuf = null;
|
|
@@ -762,10 +762,10 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
762
762
|
}
|
|
763
763
|
|
|
764
764
|
function _cleanup() {
|
|
765
|
-
if (currentFd !== null) { try {
|
|
766
|
-
if (currentTmpPath) { try {
|
|
765
|
+
if (currentFd !== null) { try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
|
|
766
|
+
if (currentTmpPath) { try { nodeFs.unlinkSync(currentTmpPath); } catch (_e) { /* tmp file already removed */ } }
|
|
767
767
|
for (var i = 0; i < files.length; i++) {
|
|
768
|
-
try {
|
|
768
|
+
try { nodeFs.unlinkSync(files[i].path); } catch (_e) { /* tmp file already removed */ }
|
|
769
769
|
}
|
|
770
770
|
}
|
|
771
771
|
|
|
@@ -945,10 +945,10 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
945
945
|
|
|
946
946
|
// Generate the tmp path — never derived from the
|
|
947
947
|
// operator-supplied filename.
|
|
948
|
-
var unique =
|
|
949
|
-
currentTmpPath =
|
|
948
|
+
var unique = bCrypto.generateToken(C.BYTES.bytes(16));
|
|
949
|
+
currentTmpPath = nodePath.join(tmpDir, "blamejs-up-" + unique);
|
|
950
950
|
try {
|
|
951
|
-
currentFd =
|
|
951
|
+
currentFd = nodeFs.openSync(currentTmpPath, "wx", 0o600);
|
|
952
952
|
} catch (e) {
|
|
953
953
|
done(new BodyParserError("body-parser/multipart-tmp-open",
|
|
954
954
|
"could not open multipart tmp file: " + ((e && e.message) || String(e)),
|
|
@@ -1027,7 +1027,7 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
1027
1027
|
try {
|
|
1028
1028
|
var written = 0;
|
|
1029
1029
|
while (written < bodyChunk.length) {
|
|
1030
|
-
written +=
|
|
1030
|
+
written += nodeFs.writeSync(currentFd, bodyChunk, written, bodyChunk.length - written);
|
|
1031
1031
|
}
|
|
1032
1032
|
} catch (e) {
|
|
1033
1033
|
done(new BodyParserError("body-parser/multipart-tmp-write",
|
|
@@ -1068,7 +1068,7 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
1068
1068
|
// fileFilter rejected — already recorded in filesRejected; no
|
|
1069
1069
|
// tmp file was opened, nothing to clean up here.
|
|
1070
1070
|
} else if (currentFd !== null) {
|
|
1071
|
-
try {
|
|
1071
|
+
try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ }
|
|
1072
1072
|
currentFd = null;
|
|
1073
1073
|
files.push({
|
|
1074
1074
|
field: currentField,
|
|
@@ -1247,7 +1247,7 @@ function create(opts) {
|
|
|
1247
1247
|
if (cleanedUp) return;
|
|
1248
1248
|
cleanedUp = true;
|
|
1249
1249
|
for (var i = 0; i < mpResult.files.length; i++) {
|
|
1250
|
-
try {
|
|
1250
|
+
try { nodeFs.unlinkSync(mpResult.files[i].path); } catch (_e) { /* tmp file already removed */ }
|
|
1251
1251
|
}
|
|
1252
1252
|
}
|
|
1253
1253
|
res.on("finish", cleanup);
|
|
@@ -1294,7 +1294,7 @@ function create(opts) {
|
|
|
1294
1294
|
? "http.chunked.extension.refused"
|
|
1295
1295
|
: "http.chunked.malformed.refused";
|
|
1296
1296
|
try {
|
|
1297
|
-
|
|
1297
|
+
audit().safeEmit({
|
|
1298
1298
|
action: chunkAction,
|
|
1299
1299
|
outcome: "denied",
|
|
1300
1300
|
metadata: {
|
|
@@ -1500,7 +1500,7 @@ module.exports = {
|
|
|
1500
1500
|
BodyParserError: BodyParserError,
|
|
1501
1501
|
// Standalone async helpers — surfaced via b.parsers.{json,multipart}.
|
|
1502
1502
|
// The middleware composes these so the request-handling pipeline and
|
|
1503
|
-
// the operator-callable surface share one parsing
|
|
1503
|
+
// the operator-callable surface share one parsing nodePath.
|
|
1504
1504
|
parseJson: parseJsonStandalone,
|
|
1505
1505
|
parseMultipart: parseMultipartStandalone,
|
|
1506
1506
|
// Internal helpers exposed for tests + the csrf-protect refactor.
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
|
|
91
91
|
var zlib = require("node:zlib");
|
|
92
92
|
var C = require("../constants");
|
|
93
|
-
var
|
|
93
|
+
var numericBounds = require("../numeric-bounds");
|
|
94
94
|
var requestHelpers = require("../request-helpers");
|
|
95
95
|
var validateOpts = require("../validate-opts");
|
|
96
96
|
var { defineClass } = require("../framework-error");
|
|
@@ -271,12 +271,12 @@ function create(opts) {
|
|
|
271
271
|
var threshold;
|
|
272
272
|
if (opts.threshold === undefined) {
|
|
273
273
|
threshold = DEFAULT_OPTS.threshold;
|
|
274
|
-
} else if (
|
|
274
|
+
} else if (numericBounds.isNonNegativeFiniteInt(opts.threshold)) {
|
|
275
275
|
threshold = opts.threshold;
|
|
276
276
|
} else {
|
|
277
277
|
throw new CompressionError("compression/bad-opt",
|
|
278
278
|
"middleware.compression: threshold must be a non-negative finite integer; got " +
|
|
279
|
-
|
|
279
|
+
numericBounds.shape(opts.threshold));
|
|
280
280
|
}
|
|
281
281
|
var encodings = Array.isArray(opts.encodings) && opts.encodings.length > 0
|
|
282
282
|
? opts.encodings.slice() : DEFAULT_OPTS.encodings.slice();
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
*/
|
|
114
114
|
|
|
115
115
|
var C = require("../constants");
|
|
116
|
-
var
|
|
116
|
+
var bCrypto = require("../crypto");
|
|
117
117
|
var numericBounds = require("../numeric-bounds");
|
|
118
118
|
var validateOpts = require("../validate-opts");
|
|
119
119
|
var { defineClass } = require("../framework-error");
|
|
@@ -283,7 +283,7 @@ function create(opts) {
|
|
|
283
283
|
// Pre-fix the typeof-only check accepted Infinity / NaN — both
|
|
284
284
|
// bypassed the `< MIN_NONCE_BYTES` guard (NaN < N is always false,
|
|
285
285
|
// Infinity < N is always false), then crashed per-request when
|
|
286
|
-
// `
|
|
286
|
+
// `bCrypto.generateBytes(Infinity)` hit ERR_OUT_OF_RANGE. Route through
|
|
287
287
|
// shared numeric-bounds (positive finite int) before the lower-bound
|
|
288
288
|
// check so the typo / coercion is caught at create() time.
|
|
289
289
|
if (!numericBounds.isPositiveFiniteInt(nonceBytes)) {
|
|
@@ -322,7 +322,7 @@ function create(opts) {
|
|
|
322
322
|
if (opts.placeholder === undefined) {
|
|
323
323
|
// OS-RNG → SHAKE256 → hex via the framework random helper.
|
|
324
324
|
placeholder = PLACEHOLDER_PREFIX +
|
|
325
|
-
|
|
325
|
+
bCrypto.generateToken(PLACEHOLDER_RAND_BYTES) +
|
|
326
326
|
PLACEHOLDER_SUFFIX;
|
|
327
327
|
} else if (typeof opts.placeholder !== "string" || opts.placeholder.length === 0) {
|
|
328
328
|
throw new CspNonceError("csp-nonce/bad-placeholder",
|
|
@@ -336,7 +336,7 @@ function create(opts) {
|
|
|
336
336
|
// Generate the nonce. Cheap (16 bytes from getrandom → SHAKE256 →
|
|
337
337
|
// base64 encode); do it always for consistency unless `always:
|
|
338
338
|
// false` was set explicitly.
|
|
339
|
-
var nonce =
|
|
339
|
+
var nonce = bCrypto.generateBytes(nonceBytes).toString("base64");
|
|
340
340
|
|
|
341
341
|
// Attach to req for handler access.
|
|
342
342
|
req[property] = nonce;
|
package/lib/middleware/health.js
CHANGED
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
*/
|
|
101
101
|
|
|
102
102
|
var C = require("../constants");
|
|
103
|
-
var
|
|
103
|
+
var numericBounds = require("../numeric-bounds");
|
|
104
104
|
var requestHelpers = require("../request-helpers");
|
|
105
105
|
var safeAsync = require("../safe-async");
|
|
106
106
|
var validateOpts = require("../validate-opts");
|
|
@@ -179,22 +179,22 @@ function create(opts) {
|
|
|
179
179
|
var defaultTimeoutMs;
|
|
180
180
|
if (opts.defaultTimeoutMs === undefined) {
|
|
181
181
|
defaultTimeoutMs = DEFAULT_TIMEOUT_MS;
|
|
182
|
-
} else if (
|
|
182
|
+
} else if (numericBounds.isPositiveFiniteInt(opts.defaultTimeoutMs)) {
|
|
183
183
|
defaultTimeoutMs = opts.defaultTimeoutMs;
|
|
184
184
|
} else {
|
|
185
185
|
throw new HealthError("health/bad-opt",
|
|
186
186
|
"defaultTimeoutMs must be a positive finite integer; got " +
|
|
187
|
-
|
|
187
|
+
numericBounds.shape(opts.defaultTimeoutMs));
|
|
188
188
|
}
|
|
189
189
|
var cacheMs;
|
|
190
190
|
if (opts.cacheMs === undefined) {
|
|
191
191
|
cacheMs = 0;
|
|
192
|
-
} else if (
|
|
192
|
+
} else if (numericBounds.isNonNegativeFiniteInt(opts.cacheMs)) {
|
|
193
193
|
cacheMs = opts.cacheMs;
|
|
194
194
|
} else {
|
|
195
195
|
throw new HealthError("health/bad-opt",
|
|
196
196
|
"cacheMs must be a non-negative finite integer; got " +
|
|
197
|
-
|
|
197
|
+
numericBounds.shape(opts.cacheMs));
|
|
198
198
|
}
|
|
199
199
|
var includeMeta = opts.includeMeta !== false;
|
|
200
200
|
var version = opts.version || null;
|
|
@@ -237,12 +237,12 @@ function create(opts) {
|
|
|
237
237
|
var timeoutMs;
|
|
238
238
|
if (copts.timeoutMs === undefined) {
|
|
239
239
|
timeoutMs = defaultTimeoutMs;
|
|
240
|
-
} else if (
|
|
240
|
+
} else if (numericBounds.isPositiveFiniteInt(copts.timeoutMs)) {
|
|
241
241
|
timeoutMs = copts.timeoutMs;
|
|
242
242
|
} else {
|
|
243
243
|
throw new HealthError("health/bad-opt",
|
|
244
244
|
"registerCheck: timeoutMs must be a positive finite integer; got " +
|
|
245
|
-
|
|
245
|
+
numericBounds.shape(copts.timeoutMs));
|
|
246
246
|
}
|
|
247
247
|
checks.push({
|
|
248
248
|
name: name,
|
|
@@ -46,6 +46,9 @@ var numericBounds = require("../numeric-bounds");
|
|
|
46
46
|
var safeBuffer = require("../safe-buffer");
|
|
47
47
|
var safeJson = require("../safe-json");
|
|
48
48
|
var safeSql = require("../safe-sql");
|
|
49
|
+
var bCrypto = require("../crypto");
|
|
50
|
+
var cryptoField = require("../crypto-field");
|
|
51
|
+
var vault = require("../vault");
|
|
49
52
|
var { defineClass } = require("../framework-error");
|
|
50
53
|
|
|
51
54
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
@@ -136,7 +139,7 @@ function memoryStore(opts) {
|
|
|
136
139
|
* @signature b.middleware.idempotencyKey.dbStore(opts)
|
|
137
140
|
* @since 0.9.14
|
|
138
141
|
* @status stable
|
|
139
|
-
* @related b.middleware.idempotencyKey, b.middleware.idempotencyKey.memoryStore, b.db
|
|
142
|
+
* @related b.middleware.idempotencyKey, b.middleware.idempotencyKey.memoryStore, b.db, b.cryptoField
|
|
140
143
|
*
|
|
141
144
|
* Persistent-backed store for `idempotencyKey` middleware. Implements
|
|
142
145
|
* the same three-method interface as `memoryStore` (`get` / `set` /
|
|
@@ -148,30 +151,62 @@ function memoryStore(opts) {
|
|
|
148
151
|
*
|
|
149
152
|
* - multiple processes share the request-handling fleet (forks
|
|
150
153
|
* behind a load balancer, multi-instance K8s deployment) and a
|
|
151
|
-
* retry can land on a different process than the original
|
|
152
|
-
* request — only a shared store satisfies the §2 replay
|
|
153
|
-
* semantics across the fleet;
|
|
154
|
+
* retry can land on a different process than the original;
|
|
154
155
|
* - the daemon may restart between the original request and the
|
|
155
156
|
* retry (graceful rolling deploy, OOM kill, planned reboot) —
|
|
156
157
|
* `memoryStore` is volatile, `dbStore` survives the restart;
|
|
157
158
|
* - audit / compliance review needs to walk historic
|
|
158
|
-
* idempotency cache decisions
|
|
159
|
-
* `SELECT
|
|
159
|
+
* idempotency cache decisions queryable with
|
|
160
|
+
* `SELECT k, status_code, expires_at FROM <tableName>` —
|
|
161
|
+
* non-sealed columns are forensic-queryable without unsealing.
|
|
162
|
+
*
|
|
163
|
+
* **Defense-in-depth defaults (since 0.9.15) — both can be opted out:**
|
|
164
|
+
*
|
|
165
|
+
* - `hashKeys: true` — operator-supplied keys are sha3-512
|
|
166
|
+
* namespace-hashed via `b.crypto.namespaceHash("idempotency-key",
|
|
167
|
+
* key)` before insert/lookup. The `k` column carries the hash, not
|
|
168
|
+
* the raw key. Operator keys often carry PII (order numbers,
|
|
169
|
+
* emails, vendor prefixes); the DB never sees them.
|
|
170
|
+
* - `seal: true` — `headers` and `body` columns are sealed via
|
|
171
|
+
* `b.cryptoField.sealRow` (vault-managed key, AEAD envelope) so a
|
|
172
|
+
* DB dump leaks neither cached response bodies nor headers.
|
|
173
|
+
* Requires `b.vault.init(...)` to have run; falls back to plain-
|
|
174
|
+
* text with a one-shot audit warning when vault isn't ready, so
|
|
175
|
+
* test-fixture / boot-script callers still work.
|
|
160
176
|
*
|
|
161
177
|
* Lazily-expired: `get(key)` returns `null` for any row whose
|
|
162
|
-
* `expires_at` has passed
|
|
163
|
-
* `
|
|
164
|
-
*
|
|
165
|
-
*
|
|
178
|
+
* `expires_at` has passed. The cleanup is scoped by the observed
|
|
179
|
+
* `expires_at` so a concurrent upsert from a sibling process isn't
|
|
180
|
+
* clobbered.
|
|
181
|
+
*
|
|
182
|
+
* **Schema (v0.9.15, split columns):**
|
|
183
|
+
*
|
|
184
|
+
* ```
|
|
185
|
+
* k TEXT PRIMARY KEY -- hashed key when hashKeys=true
|
|
186
|
+
* fingerprint TEXT NOT NULL -- request method+path+body digest
|
|
187
|
+
* status_code INTEGER NOT NULL -- forensic-queryable
|
|
188
|
+
* headers TEXT NOT NULL -- JSON, sealed when seal=true
|
|
189
|
+
* body TEXT NOT NULL -- base64, sealed when seal=true
|
|
190
|
+
* expires_at INTEGER NOT NULL
|
|
191
|
+
* ```
|
|
192
|
+
*
|
|
193
|
+
* **Migration note**: v0.9.14 used a single `v` JSON envelope column.
|
|
194
|
+
* Operators with a v0.9.14 table must `DROP TABLE <tableName>;` (or
|
|
195
|
+
* pick a fresh `tableName`) before upgrading — `CREATE TABLE IF NOT
|
|
196
|
+
* EXISTS` won't migrate column layout. Pre-v1 the framework breaks
|
|
197
|
+
* across patch versions for security correctness.
|
|
166
198
|
*
|
|
167
199
|
* @opts
|
|
168
200
|
* db: object, // required — sqlite-shaped: { prepare(sql) → { run, get, all } }
|
|
169
201
|
* tableName?: string, // default "blamejs_idempotency_keys"; validated via b.safeSql.validateIdentifier
|
|
170
202
|
* init?: boolean, // default true — run CREATE TABLE IF NOT EXISTS at construction
|
|
203
|
+
* hashKeys?: boolean, // default true — store sha3-512 namespace-hash of the key, not the raw key
|
|
204
|
+
* seal?: boolean, // default true — seal headers + body via b.cryptoField when vault is ready
|
|
171
205
|
*
|
|
172
206
|
* @example
|
|
173
|
-
* // single-process daemon, framework's internal sqlite:
|
|
207
|
+
* // single-process daemon, framework's internal sqlite, both defaults on:
|
|
174
208
|
* var b = require("blamejs");
|
|
209
|
+
* await b.vault.init({ dataDir: "/var/lib/myapp" });
|
|
175
210
|
* await b.db.init({ dataDir: "/var/lib/myapp", schema: [] });
|
|
176
211
|
* var store = b.middleware.idempotencyKey.dbStore({ db: b.db });
|
|
177
212
|
* var mw = b.middleware.idempotencyKey({
|
|
@@ -179,16 +214,6 @@ function memoryStore(opts) {
|
|
|
179
214
|
* ttlMs: b.constants.TIME.hours(24),
|
|
180
215
|
* });
|
|
181
216
|
* app.use(mw);
|
|
182
|
-
*
|
|
183
|
-
* @example
|
|
184
|
-
* // multi-process fleet, shared better-sqlite3 instance over WAL:
|
|
185
|
-
* var Database = require("better-sqlite3");
|
|
186
|
-
* var db = new Database("/var/lib/myapp/idempotency.db", { fileMustExist: false });
|
|
187
|
-
* db.pragma("journal_mode = WAL");
|
|
188
|
-
* var store = b.middleware.idempotencyKey.dbStore({
|
|
189
|
-
* db: db,
|
|
190
|
-
* tableName: "request_idempotency",
|
|
191
|
-
* });
|
|
192
217
|
*/
|
|
193
218
|
function dbStore(opts) {
|
|
194
219
|
opts = opts || {};
|
|
@@ -197,12 +222,9 @@ function dbStore(opts) {
|
|
|
197
222
|
"dbStore: opts.db must be a sqlite-shaped database with a `prepare(sql)` method", true);
|
|
198
223
|
}
|
|
199
224
|
var tableNameRaw = opts.tableName !== undefined ? opts.tableName : "blamejs_idempotency_keys";
|
|
200
|
-
// Quote-and-validate
|
|
201
|
-
// validateIdentifier internally
|
|
202
|
-
//
|
|
203
|
-
// double-quoted form. Identifier ALWAYS reaches SQL through the
|
|
204
|
-
// quoted form — defense-in-depth so a future shape-regex bypass
|
|
205
|
-
// can't reach raw concatenation. Per PR #44 review.
|
|
225
|
+
// Quote-and-validate via safeSql.quoteIdentifier — runs
|
|
226
|
+
// validateIdentifier internally + emits the dialect-correct quoted
|
|
227
|
+
// form. Identifier always reaches SQL through the quoted form.
|
|
206
228
|
var qTable;
|
|
207
229
|
try { qTable = safeSql.quoteIdentifier(tableNameRaw, "sqlite"); }
|
|
208
230
|
catch (sqlErr) {
|
|
@@ -211,65 +233,143 @@ function dbStore(opts) {
|
|
|
211
233
|
(sqlErr && sqlErr.message ? sqlErr.message : String(sqlErr)), true);
|
|
212
234
|
}
|
|
213
235
|
var qIndex = safeSql.quoteIdentifier(tableNameRaw + "_expires_idx", "sqlite");
|
|
214
|
-
var doInit
|
|
236
|
+
var doInit = opts.init !== false;
|
|
237
|
+
var hashKeys = opts.hashKeys !== false;
|
|
238
|
+
var sealReq = opts.seal !== false;
|
|
215
239
|
var db = opts.db;
|
|
216
240
|
|
|
241
|
+
// Probe vault readiness with a sentinel seal. If vault.init() hasn't
|
|
242
|
+
// run (test fixture / boot-script / operator simply hasn't wired the
|
|
243
|
+
// posture yet) sealing falls back to plaintext for the lifetime of
|
|
244
|
+
// this dbStore instance and a single audit warning emits so the
|
|
245
|
+
// posture gap is visible in the chain.
|
|
246
|
+
var sealEnabled = false;
|
|
247
|
+
if (sealReq) {
|
|
248
|
+
try {
|
|
249
|
+
vault.seal("__idempotency_seal_probe__");
|
|
250
|
+
sealEnabled = true;
|
|
251
|
+
} catch (_vaultErr) {
|
|
252
|
+
_emitAudit("idempotency.seal_skipped_no_vault",
|
|
253
|
+
{ tableName: tableNameRaw,
|
|
254
|
+
reason: "vault.init() has not run; sealing falls back to plaintext" },
|
|
255
|
+
"warning");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Register the table with cryptoField. registerTable is idempotent
|
|
260
|
+
// — subsequent dbStore() calls with the same tableName re-declare
|
|
261
|
+
// the same sealedFields and no-op.
|
|
262
|
+
if (sealEnabled) {
|
|
263
|
+
cryptoField.registerTable(tableNameRaw, {
|
|
264
|
+
sealedFields: ["headers", "body"],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
217
268
|
if (doInit) {
|
|
218
269
|
db.prepare("CREATE TABLE IF NOT EXISTS " + qTable + " (" +
|
|
219
270
|
"k TEXT PRIMARY KEY, " +
|
|
220
|
-
"
|
|
271
|
+
"fingerprint TEXT NOT NULL, " +
|
|
272
|
+
"status_code INTEGER NOT NULL, " +
|
|
273
|
+
"headers TEXT NOT NULL, " +
|
|
274
|
+
"body TEXT NOT NULL, " +
|
|
221
275
|
"expires_at INTEGER NOT NULL)").run();
|
|
222
276
|
db.prepare("CREATE INDEX IF NOT EXISTS " + qIndex + " ON " +
|
|
223
277
|
qTable + "(expires_at)").run();
|
|
224
278
|
}
|
|
225
279
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
var stmtGet
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
var
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
280
|
+
// Prepared statements. status_code + expires_at stay non-sealed
|
|
281
|
+
// so audit/forensic SELECTs don't have to unseal-everything.
|
|
282
|
+
var stmtGet = db.prepare(
|
|
283
|
+
"SELECT fingerprint, status_code, headers, body, expires_at FROM " +
|
|
284
|
+
qTable + " WHERE k = ?");
|
|
285
|
+
var stmtUpsert = db.prepare(
|
|
286
|
+
"INSERT INTO " + qTable +
|
|
287
|
+
"(k, fingerprint, status_code, headers, body, expires_at) " +
|
|
288
|
+
"VALUES (?, ?, ?, ?, ?, ?) " +
|
|
289
|
+
"ON CONFLICT(k) DO UPDATE SET " +
|
|
290
|
+
" fingerprint = excluded.fingerprint, " +
|
|
291
|
+
" status_code = excluded.status_code, " +
|
|
292
|
+
" headers = excluded.headers, " +
|
|
293
|
+
" body = excluded.body, " +
|
|
294
|
+
" expires_at = excluded.expires_at");
|
|
239
295
|
var stmtDeleteStale = db.prepare("DELETE FROM " + qTable +
|
|
240
296
|
" WHERE k = ? AND expires_at <= ?");
|
|
297
|
+
var stmtDelete = db.prepare("DELETE FROM " + qTable + " WHERE k = ?");
|
|
298
|
+
|
|
299
|
+
function _k(rawKey) {
|
|
300
|
+
if (!hashKeys) return rawKey;
|
|
301
|
+
return bCrypto.namespaceHash("idempotency-key", rawKey);
|
|
302
|
+
}
|
|
241
303
|
|
|
242
304
|
return {
|
|
243
|
-
get: function (
|
|
244
|
-
var row = stmtGet.get(
|
|
305
|
+
get: function (rawKey) {
|
|
306
|
+
var row = stmtGet.get(_k(rawKey));
|
|
245
307
|
if (!row) return null;
|
|
246
308
|
if (row.expires_at < Date.now()) {
|
|
247
|
-
|
|
248
|
-
// upsert that wrote a fresher row isn't clobbered.
|
|
249
|
-
stmtDeleteStale.run(key, row.expires_at);
|
|
309
|
+
stmtDeleteStale.run(_k(rawKey), row.expires_at);
|
|
250
310
|
return null;
|
|
251
311
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
312
|
+
var liveRow = row;
|
|
313
|
+
if (sealEnabled) {
|
|
314
|
+
try { liveRow = cryptoField.unsealRow(tableNameRaw, row); }
|
|
315
|
+
catch (_unsealErr) {
|
|
316
|
+
// Decryption failed (key rotation gap / corrupt envelope).
|
|
317
|
+
// Treat as miss + drop the row so the handler runs fresh
|
|
318
|
+
// and we capture a re-sealable replacement.
|
|
319
|
+
stmtDeleteStale.run(_k(rawKey), row.expires_at);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
var headersObj;
|
|
324
|
+
try {
|
|
325
|
+
headersObj = safeJson.parse(liveRow.headers, { maxBytes: 4 * 1024 * 1024 }); // allow:raw-byte-literal — 4 MiB headers ceiling
|
|
326
|
+
} catch (_jsonErr) {
|
|
327
|
+
// Parse failure has two distinct causes:
|
|
328
|
+
// 1. Genuine corruption (truncated row, encoding mishap) — drop.
|
|
329
|
+
// 2. The row was sealed by a sibling process (vault: prefix
|
|
330
|
+
// present) but THIS process has sealEnabled=false (vault
|
|
331
|
+
// not initialized OR opts.seal=false). The row is valid
|
|
332
|
+
// cross-process state we just can't read locally;
|
|
333
|
+
// DELETING it would clobber another process's cache and
|
|
334
|
+
// turn a hit into a miss with potential side-effect re-
|
|
335
|
+
// execution. Treat as miss + LEAVE the row in place.
|
|
336
|
+
// Per Codex P1 on PR #45.
|
|
337
|
+
var lookedSealed = typeof liveRow.headers === "string" &&
|
|
338
|
+
liveRow.headers.indexOf("vault:") === 0;
|
|
339
|
+
if (!lookedSealed) {
|
|
340
|
+
stmtDeleteStale.run(_k(rawKey), row.expires_at);
|
|
341
|
+
}
|
|
263
342
|
return null;
|
|
264
343
|
}
|
|
344
|
+
return {
|
|
345
|
+
fingerprint: liveRow.fingerprint,
|
|
346
|
+
statusCode: liveRow.status_code,
|
|
347
|
+
headers: headersObj,
|
|
348
|
+
body: liveRow.body,
|
|
349
|
+
};
|
|
265
350
|
},
|
|
266
|
-
set: function (
|
|
267
|
-
|
|
351
|
+
set: function (rawKey, value, ttlMs) {
|
|
352
|
+
var rowOut = {
|
|
353
|
+
k: _k(rawKey),
|
|
354
|
+
fingerprint: value.fingerprint,
|
|
355
|
+
status_code: value.statusCode,
|
|
356
|
+
headers: JSON.stringify(value.headers || {}),
|
|
357
|
+
body: value.body || "",
|
|
358
|
+
expires_at: Date.now() + ttlMs,
|
|
359
|
+
};
|
|
360
|
+
if (sealEnabled) {
|
|
361
|
+
rowOut = cryptoField.sealRow(tableNameRaw, rowOut);
|
|
362
|
+
}
|
|
363
|
+
stmtUpsert.run(
|
|
364
|
+
rowOut.k, rowOut.fingerprint, rowOut.status_code,
|
|
365
|
+
rowOut.headers, rowOut.body, rowOut.expires_at);
|
|
268
366
|
},
|
|
269
|
-
delete: function (
|
|
270
|
-
stmtDelete.run(
|
|
367
|
+
delete: function (rawKey) {
|
|
368
|
+
stmtDelete.run(_k(rawKey));
|
|
271
369
|
},
|
|
272
|
-
_tableName:
|
|
370
|
+
_tableName: tableNameRaw,
|
|
371
|
+
_hashKeys: hashKeys,
|
|
372
|
+
_sealEnabled: sealEnabled,
|
|
273
373
|
};
|
|
274
374
|
}
|
|
275
375
|
|
|
@@ -52,7 +52,7 @@ var defineClass = require("../framework-error").defineClass;
|
|
|
52
52
|
var lazyRequire = require("../lazy-require");
|
|
53
53
|
var validateOpts = require("../validate-opts");
|
|
54
54
|
|
|
55
|
-
var
|
|
55
|
+
var bCrypto = lazyRequire(function () { return require("../crypto"); });
|
|
56
56
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
57
57
|
|
|
58
58
|
var RequireBoundKeyError = defineClass("RequireBoundKeyError", { alwaysPermanent: true });
|
|
@@ -67,7 +67,7 @@ function _parseBearer(req) {
|
|
|
67
67
|
function _timingSafeStringEqual(a, b) {
|
|
68
68
|
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
69
69
|
if (a.length !== b.length) return false;
|
|
70
|
-
return
|
|
70
|
+
return bCrypto().timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
@@ -243,7 +243,7 @@ function create(opts) {
|
|
|
243
243
|
var fpColon = req.peerFingerprint && req.peerFingerprint.colon;
|
|
244
244
|
if (!fpHex && req.peerCert && req.peerCert.raw) {
|
|
245
245
|
try {
|
|
246
|
-
var fp =
|
|
246
|
+
var fp = bCrypto().hashCertFingerprint(req.peerCert.raw);
|
|
247
247
|
fpHex = fp.hex; fpColon = fp.colon;
|
|
248
248
|
} catch (_e) { /* fall through to refused below */ }
|
|
249
249
|
}
|
|
@@ -256,7 +256,7 @@ function create(opts) {
|
|
|
256
256
|
keyId: record.id || null,
|
|
257
257
|
});
|
|
258
258
|
}
|
|
259
|
-
} else if (!
|
|
259
|
+
} else if (!bCrypto().isCertRevoked(req.peerCert.raw, pinned)) {
|
|
260
260
|
// isCertRevoked returns true on MATCH against the deny-list
|
|
261
261
|
// shape; we use it here as a fingerprint-set membership test
|
|
262
262
|
// because it does the same constant-time hex/colon comparison
|
|
@@ -49,7 +49,7 @@ var defineClass = require("../framework-error").defineClass;
|
|
|
49
49
|
var lazyRequire = require("../lazy-require");
|
|
50
50
|
var validateOpts = require("../validate-opts");
|
|
51
51
|
|
|
52
|
-
var
|
|
52
|
+
var bCrypto = lazyRequire(function () { return require("../crypto"); });
|
|
53
53
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
54
54
|
|
|
55
55
|
var RequireMtlsError = defineClass("RequireMtlsError", { alwaysPermanent: true });
|
|
@@ -169,18 +169,18 @@ function create(opts) {
|
|
|
169
169
|
// allow/deny matching.
|
|
170
170
|
var fp;
|
|
171
171
|
try {
|
|
172
|
-
fp =
|
|
172
|
+
fp = bCrypto().hashCertFingerprint(peerCert.raw);
|
|
173
173
|
} catch (e) {
|
|
174
174
|
return _refuse(res, "fingerprint-failed", { error: (e && e.message) || String(e) });
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
if (denyList.length > 0 &&
|
|
177
|
+
if (denyList.length > 0 && bCrypto().isCertRevoked(peerCert.raw, denyList)) {
|
|
178
178
|
return _refuse(res, "fingerprint-on-deny-list", {
|
|
179
179
|
fingerprint: fp.colon,
|
|
180
180
|
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
|
-
if (allowList && allowList.length > 0 && !
|
|
183
|
+
if (allowList && allowList.length > 0 && !bCrypto().isCertRevoked(peerCert.raw, allowList)) {
|
|
184
184
|
return _refuse(res, "fingerprint-not-allowed", {
|
|
185
185
|
fingerprint: fp.colon,
|
|
186
186
|
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
package/lib/migrations.js
CHANGED
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
* down() succeeds.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
var
|
|
41
|
+
var nodePath = require("path");
|
|
42
42
|
var atomicFile = require("./atomic-file");
|
|
43
43
|
var dbSchema = require("./db-schema");
|
|
44
44
|
var lazyRequire = require("./lazy-require");
|
|
45
45
|
var { boot } = require("./log");
|
|
46
46
|
var migrationFiles = require("./migration-files");
|
|
47
47
|
var numericBounds = require("./numeric-bounds");
|
|
48
|
-
var
|
|
48
|
+
var db = lazyRequire(function () { return require("./db"); });
|
|
49
49
|
var validateOpts = require("./validate-opts");
|
|
50
50
|
var { FrameworkError } = require("./framework-error");
|
|
51
51
|
|
|
@@ -200,7 +200,7 @@ function _acquireLock(db, opts) {
|
|
|
200
200
|
function _releaseLock(db, holder) {
|
|
201
201
|
// Only release our own lock — a process whose deploy was killed
|
|
202
202
|
// shouldn't have its lock cleared by an unrelated next deploy unless
|
|
203
|
-
// the operator explicitly used the staleAfterMs
|
|
203
|
+
// the operator explicitly used the staleAfterMs nodePath.
|
|
204
204
|
try {
|
|
205
205
|
db.prepare(
|
|
206
206
|
"DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
|
|
@@ -224,7 +224,7 @@ function _resolveDb(opts) {
|
|
|
224
224
|
if (opts && opts.db && typeof opts.db.prepare === "function") return opts.db;
|
|
225
225
|
// Fall back to the framework's singleton db when one isn't passed —
|
|
226
226
|
// operator-side wiring usually does `b.migrations.create({ dir })`.
|
|
227
|
-
var d =
|
|
227
|
+
var d = db();
|
|
228
228
|
if (typeof d.prepare !== "function") {
|
|
229
229
|
throw new MigrationError("migrations/no-db",
|
|
230
230
|
"no db handle: pass opts.db or initialize b.db before create()",
|
|
@@ -234,7 +234,7 @@ function _resolveDb(opts) {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
function _loadMigration(file, dir) {
|
|
237
|
-
var fullPath =
|
|
237
|
+
var fullPath = nodePath.join(dir, file);
|
|
238
238
|
// Drop the require cache for this path before loading so a test that
|
|
239
239
|
// changes a migration file between calls picks up the new content.
|
|
240
240
|
// Production deployments would always restart the process, but this
|