@blamejs/core 0.16.28 → 0.16.29
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/backup/bundle.js +13 -3
- package/lib/backup/index.js +16 -5
- package/lib/backup/manifest.js +10 -5
- package/lib/middleware/require-bound-key.js +1 -1
- package/lib/restore-bundle.js +10 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.16.x
|
|
10
10
|
|
|
11
|
+
- v0.16.29 (2026-07-13) — **Reject a backup manifest's absolute, drive-letter, or NTFS-stream path at validation and resolve every path through the framework path-safety primitive on both backup and restore, and stop a bound-key auth middleware from hanging when a required peer certificate is absent.** Two defects surfaced while covering the backup/restore path handling and the bound-key auth middleware. A backup manifest's file paths were checked only for a leading separator and .., so a Windows drive-letter path such as C:\Windows\evil, or a colon-bearing NTFS alternate-data-stream marker such as db.enc:evil, passed b.backupManifest.validate, and the backup and restore steps joined the caller- or manifest-declared relativePath and encryptedPath directly, without the framework's path-safety primitive -- so a path built from untrusted input could read a file outside dataDir on backup, or aim a restored file (or a blob read) outside the staging directory on restore. validate now rejects a colon anywhere (both the drive-letter prefix and the alternate-data-stream marker) alongside .. and a leading separator, and both b.backupBundle.create and b.restoreBundle resolve every manifest-declared path through b.safePath (which refuses traversal, absolute, drive-letter, UNC, NTFS alternate-data-stream, and reserved-name paths), so each sink is contained even if a path slips past the first-line check. Separately, b.middleware.requireBoundKey dereferenced req.peerCert.raw in its peer-certificate pinning branch when a fingerprint had been pre-attached by upstream mTLS but the certificate object was absent, throwing an uncaught TypeError that rejected the middleware promise -- the request hung with no response instead of a clean fail-closed denial; the branch now guards the certificate and denies. **Fixed:** *b.middleware.requireBoundKey fails closed instead of hanging when a required peer certificate is absent* — In the peer-certificate pinning branch, the middleware read req.peerCert.raw without checking that req.peerCert was present. When an upstream mTLS layer had pre-attached a peer fingerprint (req.peerFingerprint) but no certificate object, that read threw an uncaught TypeError, which rejected the middleware's promise -- the request received no response and hung, rather than a clean fail-closed 401/403. The branch now checks for the certificate before dereferencing it and denies when it is missing, so a peer-cert-pinned key without a usable certificate is refused, not stalled. **Security:** *Backup and restore resolve every filesystem path through b.safePath, and b.backupManifest.validate rejects an absolute, drive-letter, or NTFS-stream path* — A backup manifest's per-file relativePath and encryptedPath were validated only against .. and a leading / or \, so a Windows drive-absolute path (C:\...) or an NTFS alternate-data-stream marker (db.enc:evil) passed b.backupManifest.validate, and the backup, restore, and storage-adapter steps built their filesystem paths with a plain path join of the caller- or manifest-supplied values -- meaning a path built from untrusted input could read a file outside dataDir on backup, aim a decrypted-file write or an encrypted-blob read outside the intended directory on restore, or escape the storage root through the filesystem adapter. validate() now rejects a colon anywhere (covering both the drive-letter prefix and the alternate-data-stream marker) alongside .. and a leading separator, and every filesystem sink -- b.backupBundle.create (the source read), b.restoreBundle (the encrypted-blob read and the restore destination), and the b.backup.bundleAdapterStorage.fsAdapter key resolver -- resolves its path through b.safePath, which refuses traversal, absolute, drive-letter, UNC, NTFS alternate-data-stream, and Windows reserved-name components and contains the result under its base. (The object-store storage adapter builds an object key, not a filesystem path, and keeps its traversal/NUL check -- a colon is a legal object-key character.) Legitimate relative paths back up and restore unchanged.
|
|
12
|
+
|
|
11
13
|
- v0.16.28 (2026-07-13) — **Fix a Sieve filter bypass where an explicit :comparator silently disabled a header/address/envelope test, plus a JSONPath normalized-path round-trip fault and a rejected daemon cwd option.** Three defects surfaced while covering the Sieve interpreter, JSONPath engine, and daemon supervisor. In b.mail.sieve, an explicit :comparator on a header, address, or envelope test silently disabled the whole test: the Sieve parser did not bind the comparator name to its tag, so the comparator string was consumed as the first positional argument (the header/address name) and every real argument shifted by one -- the test looked up a non-existent header, never matched, and the message fell through to implicit keep, so a discard / fileinto / redirect that should have fired instead delivered the message. The parser now binds :comparator to its tag (RFC 5228 §2.7.3), so the comparator is applied and the arguments stay aligned. b.jsonPath.paths emitted a normalized path containing raw control characters (only ' and \ were escaped), which the query parser then rejected, so a path returned by paths() did not round-trip through query(); control characters are now escaped per RFC 9535 §2.7. And b.daemon.start rejected its own documented cwd option with an unknown-option error; cwd is now accepted and forwarded as the detached child's working directory. **Fixed:** *b.jsonPath.paths emits a normalized path that round-trips through b.jsonPath.query* — A normalized path returned by paths() escaped only the single-quote and backslash characters inside a member name, leaving raw control characters (newline, tab, and the rest of U+0000 through U+001F) in the output. The query parser rejects an unescaped control character in a name selector, so such a path could not be fed back into query(). Control characters in a normalized-path name are now escaped as their short form (\b \t \n \f \r) or \uXXXX, per RFC 9535 §2.7, so paths() output round-trips. · *b.daemon.start accepts and applies the documented cwd option* — The start() options validator did not list cwd among the accepted options, so passing the documented cwd (the working directory for the detached child) was rejected with a daemon/bad-opts unknown-option error and the feature was unusable. cwd is now an accepted optional string and is forwarded to the detached child spawn as its working directory. **Security:** *b.mail.sieve applies an explicit :comparator instead of silently disabling the test* — A Sieve script that used an explicit :comparator on a header, address, or envelope test -- for example header :comparator "i;octet" :is "Subject" "..." -- silently stopped filtering. The parser did not attach the comparator's value string to the :comparator tag, so it was consumed as the first positional argument (the header or address name) and shifted the real name and key list by one position. The test then queried a non-existent header, never matched, and evaluation fell through to implicit keep, so a discard, fileinto, or reject the operator intended was not applied and the message was delivered anyway -- a filter bypass for any rule with an explicit comparator. The parser now binds :comparator to its tag per RFC 5228 §2.7.3, so the chosen comparator (i;octet exact vs the default i;ascii-casemap case-insensitive) is applied and the positional arguments stay aligned; a :comparator with no following comparator-name string is refused at parse time.
|
|
12
14
|
|
|
13
15
|
- v0.16.27 (2026-07-12) — **Fix b.pubsub pattern subscriptions silently dropping every message for a single-wildcard pattern such as orders.*.created.** b.pubsub.subscribePattern with a * wildcard between dotted segments never matched any channel, so a pattern subscriber received nothing. The documented flagship pattern orders.*.created silently dropped orders.eu.created, and the same held for a.*.c, a.*.b.*.c, and any pattern whose wildcard sits between literal segments -- the source @example itself did not work. The segment matcher required a literal that follows a * to fit entirely before the next dot, but a trailing literal like .created contains a dot and legitimately begins before that boundary, so the match was rejected. The matcher now lets a post-wildcard literal begin at or before the next dot and matches it verbatim, while a single-segment * still never spans a dot -- so orders.*.created matches orders.eu.created but not orders.eu.fr.created or orders.created, and a pattern subscriber never receives a message from an extra path segment. Surfaced while covering the pub/sub matcher's branches. **Fixed:** *b.pubsub.subscribePattern matches a single-segment wildcard between literal segments* — A pattern with a * between dotted segments (the documented orders.*.created, or any a.*.c / a.*.b.*.c) matched no channel at all, so the pattern subscriber's handler never fired. The matcher rejected the literal that follows a wildcard unless it fit entirely before the next dot, but a trailing literal such as .created contains a dot and starts before that boundary, so a legitimate match was dropped. The literal after a wildcard is now allowed to begin at or before the next dot and is matched verbatim; the wildcard still matches exactly one non-dot segment, so orders.*.created matches orders.eu.created but not orders.eu.fr.created (two segments) or orders.created (none) -- a pattern subscription never widens to an extra path segment. Behaviour is now consistent with the documented example.
|
package/lib/backup/bundle.js
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
var nodeFs = require("node:fs");
|
|
52
52
|
var nodePath = require("node:path");
|
|
53
53
|
var atomicFile = require("../atomic-file");
|
|
54
|
+
var safePath = require("../safe-path");
|
|
54
55
|
var bCrypto = require("./crypto");
|
|
55
56
|
var backupManifest = require("./manifest");
|
|
56
57
|
var validateOpts = require("../validate-opts");
|
|
@@ -122,11 +123,20 @@ async function create(opts) {
|
|
|
122
123
|
throw new BackupBundleError("backup-bundle/bad-include",
|
|
123
124
|
"create: files[" + i + "] requires { relativePath: string }");
|
|
124
125
|
}
|
|
125
|
-
if (entry.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(entry.relativePath)
|
|
126
|
+
if (entry.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(entry.relativePath) ||
|
|
127
|
+
entry.relativePath.indexOf(":") !== -1) {
|
|
128
|
+
// A colon anywhere is refused alongside '..' and a leading separator: it
|
|
129
|
+
// covers both a Windows drive prefix (C:\..., absolute) and an NTFS
|
|
130
|
+
// alternate-data-stream marker (db.enc:evil). This friendly pre-screen
|
|
131
|
+
// mirrors b.backupManifest.validate; safePath.resolve below is the
|
|
132
|
+
// authoritative sink for the residual classes (UNC, reserved name, etc.).
|
|
126
133
|
throw new BackupBundleError("backup-bundle/bad-include",
|
|
127
|
-
"create: files[" + i + "].relativePath must be a relative path (got '" + entry.relativePath + "')");
|
|
134
|
+
"create: files[" + i + "].relativePath must be a relative path without '..', a leading separator, or a colon (got '" + entry.relativePath + "')");
|
|
128
135
|
}
|
|
129
|
-
|
|
136
|
+
// Resolve the source path THROUGH dataDir with safePath so a relativePath
|
|
137
|
+
// built from untrusted input can't read a file outside dataDir even if it
|
|
138
|
+
// slips the first-line check (UNC, reserved name, encoded separator, bidi).
|
|
139
|
+
var srcPath = safePath.resolve(dataDir, entry.relativePath);
|
|
130
140
|
if (!nodeFs.existsSync(srcPath)) {
|
|
131
141
|
if (entry.required) {
|
|
132
142
|
throw new BackupBundleError("backup-bundle/missing-required",
|
package/lib/backup/index.js
CHANGED
|
@@ -55,6 +55,7 @@ var os = require("node:os");
|
|
|
55
55
|
var nodePath = require("node:path");
|
|
56
56
|
var bCrypto = require("../crypto");
|
|
57
57
|
var atomicFile = require("../atomic-file");
|
|
58
|
+
var safePath = require("../safe-path");
|
|
58
59
|
var C = require("../constants");
|
|
59
60
|
var backupBundle = require("./bundle");
|
|
60
61
|
var frameworkFiles = require("../framework-files");
|
|
@@ -2197,13 +2198,23 @@ bundleAdapterStorage.fsAdapter = function (fsOpts) {
|
|
|
2197
2198
|
atomicFile.ensureDir(root);
|
|
2198
2199
|
|
|
2199
2200
|
function _keyPath(key) {
|
|
2200
|
-
//
|
|
2201
|
-
//
|
|
2202
|
-
|
|
2201
|
+
// Gate the key under Windows path semantics REGARDLESS of the host, then
|
|
2202
|
+
// resolve the real on-disk path with the host's semantics. An fsAdapter
|
|
2203
|
+
// store is portable — a bundle written on one OS is often read on another —
|
|
2204
|
+
// and win32 splits on both `/` and `\`, so a key like `..\evil` (a harmless
|
|
2205
|
+
// literal filename under POSIX, but a climb out of root when the same key is
|
|
2206
|
+
// later interpreted on Windows) is refused everywhere. The gate is a strict
|
|
2207
|
+
// superset of the host's, also refusing an absolute / drive-letter / UNC
|
|
2208
|
+
// prefix, an NTFS alternate-data-stream marker, a Windows reserved name, and
|
|
2209
|
+
// NUL / control bytes; defense in depth even though the storage layer also
|
|
2210
|
+
// checks. A hand-rolled '..' + NUL substring check (the prior shape) missed
|
|
2211
|
+
// the drive / ADS / reserved-name class the shared primitive handles, and a
|
|
2212
|
+
// host-only resolve missed the cross-platform backslash-traversal class.
|
|
2213
|
+
if (safePath.resolveOrNull(root, key, { platform: "win32" }) === null) {
|
|
2203
2214
|
throw new BackupError("backup/bad-key",
|
|
2204
|
-
"fsAdapter: key contains invalid characters: " + JSON.stringify(key));
|
|
2215
|
+
"fsAdapter: key contains invalid or unsafe path characters: " + JSON.stringify(key));
|
|
2205
2216
|
}
|
|
2206
|
-
return
|
|
2217
|
+
return safePath.resolve(root, key);
|
|
2207
2218
|
}
|
|
2208
2219
|
|
|
2209
2220
|
return {
|
package/lib/backup/manifest.js
CHANGED
|
@@ -121,14 +121,19 @@ function _validateFileEntry(f, idx, errors) {
|
|
|
121
121
|
}
|
|
122
122
|
if (typeof f.relativePath !== "string" || f.relativePath.length === 0) {
|
|
123
123
|
errors.push("files[" + idx + "].relativePath: required non-empty string");
|
|
124
|
-
} else if (f.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(f.relativePath)) {
|
|
125
|
-
// No traversal — when restored, relativePath joins under dataDir
|
|
126
|
-
|
|
124
|
+
} else if (f.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(f.relativePath) || f.relativePath.indexOf(":") !== -1) {
|
|
125
|
+
// No traversal — when restored, relativePath joins under dataDir. A colon
|
|
126
|
+
// anywhere is refused alongside '..' and a leading separator: it covers
|
|
127
|
+
// both a Windows drive prefix (C:\..., absolute — path.resolve would honor
|
|
128
|
+
// it and escape dataDir) and an NTFS alternate-data-stream marker
|
|
129
|
+
// (db.enc:evil), so validate() fails closed before restore, matching the
|
|
130
|
+
// safePath sink b.restoreBundle resolves both paths through.
|
|
131
|
+
errors.push("files[" + idx + "].relativePath: must be a relative path without '..', a leading separator, or a colon (drive letter / NTFS data-stream marker)");
|
|
127
132
|
}
|
|
128
133
|
if (typeof f.encryptedPath !== "string" || f.encryptedPath.length === 0) {
|
|
129
134
|
errors.push("files[" + idx + "].encryptedPath: required non-empty string");
|
|
130
|
-
} else if (f.encryptedPath.indexOf("..") !== -1 || /^[/\\]/.test(f.encryptedPath)) {
|
|
131
|
-
errors.push("files[" + idx + "].encryptedPath: must be a relative path without '..'
|
|
135
|
+
} else if (f.encryptedPath.indexOf("..") !== -1 || /^[/\\]/.test(f.encryptedPath) || f.encryptedPath.indexOf(":") !== -1) {
|
|
136
|
+
errors.push("files[" + idx + "].encryptedPath: must be a relative path without '..', a leading separator, or a colon (drive letter / NTFS data-stream marker)");
|
|
132
137
|
}
|
|
133
138
|
if (typeof f.size !== "number" || !Number.isInteger(f.size) || f.size < 0) {
|
|
134
139
|
errors.push("files[" + idx + "].size: required non-negative integer");
|
|
@@ -289,7 +289,7 @@ function create(opts) {
|
|
|
289
289
|
keyId: record.id || null,
|
|
290
290
|
});
|
|
291
291
|
}
|
|
292
|
-
} else if (!bCrypto().isCertRevoked(req.peerCert.raw, pinned)) {
|
|
292
|
+
} else if (!(req.peerCert && req.peerCert.raw) || !bCrypto().isCertRevoked(req.peerCert.raw, pinned)) {
|
|
293
293
|
// isCertRevoked returns true on MATCH against the deny-list
|
|
294
294
|
// shape; we use it here as a fingerprint-set membership test
|
|
295
295
|
// because it does the same constant-time hex/colon comparison
|
package/lib/restore-bundle.js
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
var nodeFs = require("node:fs");
|
|
51
51
|
var nodePath = require("node:path");
|
|
52
52
|
var atomicFile = require("./atomic-file");
|
|
53
|
+
var safePath = require("./safe-path");
|
|
53
54
|
var C = require("./constants");
|
|
54
55
|
var backupCrypto = require("./backup/crypto");
|
|
55
56
|
var backupManifest = require("./backup/manifest");
|
|
@@ -226,7 +227,11 @@ async function extract(opts) {
|
|
|
226
227
|
continue;
|
|
227
228
|
}
|
|
228
229
|
|
|
229
|
-
|
|
230
|
+
// Resolve the manifest-declared encrypted-blob path THROUGH the base with
|
|
231
|
+
// safePath -- a tampered manifest could otherwise carry a traversal /
|
|
232
|
+
// absolute / drive-letter / NTFS-ADS encryptedPath and read a file outside
|
|
233
|
+
// the bundle. manifest.validate is a first-line check; this is the sink.
|
|
234
|
+
var blobPath = safePath.resolve(bundleDir, entry.encryptedPath);
|
|
230
235
|
// Cap the read to the manifest's declared encryptedSize so an oversize-on-
|
|
231
236
|
// disk blob is refused BEFORE it is read into memory (was: read fully, then
|
|
232
237
|
// compare → an OOM window for a huge swapped blob). A valid blob is exactly
|
|
@@ -282,7 +287,10 @@ async function extract(opts) {
|
|
|
282
287
|
" — bundle is corrupted or manifest tampered");
|
|
283
288
|
}
|
|
284
289
|
|
|
285
|
-
|
|
290
|
+
// Resolve the restore destination THROUGH the staging base with safePath
|
|
291
|
+
// so a tampered relativePath (traversal / absolute / drive-letter / ADS)
|
|
292
|
+
// cannot escape stagingDir when the decrypted file is written.
|
|
293
|
+
var destPath = safePath.resolve(stagingDir, entry.relativePath);
|
|
286
294
|
atomicFile.ensureDir(nodePath.dirname(destPath));
|
|
287
295
|
atomicFile.writeSync(destPath, plaintext, { fileMode: 0o600 });
|
|
288
296
|
|
package/package.json
CHANGED
package/sbom.cdx.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:1f6a9458-410d-4d84-986b-01af22e84765",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-13T15:57:31.414Z",
|
|
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.16.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.16.29",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
25
|
+
"version": "0.16.29",
|
|
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.16.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.16.29",
|
|
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.16.
|
|
57
|
+
"ref": "@blamejs/core@0.16.29",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|