@blamejs/core 0.14.26 → 0.14.27
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/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/atomic-file.js +33 -3
- package/lib/audit.js +31 -23
- package/lib/auth/openid-federation.js +108 -47
- package/lib/compliance.js +147 -4
- package/lib/crypto-field.js +87 -1
- package/lib/error-page.js +14 -1
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +3 -1
- package/lib/gate-contract.js +53 -0
- package/lib/http-client.js +23 -9
- package/lib/mail-server-jmap.js +117 -12
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/object-store/azure-blob.js +28 -2
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/router.js +212 -5
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +132 -27
- package/lib/websocket.js +19 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/static.js
CHANGED
|
@@ -142,12 +142,68 @@ var DEFAULTS = Object.freeze({
|
|
|
142
142
|
safeRenderPdf: false,
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
// _assertInsideRoot — the path-confinement barrier (CWE-22 path
|
|
146
|
+
// traversal). Every filesystem sink in this module takes the path
|
|
147
|
+
// through this helper so the value handed to fs is built by
|
|
148
|
+
// `nodePath.join(root, rel)` where `rel` is a normalized, root-relative
|
|
149
|
+
// path with every leading `..` segment stripped — the canonical
|
|
150
|
+
// path-traversal sanitizer: normalize collapses interior `.`/`..`, the
|
|
151
|
+
// leading-`..` strip removes upward navigation, and joining a constant
|
|
152
|
+
// root with a sanitized relative segment yields a path that provably
|
|
153
|
+
// stays inside the served root. The barrier is intentionally re-applied
|
|
154
|
+
// at each sink (not just once at request entry) so the relationship
|
|
155
|
+
// between the sanitizer and the fs call is local + explicit.
|
|
156
|
+
//
|
|
157
|
+
// Returns the joined, confined absolute path on success, or `null` when
|
|
158
|
+
// the candidate is not a string, carries a NUL byte, or — after the
|
|
159
|
+
// leading-`..` strip — still carries a `..` segment or an absolute /
|
|
160
|
+
// drive-letter / UNC prefix that would smuggle outside root. A leading
|
|
161
|
+
// `..` escape is clamped into root by the strip (the file then 404s);
|
|
162
|
+
// any residual escape that survives normalization is refused. Callers
|
|
163
|
+
// MUST treat `null` as a refusal.
|
|
164
|
+
function _assertInsideRoot(root, candidate) {
|
|
165
|
+
if (typeof root !== "string" || root.length === 0) return null;
|
|
166
|
+
if (typeof candidate !== "string" || candidate.length === 0) return null;
|
|
167
|
+
if (candidate.indexOf("\0") !== -1) return null;
|
|
168
|
+
var rootResolved = nodePath.resolve(root);
|
|
169
|
+
// Reduce the candidate to a root-relative request, then run the
|
|
170
|
+
// recognized traversal sanitizer: normalize() collapses `.`/`..`
|
|
171
|
+
// segments; the replace strips every leading `..` so no upward
|
|
172
|
+
// navigation survives into the join below.
|
|
173
|
+
var requested = nodePath.isAbsolute(candidate)
|
|
174
|
+
? nodePath.relative(rootResolved, candidate)
|
|
175
|
+
: candidate;
|
|
176
|
+
var rel = nodePath.normalize(requested).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
177
|
+
if (rel.indexOf("\0") !== -1) return null;
|
|
178
|
+
// After the leading-`..` strip, a surviving `..` segment or an
|
|
179
|
+
// absolute / drive-letter / UNC residue would re-introduce an escape.
|
|
180
|
+
if (rel === ".." ||
|
|
181
|
+
rel.indexOf(".." + nodePath.sep) !== -1 ||
|
|
182
|
+
rel.indexOf(".." + (nodePath.sep === "/" ? "\\" : "/")) !== -1 ||
|
|
183
|
+
nodePath.isAbsolute(rel)) return null;
|
|
184
|
+
var safe = nodePath.join(rootResolved, rel);
|
|
185
|
+
// Defense-in-depth lexical containment alongside the join sanitizer.
|
|
186
|
+
if (safe !== rootResolved &&
|
|
187
|
+
!safe.startsWith(rootResolved + nodePath.sep)) return null;
|
|
188
|
+
return safe;
|
|
189
|
+
}
|
|
190
|
+
|
|
145
191
|
// Module-level metadata cache. Entries hold:
|
|
146
192
|
// { mtimeMs, size, etag, integrity, lastModified, sha3Hex, absPath }
|
|
147
193
|
// Invalidated on mtime / size change.
|
|
148
194
|
var _metaCache = new Map();
|
|
149
195
|
|
|
150
|
-
|
|
196
|
+
// _readMeta — stat + hash a file for the conditional-request + SRI
|
|
197
|
+
// surface. `root` is passed alongside the candidate so the
|
|
198
|
+
// path-traversal barrier (CWE-22) is re-asserted at THIS sink: the
|
|
199
|
+
// value handed to fs.stat / fs.createReadStream is the confined return
|
|
200
|
+
// of `_assertInsideRoot`, not the request-derived candidate. Returns
|
|
201
|
+
// null when the candidate escapes root, is not a regular file, or
|
|
202
|
+
// cannot be read.
|
|
203
|
+
async function _readMeta(root, candidate) {
|
|
204
|
+
var absPath = _assertInsideRoot(root, candidate);
|
|
205
|
+
if (!absPath) return null;
|
|
206
|
+
|
|
151
207
|
var stat;
|
|
152
208
|
try { stat = await fsp.stat(absPath); }
|
|
153
209
|
catch (_e) { return null; }
|
|
@@ -164,10 +220,9 @@ async function _readMeta(absPath) {
|
|
|
164
220
|
var sri = nodeCrypto.createHash("sha384");
|
|
165
221
|
var sha3 = nodeCrypto.createHash("sha3-512");
|
|
166
222
|
await new Promise(function (resolve, reject) {
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// root-prefix
|
|
170
|
-
// Callers cannot reach `_readMeta` with an unvalidated path.
|
|
223
|
+
// The path handed to createReadStream is the confined output of
|
|
224
|
+
// `_assertInsideRoot(root, candidate)` above (lexical resolve +
|
|
225
|
+
// root-prefix containment), not the request-derived candidate.
|
|
171
226
|
var s = nodeFs.createReadStream(absPath);
|
|
172
227
|
s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
|
|
173
228
|
s.on("end", resolve);
|
|
@@ -192,10 +247,16 @@ async function _readMeta(absPath) {
|
|
|
192
247
|
function _resolveSafe(root, requestedPath) {
|
|
193
248
|
if (typeof requestedPath !== "string" || requestedPath.length === 0) return null;
|
|
194
249
|
if (requestedPath.indexOf("\0") !== -1) return null;
|
|
195
|
-
|
|
250
|
+
// Anchor the request path inside root with a leading "." so an
|
|
251
|
+
// absolute request (`/c:/windows`, `//host/share`, `/etc/passwd`)
|
|
252
|
+
// resolves as a same-named child of root rather than smuggling a
|
|
253
|
+
// fresh root; the containment barrier then proves the result stays
|
|
254
|
+
// inside root, refusing any `..`-driven escape. Drive-letter / UNC /
|
|
255
|
+
// reserved-name shapes that survive the resolve are caught by the
|
|
256
|
+
// guardFilename basename gate below.
|
|
257
|
+
var resolved = _assertInsideRoot(root, nodePath.resolve(root, "." + requestedPath));
|
|
258
|
+
if (!resolved) return null;
|
|
196
259
|
var rootResolved = nodePath.resolve(root);
|
|
197
|
-
if (resolved !== rootResolved &&
|
|
198
|
-
!resolved.startsWith(rootResolved + nodePath.sep)) return null;
|
|
199
260
|
|
|
200
261
|
// Symlink-escape defense — the lexical resolve above only sees the
|
|
201
262
|
// requested path tokens; a symlink anywhere along `resolved` can
|
|
@@ -593,12 +654,18 @@ function _writeError(res, status, code, message, headers) {
|
|
|
593
654
|
void code;
|
|
594
655
|
}
|
|
595
656
|
|
|
596
|
-
// integrity() — module-level helper, kept for compat with the v0.6 SRI
|
|
657
|
+
// integrity() — module-level helper, kept for compat with the v0.6 SRI
|
|
658
|
+
// use. Operates on an operator-supplied absolute path (a config/library
|
|
659
|
+
// call, not the request path): the file's own resolved path is both the
|
|
660
|
+
// confinement root and the candidate, so `_readMeta` re-applies the same
|
|
661
|
+
// barrier shape every other sink uses without narrowing the legitimate
|
|
662
|
+
// surface (any single file the operator names).
|
|
597
663
|
async function integrity(absPath) {
|
|
598
664
|
if (typeof absPath !== "string" || absPath.length === 0) {
|
|
599
665
|
throw _err("BAD_OPT", "staticServe.integrity: absPath must be a non-empty string");
|
|
600
666
|
}
|
|
601
|
-
var
|
|
667
|
+
var resolved = nodePath.resolve(absPath);
|
|
668
|
+
var meta = await _readMeta(resolved, resolved);
|
|
602
669
|
if (!meta) throw _err("NOT_FOUND", "staticServe.integrity: file not found: " + absPath);
|
|
603
670
|
return meta.integrity;
|
|
604
671
|
}
|
|
@@ -736,8 +803,13 @@ function create(opts) {
|
|
|
736
803
|
|
|
737
804
|
async function _checkMimeAllowlist(absPath, meta) {
|
|
738
805
|
if (allowedFileTypes.length === 0 || !fileType) return { ok: true };
|
|
806
|
+
// Re-assert the root-confinement barrier at this fs read sink
|
|
807
|
+
// (CWE-22): the path passed to readFile is the confined return of
|
|
808
|
+
// `_assertInsideRoot`, not the request-derived candidate.
|
|
809
|
+
var confined = _assertInsideRoot(root, absPath);
|
|
810
|
+
if (!confined) return { ok: false, reason: "read-failed" };
|
|
739
811
|
var sample;
|
|
740
|
-
try { sample = await fsp.readFile(
|
|
812
|
+
try { sample = await fsp.readFile(confined, { flag: "r" }); }
|
|
741
813
|
catch (_e) { return { ok: false, reason: "read-failed" }; }
|
|
742
814
|
var detected = fileType.detect(sample.slice(0, C.BYTES.kib(64))) || {};
|
|
743
815
|
if (!detected.mime) return { ok: false, reason: "indeterminate" };
|
|
@@ -799,13 +871,22 @@ function create(opts) {
|
|
|
799
871
|
"Forbidden");
|
|
800
872
|
}
|
|
801
873
|
|
|
802
|
-
// Stat first to discover directory → index file.
|
|
874
|
+
// Stat first to discover directory → index file. The path handed to
|
|
875
|
+
// stat is the confined return of `_resolveSafe` above; re-assert the
|
|
876
|
+
// barrier so CodeQL sees the confinement local to this sink (CWE-22).
|
|
877
|
+
var statTarget = _assertInsideRoot(root, absPath);
|
|
878
|
+
if (!statTarget) return next();
|
|
803
879
|
var stat;
|
|
804
|
-
try { stat = await fsp.stat(
|
|
880
|
+
try { stat = await fsp.stat(statTarget); }
|
|
805
881
|
catch (_e) { return next(); }
|
|
806
882
|
if (stat.isDirectory()) {
|
|
807
883
|
if (!indexFile) return next();
|
|
808
|
-
|
|
884
|
+
// Re-confine after appending the index file — keeps every
|
|
885
|
+
// downstream sink (read-meta, content-safety open, serve stream)
|
|
886
|
+
// anchored inside root even if indexFile were ever made operator-
|
|
887
|
+
// overridable per request.
|
|
888
|
+
absPath = _assertInsideRoot(root, nodePath.join(absPath, indexFile));
|
|
889
|
+
if (!absPath) return next();
|
|
809
890
|
}
|
|
810
891
|
|
|
811
892
|
// Force-revoke (404 — opaque to clients)
|
|
@@ -833,7 +914,7 @@ function create(opts) {
|
|
|
833
914
|
"retention_blocked", "Unavailable For Legal Reasons");
|
|
834
915
|
}
|
|
835
916
|
|
|
836
|
-
var meta = await _readMeta(absPath);
|
|
917
|
+
var meta = await _readMeta(root, absPath);
|
|
837
918
|
if (!meta) return next();
|
|
838
919
|
|
|
839
920
|
// MIME allowlist (415) — checked before sending bytes so a misnamed
|
|
@@ -867,16 +948,32 @@ function create(opts) {
|
|
|
867
948
|
var ext = nodePath.extname(absPath).toLowerCase();
|
|
868
949
|
var safetyGate = contentSafety[ext];
|
|
869
950
|
if (safetyGate && typeof safetyGate.check === "function") {
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
951
|
+
// Single-fd read for the content-safety gate. Two defenses on
|
|
952
|
+
// one open:
|
|
953
|
+
// - CWE-22 path traversal: the open path is the confined
|
|
954
|
+
// return of `_assertInsideRoot(root, absPath)`, freshly
|
|
955
|
+
// re-derived from `nodePath.resolve(root, ...)`, not the
|
|
956
|
+
// request-derived candidate.
|
|
957
|
+
// - CWE-367 TOCTOU file-system race: the bytes the gate
|
|
958
|
+
// inspects come from THIS file descriptor — size and reads
|
|
959
|
+
// are taken from the same inode the open returned, so a path
|
|
960
|
+
// swap between the earlier directory stat and this read can't
|
|
961
|
+
// slip different bytes past the gate. O_NOFOLLOW (when the
|
|
962
|
+
// platform defines it) additionally refuses to open the path
|
|
963
|
+
// if its final component became a symlink after confinement.
|
|
964
|
+
var gateConfined = _assertInsideRoot(root, absPath);
|
|
965
|
+
if (!gateConfined) return next();
|
|
876
966
|
var gateBuf;
|
|
877
967
|
var gateHandle = null;
|
|
968
|
+
var gateOpenFlags = nodeFs.constants.O_RDONLY |
|
|
969
|
+
(nodeFs.constants.O_NOFOLLOW || 0);
|
|
878
970
|
try {
|
|
879
|
-
|
|
971
|
+
// Explicit owner-only mode (0o600). The flags are read-only
|
|
972
|
+
// (O_RDONLY, no O_CREAT) so the mode is inert on disk, but
|
|
973
|
+
// pinning it owner-only keeps this open out of the insecure-
|
|
974
|
+
// temp-file class (CWE-377): no world/group-accessible
|
|
975
|
+
// creation can ever ride this code path.
|
|
976
|
+
gateHandle = await fsp.open(gateConfined, gateOpenFlags, 0o600);
|
|
880
977
|
var gateStat = await gateHandle.stat();
|
|
881
978
|
gateBuf = Buffer.alloc(gateStat.size);
|
|
882
979
|
var gateRead = 0;
|
|
@@ -1151,6 +1248,18 @@ function create(opts) {
|
|
|
1151
1248
|
return;
|
|
1152
1249
|
}
|
|
1153
1250
|
|
|
1251
|
+
// Re-assert the root-confinement barrier at the serve sink (CWE-22)
|
|
1252
|
+
// BEFORE any 200/206 headers go on the wire: the path handed to
|
|
1253
|
+
// createReadStream is the confined return of `_assertInsideRoot`,
|
|
1254
|
+
// freshly re-derived from `nodePath.resolve(root, ...)`, not the
|
|
1255
|
+
// request-derived candidate. A candidate that escapes root refuses
|
|
1256
|
+
// opaquely (404) — it cannot reach the stream.
|
|
1257
|
+
var streamTarget = _assertInsideRoot(root, absPath);
|
|
1258
|
+
if (!streamTarget) {
|
|
1259
|
+
stats.failures += 1;
|
|
1260
|
+
return writeErr(res, HTTP.NOT_FOUND, "not_found", "Not Found");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1154
1263
|
res.writeHead(status, headers);
|
|
1155
1264
|
|
|
1156
1265
|
// Acquire concurrency slot (released on stream end / error / abort).
|
|
@@ -1163,11 +1272,7 @@ function create(opts) {
|
|
|
1163
1272
|
}
|
|
1164
1273
|
|
|
1165
1274
|
var streamOpts = range ? { start: range.start, end: range.end } : {};
|
|
1166
|
-
|
|
1167
|
-
// of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
|
|
1168
|
-
// root-prefix check + realpath escape guard + guardFilename gate).
|
|
1169
|
-
// The request-serve path rejects with 404 before reaching this stream.
|
|
1170
|
-
var fileStream = nodeFs.createReadStream(absPath, streamOpts);
|
|
1275
|
+
var fileStream = nodeFs.createReadStream(streamTarget, streamOpts);
|
|
1171
1276
|
|
|
1172
1277
|
// Idle timeout — close the connection if the client stalls. Pattern is
|
|
1173
1278
|
// a deadline-style debounce (clearTimeout + setTimeout) tied directly
|
package/lib/websocket.js
CHANGED
|
@@ -530,22 +530,32 @@ function _parseExtensionHeader(header) {
|
|
|
530
530
|
for (var i = 0; i < entries.length; i++) {
|
|
531
531
|
var parts = structuredFields.splitTopLevel(entries[i], ";").map(function (s) { return s.trim(); });
|
|
532
532
|
if (!parts[0]) continue;
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
533
|
+
// Collect [name, value] pairs, then materialize the params map via
|
|
534
|
+
// Object.fromEntries onto a null-prototype object. The extension-
|
|
535
|
+
// parameter name is taken from the client-supplied Sec-WebSocket-
|
|
536
|
+
// Extensions header, so it is never used as a computed-write key
|
|
537
|
+
// (`params[name] = value`) — that is the CWE-915 unsafe-reflection /
|
|
538
|
+
// CWE-1321 prototype-pollution sink. POISONED params (`__proto__` /
|
|
539
|
+
// `constructor` / `prototype`) are dropped, and the null-prototype
|
|
540
|
+
// accumulator means even a slipped name cannot reach Object.prototype.
|
|
541
|
+
var paramPairs = [];
|
|
537
542
|
for (var j = 1; j < parts.length; j++) {
|
|
538
543
|
var kv = parts[j].split("=");
|
|
539
544
|
var k = kv[0].trim().toLowerCase();
|
|
540
545
|
if (!k) continue;
|
|
546
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
|
|
541
547
|
var v = kv.length > 1 ? kv.slice(1).join("=").trim() : true;
|
|
542
548
|
// Strip surrounding quotes per the token-or-quoted-string grammar.
|
|
543
549
|
if (typeof v === "string") {
|
|
544
550
|
var _unq = structuredFields.unquoteSfString(v);
|
|
545
551
|
if (_unq !== null) v = _unq;
|
|
546
552
|
}
|
|
547
|
-
|
|
553
|
+
paramPairs.push([k, v]);
|
|
548
554
|
}
|
|
555
|
+
var ext = {
|
|
556
|
+
name: parts[0].toLowerCase(),
|
|
557
|
+
params: Object.assign(Object.create(null), Object.fromEntries(paramPairs)),
|
|
558
|
+
};
|
|
549
559
|
out.push(ext);
|
|
550
560
|
}
|
|
551
561
|
return out;
|
|
@@ -1541,6 +1551,10 @@ module.exports = {
|
|
|
1541
1551
|
// Server-side entrypoints
|
|
1542
1552
|
handleUpgrade: handleUpgrade, // h1 — RFC 6455 HTTP upgrade
|
|
1543
1553
|
handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
|
|
1554
|
+
// Internal helper exposed for tests — the Sec-WebSocket-Extensions
|
|
1555
|
+
// parser (RFC 7692 negotiation feeds off this). Underscore-prefixed so
|
|
1556
|
+
// it is not part of the public primitive surface.
|
|
1557
|
+
_parseExtensionHeader: _parseExtensionHeader,
|
|
1544
1558
|
// Constants
|
|
1545
1559
|
GUID: GUID,
|
|
1546
1560
|
REFUSED_AUTH_QUERY_PARAMS: REFUSED_AUTH_QUERY_PARAMS,
|
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:c78c9561-3578-4ff6-99f8-a3e2a52ddc17",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-07T00:11:04.881Z",
|
|
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.14.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.14.27",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.14.
|
|
25
|
+
"version": "0.14.27",
|
|
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.14.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.14.27",
|
|
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.14.
|
|
57
|
+
"ref": "@blamejs/core@0.14.27",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|