@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/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
- async function _readMeta(absPath) {
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
- // lgtm[js/path-injection] `absPath` is the sandbox-validated return
168
- // of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
169
- // root-prefix check + realpath escape guard + guardFilename gate).
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
- var resolved = nodePath.resolve(root, "." + requestedPath);
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 use.
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 meta = await _readMeta(nodePath.resolve(absPath));
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(absPath, { flag: "r" }); }
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(absPath); }
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
- absPath = nodePath.join(absPath, indexFile);
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
- // CodeQL js/file-system-race defense single fd anchored to the
871
- // inode for the bytes we hand to the content-safety gate. The
872
- // absPath was anchored under root by _resolveSafe above; the
873
- // filehandle pattern binds size + read to the same inode so a
874
- // swap between stat (line 771) and read can't slip different
875
- // bytes past the gate.
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
- gateHandle = await fsp.open(absPath, "r");
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
- // lgtm[js/path-injection] `absPath` is the sandbox-validated return
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
- // `params` has no prototype chain `Object.create(null)` defends
534
- // against `__proto__` / `constructor` / `prototype` parameter names
535
- // in the Sec-WebSocket-Extensions header polluting downstream lookups.
536
- var ext = { name: parts[0].toLowerCase(), params: Object.create(null) };
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
- ext.params[k] = v;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.26",
3
+ "version": "0.14.27",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:e0214cf9-5d77-475a-af9a-e3cedff9f6d7",
5
+ "serialNumber": "urn:uuid:c78c9561-3578-4ff6-99f8-a3e2a52ddc17",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-06T21:14:16.419Z",
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.26",
22
+ "bom-ref": "@blamejs/core@0.14.27",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.26",
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.26",
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.26",
57
+ "ref": "@blamejs/core@0.14.27",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]