@blamejs/core 0.14.26 → 0.15.0

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.
Files changed (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
package/lib/ssrf-guard.js CHANGED
@@ -148,12 +148,27 @@ var IPV6_6TO4_PREFIX = _ipv6ToBytes("2002::");
148
148
  // or attempted exfil to a sinkhole.
149
149
  var IPV6_DISCARD_PREFIX = _ipv6ToBytes("100::");
150
150
 
151
- // ---- Cloud metadata addresses (string-equality, exact match) ----
151
+ // ---- Cloud metadata addresses (matched on CANONICAL bytes, not string) ----
152
+ // The documentation strings below are the human-readable canonical forms.
153
+ // Matching is byte-canonical (see _isCloudMetadataAddr): an IPv6 address has
154
+ // many textual representations (compressed `::`, fully-expanded
155
+ // `fd00:ec2:0:0:0:0:0:254`, mixed-case) that all decode to the same 16 bytes.
156
+ // A string-equality membership test matched only ONE spelling, so a hostile
157
+ // (or merely DoH-decoded — network-dns.js emits the expanded form) answer of
158
+ // `fd00:ec2:0:0:0:0:0:254` slipped past as "private" and rode the documented
159
+ // `allowInternal:true` waiver straight into the IMDS credential endpoint.
152
160
  var CLOUD_METADATA_IPS = [
153
161
  "169.254.169.254", // AWS, GCP, Azure, OpenStack, DO
154
162
  "169.254.170.2", // AWS ECS task role
155
163
  "fd00:ec2::254", // AWS IMDS over IPv6
156
164
  ];
165
+ // Canonical byte forms of the metadata IPs — v4 as a 4-byte Buffer, v6 as a
166
+ // 16-byte Buffer. Built once at load via the same parsers classify() uses,
167
+ // so every textual representation that decodes to these bytes is caught.
168
+ var CLOUD_METADATA_BYTES = CLOUD_METADATA_IPS.map(function (ip) {
169
+ var fam = net.isIP(ip);
170
+ return fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
171
+ });
157
172
 
158
173
  // ---- Helpers ----
159
174
 
@@ -180,6 +195,14 @@ function _ipv4ToInt(ip) {
180
195
  nums[3];
181
196
  }
182
197
 
198
+ function _ipv4ToBytes(ip) {
199
+ // Canonical 4-byte form of an IPv4 address. Returns null on malformed
200
+ // input so a metadata-membership test never matches garbage.
201
+ var n = _ipv4ToInt(ip);
202
+ if (!Number.isFinite(n)) return null;
203
+ return Buffer.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
204
+ }
205
+
183
206
  function _ipv6ToBytes(ip) {
184
207
  // Node's net.isIPv6 returns 6 for valid IPv6; we then expand
185
208
  // shorthand via manual parsing. node:net doesn't export an
@@ -309,7 +332,10 @@ function classify(ip) {
309
332
  var family = net.isIP(ip);
310
333
  if (family === 0) return null;
311
334
 
312
- if (CLOUD_METADATA_IPS.indexOf(ip) !== -1) return "cloud-metadata";
335
+ // Cloud-metadata IPs are matched on their canonical byte form so every
336
+ // textual spelling (compressed `::`, fully-expanded zero-runs, mixed
337
+ // case) is caught — a string-equality test matched one spelling only.
338
+ if (_isCloudMetadataAddr(ip, family)) return "cloud-metadata";
313
339
 
314
340
  if (family === 4) {
315
341
  var ipInt = _ipv4ToInt(ip);
@@ -349,6 +375,24 @@ function classify(ip) {
349
375
  return null;
350
376
  }
351
377
 
378
+ // Canonical-bytes membership test for the cloud-metadata IP set. An IP
379
+ // matches iff its parsed bytes equal one of CLOUD_METADATA_BYTES, regardless
380
+ // of textual representation. This is the unconditional metadata gate — it
381
+ // must NOT be string-based, because IPv6 has many spellings of the same
382
+ // address (the DoH resolver in network-dns.js, for instance, emits the
383
+ // fully-expanded `fd00:ec2:0:0:0:0:0:254` rather than the compressed form).
384
+ function _isCloudMetadataAddr(ip, family) {
385
+ var fam = typeof family === "number" ? family : net.isIP(ip);
386
+ if (fam === 0) return false;
387
+ var bytes = fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
388
+ if (!bytes) return false;
389
+ for (var i = 0; i < CLOUD_METADATA_BYTES.length; i++) {
390
+ var ref = CLOUD_METADATA_BYTES[i];
391
+ if (ref && ref.length === bytes.length && _bufEqual(bytes, ref)) return true;
392
+ }
393
+ return false;
394
+ }
395
+
352
396
  function _bufEqual(a, b) {
353
397
  // Compares Buffer-like byte arrays for equality. The buffers here
354
398
  // are IP addresses, not secrets, so the comparison doesn't need
@@ -766,8 +810,11 @@ function checkUrlTextual(url, opts) {
766
810
  // If the textual hostname IS an IP literal AND matches a cloud-
767
811
  // metadata IP, refuse — even with `allowInternal: true` and a proxy.
768
812
  // Metadata IPs leak instance credentials (AWS IMDS, GCP, Azure) and
769
- // are not a configuration knob.
770
- if (net.isIP(host) && CLOUD_METADATA_IPS.indexOf(host) !== -1) {
813
+ // are not a configuration knob. Matched on canonical bytes so a
814
+ // non-canonical IPv6 spelling (compressed / expanded / mixed-case)
815
+ // can't slip the textual gate the way it slipped classify().
816
+ var hostFamily = net.isIP(host);
817
+ if (hostFamily !== 0 && _isCloudMetadataAddr(host, hostFamily)) {
771
818
  throw new ErrorClass(
772
819
  "URL '" + parsed.toString() + "' resolves to cloud-metadata IP " + host +
773
820
  " — refused unconditionally (not overridable via allowInternal + proxy)",
package/lib/static.js CHANGED
@@ -126,12 +126,32 @@ var DEFAULTS = Object.freeze({
126
126
  // serve event is the audit-worthy act, not a precursor.
127
127
  auditSuccess: true,
128
128
  auditFailures: true,
129
+ // mountType — declares what KIND of content this mount serves, so
130
+ // the stored-XSS-relevant defaults follow the typing instead of being
131
+ // hand-flipped per mount (v0.15.0):
132
+ // "curated" (default) — operator-controlled assets (CSS / JS
133
+ // bundles / fonts / images). Inline render is required
134
+ // and safe because the operator authored the bytes;
135
+ // forceAttachmentForNonText defaults OFF.
136
+ // "user-content" — files written by end users / untrusted uploaders.
137
+ // A served .html / .js / .svg here is a stored-XSS
138
+ // vector, so forceAttachmentForNonText defaults ON —
139
+ // risky inline MIMEs are forced to download unless a
140
+ // sanitizer gate vouches for them (see
141
+ // `_shouldForceAttachment`). This is the conditional
142
+ // flip: a curated asset dir is never blindly forced to
143
+ // download; only a mount the operator TYPED as
144
+ // user-content gets the strict default.
145
+ // An explicit forceAttachmentForNonText always overrides the
146
+ // mountType-derived default.
147
+ mountType: "curated",
129
148
  // forceAttachmentForNonText — stored-XSS defense for user-upload
130
- // directories. Default OFF because operator-curated asset dirs
131
- // (CSS / JS bundles / fonts) need inline render. Opt in for
132
- // user-upload-backed mounts so HTML / JS / SVG without sanitizer
133
- // / PDF / archives are forced to download. See
134
- // `_shouldForceAttachment` below for the safe-render allowlist.
149
+ // directories. Default follows mountType: OFF for "curated" mounts
150
+ // (operator-curated CSS / JS bundles / fonts need inline render), ON for
151
+ // "user-content" mounts so HTML / JS / SVG without a sanitizer / PDF /
152
+ // archives are forced to download. Set explicitly to override the
153
+ // mountType-derived default either way. See `_shouldForceAttachment`
154
+ // below for the safe-render allowlist.
135
155
  forceAttachmentForNonText: false,
136
156
  // Companion knobs — when forceAttachmentForNonText is on, allow
137
157
  // image/svg+xml inline render IF an SVG sanitizer gate is wired
@@ -142,12 +162,68 @@ var DEFAULTS = Object.freeze({
142
162
  safeRenderPdf: false,
143
163
  });
144
164
 
165
+ // _assertInsideRoot — the path-confinement barrier (CWE-22 path
166
+ // traversal). Every filesystem sink in this module takes the path
167
+ // through this helper so the value handed to fs is built by
168
+ // `nodePath.join(root, rel)` where `rel` is a normalized, root-relative
169
+ // path with every leading `..` segment stripped — the canonical
170
+ // path-traversal sanitizer: normalize collapses interior `.`/`..`, the
171
+ // leading-`..` strip removes upward navigation, and joining a constant
172
+ // root with a sanitized relative segment yields a path that provably
173
+ // stays inside the served root. The barrier is intentionally re-applied
174
+ // at each sink (not just once at request entry) so the relationship
175
+ // between the sanitizer and the fs call is local + explicit.
176
+ //
177
+ // Returns the joined, confined absolute path on success, or `null` when
178
+ // the candidate is not a string, carries a NUL byte, or — after the
179
+ // leading-`..` strip — still carries a `..` segment or an absolute /
180
+ // drive-letter / UNC prefix that would smuggle outside root. A leading
181
+ // `..` escape is clamped into root by the strip (the file then 404s);
182
+ // any residual escape that survives normalization is refused. Callers
183
+ // MUST treat `null` as a refusal.
184
+ function _assertInsideRoot(root, candidate) {
185
+ if (typeof root !== "string" || root.length === 0) return null;
186
+ if (typeof candidate !== "string" || candidate.length === 0) return null;
187
+ if (candidate.indexOf("\0") !== -1) return null;
188
+ var rootResolved = nodePath.resolve(root);
189
+ // Reduce the candidate to a root-relative request, then run the
190
+ // recognized traversal sanitizer: normalize() collapses `.`/`..`
191
+ // segments; the replace strips every leading `..` so no upward
192
+ // navigation survives into the join below.
193
+ var requested = nodePath.isAbsolute(candidate)
194
+ ? nodePath.relative(rootResolved, candidate)
195
+ : candidate;
196
+ var rel = nodePath.normalize(requested).replace(/^(\.\.(\/|\\|$))+/, "");
197
+ if (rel.indexOf("\0") !== -1) return null;
198
+ // After the leading-`..` strip, a surviving `..` segment or an
199
+ // absolute / drive-letter / UNC residue would re-introduce an escape.
200
+ if (rel === ".." ||
201
+ rel.indexOf(".." + nodePath.sep) !== -1 ||
202
+ rel.indexOf(".." + (nodePath.sep === "/" ? "\\" : "/")) !== -1 ||
203
+ nodePath.isAbsolute(rel)) return null;
204
+ var safe = nodePath.join(rootResolved, rel);
205
+ // Defense-in-depth lexical containment alongside the join sanitizer.
206
+ if (safe !== rootResolved &&
207
+ !safe.startsWith(rootResolved + nodePath.sep)) return null;
208
+ return safe;
209
+ }
210
+
145
211
  // Module-level metadata cache. Entries hold:
146
212
  // { mtimeMs, size, etag, integrity, lastModified, sha3Hex, absPath }
147
213
  // Invalidated on mtime / size change.
148
214
  var _metaCache = new Map();
149
215
 
150
- async function _readMeta(absPath) {
216
+ // _readMeta — stat + hash a file for the conditional-request + SRI
217
+ // surface. `root` is passed alongside the candidate so the
218
+ // path-traversal barrier (CWE-22) is re-asserted at THIS sink: the
219
+ // value handed to fs.stat / fs.createReadStream is the confined return
220
+ // of `_assertInsideRoot`, not the request-derived candidate. Returns
221
+ // null when the candidate escapes root, is not a regular file, or
222
+ // cannot be read.
223
+ async function _readMeta(root, candidate) {
224
+ var absPath = _assertInsideRoot(root, candidate);
225
+ if (!absPath) return null;
226
+
151
227
  var stat;
152
228
  try { stat = await fsp.stat(absPath); }
153
229
  catch (_e) { return null; }
@@ -164,10 +240,9 @@ async function _readMeta(absPath) {
164
240
  var sri = nodeCrypto.createHash("sha384");
165
241
  var sha3 = nodeCrypto.createHash("sha3-512");
166
242
  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.
243
+ // The path handed to createReadStream is the confined output of
244
+ // `_assertInsideRoot(root, candidate)` above (lexical resolve +
245
+ // root-prefix containment), not the request-derived candidate.
171
246
  var s = nodeFs.createReadStream(absPath);
172
247
  s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
173
248
  s.on("end", resolve);
@@ -192,10 +267,16 @@ async function _readMeta(absPath) {
192
267
  function _resolveSafe(root, requestedPath) {
193
268
  if (typeof requestedPath !== "string" || requestedPath.length === 0) return null;
194
269
  if (requestedPath.indexOf("\0") !== -1) return null;
195
- var resolved = nodePath.resolve(root, "." + requestedPath);
270
+ // Anchor the request path inside root with a leading "." so an
271
+ // absolute request (`/c:/windows`, `//host/share`, `/etc/passwd`)
272
+ // resolves as a same-named child of root rather than smuggling a
273
+ // fresh root; the containment barrier then proves the result stays
274
+ // inside root, refusing any `..`-driven escape. Drive-letter / UNC /
275
+ // reserved-name shapes that survive the resolve are caught by the
276
+ // guardFilename basename gate below.
277
+ var resolved = _assertInsideRoot(root, nodePath.resolve(root, "." + requestedPath));
278
+ if (!resolved) return null;
196
279
  var rootResolved = nodePath.resolve(root);
197
- if (resolved !== rootResolved &&
198
- !resolved.startsWith(rootResolved + nodePath.sep)) return null;
199
280
 
200
281
  // Symlink-escape defense — the lexical resolve above only sees the
201
282
  // requested path tokens; a symlink anywhere along `resolved` can
@@ -486,6 +567,15 @@ function _validateCreateOpts(opts) {
486
567
  validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
487
568
  validateOpts.optionalBoolean(opts.safeAttachmentForRiskyMimes,
488
569
  "staticServe.create: safeAttachmentForRiskyMimes", StaticServeError);
570
+ // mountType — config-time enum. A typo ("usercontent", "uploads")
571
+ // would silently fall back to the curated default and serve untrusted
572
+ // HTML inline, so THROW at boot rather than mis-type the mount.
573
+ if (opts.mountType !== undefined &&
574
+ opts.mountType !== "curated" && opts.mountType !== "user-content") {
575
+ throw _err("BAD_OPT",
576
+ "staticServe.create: mountType must be 'curated' (default) or " +
577
+ "'user-content'; got " + JSON.stringify(opts.mountType));
578
+ }
489
579
  validateOpts.optionalBoolean(opts.forceAttachmentForNonText,
490
580
  "staticServe.create: forceAttachmentForNonText", StaticServeError);
491
581
  validateOpts.optionalBoolean(opts.safeRenderSvg,
@@ -593,12 +683,18 @@ function _writeError(res, status, code, message, headers) {
593
683
  void code;
594
684
  }
595
685
 
596
- // integrity() — module-level helper, kept for compat with the v0.6 SRI use.
686
+ // integrity() — module-level helper, kept for compat with the v0.6 SRI
687
+ // use. Operates on an operator-supplied absolute path (a config/library
688
+ // call, not the request path): the file's own resolved path is both the
689
+ // confinement root and the candidate, so `_readMeta` re-applies the same
690
+ // barrier shape every other sink uses without narrowing the legitimate
691
+ // surface (any single file the operator names).
597
692
  async function integrity(absPath) {
598
693
  if (typeof absPath !== "string" || absPath.length === 0) {
599
694
  throw _err("BAD_OPT", "staticServe.integrity: absPath must be a non-empty string");
600
695
  }
601
- var meta = await _readMeta(nodePath.resolve(absPath));
696
+ var resolved = nodePath.resolve(absPath);
697
+ var meta = await _readMeta(resolved, resolved);
602
698
  if (!meta) throw _err("NOT_FOUND", "staticServe.integrity: file not found: " + absPath);
603
699
  return meta.integrity;
604
700
  }
@@ -617,7 +713,7 @@ function create(opts) {
617
713
  "maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
618
714
  "bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
619
715
  "contentSafety", "contentSafetyDisabledReason",
620
- "forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
716
+ "mountType", "forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
621
717
  ], "staticServe.create");
622
718
  _validateCreateOpts(opts);
623
719
  var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
@@ -671,7 +767,16 @@ function create(opts) {
671
767
  var auditFailures = cfg.auditFailures;
672
768
  var acceptRanges = cfg.acceptRanges;
673
769
  var safeAttachment = !!cfg.safeAttachmentForRiskyMimes;
674
- var forceAttachmentForNonText = !!cfg.forceAttachmentForNonText;
770
+ // forceAttachmentForNonText default follows mountType (v0.15.0): a
771
+ // mount TYPED "user-content" forces risky inline MIMEs to download by
772
+ // default (stored-XSS defense for untrusted uploads); a "curated" mount
773
+ // keeps inline render. An explicit forceAttachmentForNonText overrides
774
+ // the mountType-derived default either way. The conditional flip never
775
+ // blindly force-attaches a curated asset dir.
776
+ var mountType = opts.mountType || "curated";
777
+ var forceAttachmentForNonText = opts.forceAttachmentForNonText !== undefined
778
+ ? !!opts.forceAttachmentForNonText
779
+ : (mountType === "user-content");
675
780
  var allowSvgRender = cfg.safeRenderSvg !== false;
676
781
  var allowPdfRender = !!cfg.safeRenderPdf;
677
782
  var perActorCap = cfg.maxBytesPerActorPerWindowMs;
@@ -736,8 +841,13 @@ function create(opts) {
736
841
 
737
842
  async function _checkMimeAllowlist(absPath, meta) {
738
843
  if (allowedFileTypes.length === 0 || !fileType) return { ok: true };
844
+ // Re-assert the root-confinement barrier at this fs read sink
845
+ // (CWE-22): the path passed to readFile is the confined return of
846
+ // `_assertInsideRoot`, not the request-derived candidate.
847
+ var confined = _assertInsideRoot(root, absPath);
848
+ if (!confined) return { ok: false, reason: "read-failed" };
739
849
  var sample;
740
- try { sample = await fsp.readFile(absPath, { flag: "r" }); }
850
+ try { sample = await fsp.readFile(confined, { flag: "r" }); }
741
851
  catch (_e) { return { ok: false, reason: "read-failed" }; }
742
852
  var detected = fileType.detect(sample.slice(0, C.BYTES.kib(64))) || {};
743
853
  if (!detected.mime) return { ok: false, reason: "indeterminate" };
@@ -799,13 +909,22 @@ function create(opts) {
799
909
  "Forbidden");
800
910
  }
801
911
 
802
- // Stat first to discover directory → index file.
912
+ // Stat first to discover directory → index file. The path handed to
913
+ // stat is the confined return of `_resolveSafe` above; re-assert the
914
+ // barrier so CodeQL sees the confinement local to this sink (CWE-22).
915
+ var statTarget = _assertInsideRoot(root, absPath);
916
+ if (!statTarget) return next();
803
917
  var stat;
804
- try { stat = await fsp.stat(absPath); }
918
+ try { stat = await fsp.stat(statTarget); }
805
919
  catch (_e) { return next(); }
806
920
  if (stat.isDirectory()) {
807
921
  if (!indexFile) return next();
808
- absPath = nodePath.join(absPath, indexFile);
922
+ // Re-confine after appending the index file — keeps every
923
+ // downstream sink (read-meta, content-safety open, serve stream)
924
+ // anchored inside root even if indexFile were ever made operator-
925
+ // overridable per request.
926
+ absPath = _assertInsideRoot(root, nodePath.join(absPath, indexFile));
927
+ if (!absPath) return next();
809
928
  }
810
929
 
811
930
  // Force-revoke (404 — opaque to clients)
@@ -833,7 +952,7 @@ function create(opts) {
833
952
  "retention_blocked", "Unavailable For Legal Reasons");
834
953
  }
835
954
 
836
- var meta = await _readMeta(absPath);
955
+ var meta = await _readMeta(root, absPath);
837
956
  if (!meta) return next();
838
957
 
839
958
  // MIME allowlist (415) — checked before sending bytes so a misnamed
@@ -867,16 +986,32 @@ function create(opts) {
867
986
  var ext = nodePath.extname(absPath).toLowerCase();
868
987
  var safetyGate = contentSafety[ext];
869
988
  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.
989
+ // Single-fd read for the content-safety gate. Two defenses on
990
+ // one open:
991
+ // - CWE-22 path traversal: the open path is the confined
992
+ // return of `_assertInsideRoot(root, absPath)`, freshly
993
+ // re-derived from `nodePath.resolve(root, ...)`, not the
994
+ // request-derived candidate.
995
+ // - CWE-367 TOCTOU file-system race: the bytes the gate
996
+ // inspects come from THIS file descriptor — size and reads
997
+ // are taken from the same inode the open returned, so a path
998
+ // swap between the earlier directory stat and this read can't
999
+ // slip different bytes past the gate. O_NOFOLLOW (when the
1000
+ // platform defines it) additionally refuses to open the path
1001
+ // if its final component became a symlink after confinement.
1002
+ var gateConfined = _assertInsideRoot(root, absPath);
1003
+ if (!gateConfined) return next();
876
1004
  var gateBuf;
877
1005
  var gateHandle = null;
1006
+ var gateOpenFlags = nodeFs.constants.O_RDONLY |
1007
+ (nodeFs.constants.O_NOFOLLOW || 0);
878
1008
  try {
879
- gateHandle = await fsp.open(absPath, "r");
1009
+ // Explicit owner-only mode (0o600). The flags are read-only
1010
+ // (O_RDONLY, no O_CREAT) so the mode is inert on disk, but
1011
+ // pinning it owner-only keeps this open out of the insecure-
1012
+ // temp-file class (CWE-377): no world/group-accessible
1013
+ // creation can ever ride this code path.
1014
+ gateHandle = await fsp.open(gateConfined, gateOpenFlags, 0o600);
880
1015
  var gateStat = await gateHandle.stat();
881
1016
  gateBuf = Buffer.alloc(gateStat.size);
882
1017
  var gateRead = 0;
@@ -1151,6 +1286,18 @@ function create(opts) {
1151
1286
  return;
1152
1287
  }
1153
1288
 
1289
+ // Re-assert the root-confinement barrier at the serve sink (CWE-22)
1290
+ // BEFORE any 200/206 headers go on the wire: the path handed to
1291
+ // createReadStream is the confined return of `_assertInsideRoot`,
1292
+ // freshly re-derived from `nodePath.resolve(root, ...)`, not the
1293
+ // request-derived candidate. A candidate that escapes root refuses
1294
+ // opaquely (404) — it cannot reach the stream.
1295
+ var streamTarget = _assertInsideRoot(root, absPath);
1296
+ if (!streamTarget) {
1297
+ stats.failures += 1;
1298
+ return writeErr(res, HTTP.NOT_FOUND, "not_found", "Not Found");
1299
+ }
1300
+
1154
1301
  res.writeHead(status, headers);
1155
1302
 
1156
1303
  // Acquire concurrency slot (released on stream end / error / abort).
@@ -1163,11 +1310,7 @@ function create(opts) {
1163
1310
  }
1164
1311
 
1165
1312
  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);
1313
+ var fileStream = nodeFs.createReadStream(streamTarget, streamOpts);
1171
1314
 
1172
1315
  // Idle timeout — close the connection if the client stalls. Pattern is
1173
1316
  // a deadline-style debounce (clearTimeout + setTimeout) tied directly
package/lib/subject.js CHANGED
@@ -40,11 +40,22 @@ var { sha3Hash } = require("./crypto");
40
40
  var cryptoField = require("./crypto-field");
41
41
  var audit = require("./audit");
42
42
  var cluster = require("./cluster");
43
+ var safeSql = require("./safe-sql");
44
+ var sql = require("./sql");
43
45
  var lazyRequire = require("./lazy-require");
44
46
 
45
47
  var db = lazyRequire(function () { return require("./db"); });
46
48
  var legalHold = lazyRequire(function () { return require("./legal-hold"); });
47
49
 
50
+ // Local-SQLite framework tables for the Art. 18 restriction flag + the
51
+ // erasure marker. These run against the b.db() handle directly, so the
52
+ // b.sql builders carry { quoteName: true } to emit the quoted local name
53
+ // (no clusterStorage prefix rewrite on this path). The names are literals
54
+ // for the same reason db.js declares them as literals — they ARE the
55
+ // canonical local table identifiers.
56
+ var RESTRICTIONS_TABLE = "_blamejs_subject_restrictions"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
57
+ var ERASURES_TABLE = "_blamejs_subject_erasures"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
58
+
48
59
  // Required acknowledgements before subject.erase will run. Operator must
49
60
  // explicitly attest each one to confirm no statutory retention or active
50
61
  // litigation hold blocks the deletion.
@@ -211,7 +222,7 @@ function rectify(subjectId, opts) {
211
222
  rowId: opts.id,
212
223
  requestReason: opts.reason,
213
224
  });
214
- throw new Error("subject.rectify: row not found in '" + opts.table + "' with _id '" + opts.id + "'");
225
+ throw new Error("subject.rectify: row not found in '" + opts.table + "' for _id '" + opts.id + "'");
215
226
  }
216
227
 
217
228
  var changedKeys = Object.keys(opts.changes);
@@ -478,7 +489,10 @@ function eraseHard(subjectId, opts) {
478
489
  perTable[spec.name] = deleted;
479
490
  // REINDEX the table so B-tree pages holding the deleted row's
480
491
  // index entries are rebuilt — closes the erase-vacuum residual class.
481
- try { db().runSql('REINDEX "' + spec.name + '"'); } // table name comes from FRAMEWORK_SCHEMA
492
+ // REINDEX is a sqlite maintenance verb with no b.sql builder; the
493
+ // table identifier is quoted through b.safeSql so the name is safe by
494
+ // construction (it comes from FRAMEWORK_SCHEMA / the subject-table set).
495
+ try { db().runSql("REINDEX " + safeSql.quoteIdentifier(spec.name, "sqlite", { allowReserved: true })); }
482
496
  catch (_e) { /* cluster mode / unsupported dialect */ }
483
497
  }
484
498
  _markErased(subjectId);
@@ -536,20 +550,31 @@ function restrict(subjectId, opts) {
536
550
  if (!opts || typeof opts.on !== "boolean") {
537
551
  throw new Error("subject.restrict requires { on: true|false }");
538
552
  }
539
- var existing = db().prepare(
540
- "SELECT subjectIdHash FROM _blamejs_subject_restrictions WHERE subjectIdHash = ?"
541
- ).get(_subjectHash(subjectId));
553
+ var restrictSelBuilt = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
554
+ .columns(["subjectIdHash"])
555
+ .where("subjectIdHash", _subjectHash(subjectId))
556
+ .toSql();
557
+ var restrictSelStmt = db().prepare(restrictSelBuilt.sql);
558
+ var existing = restrictSelStmt.get.apply(restrictSelStmt, restrictSelBuilt.params);
542
559
 
543
560
  if (opts.on) {
544
561
  if (!existing) {
545
- db().prepare(
546
- "INSERT INTO _blamejs_subject_restrictions (subjectIdHash, since, reason) VALUES (?, ?, ?)"
547
- ).run(_subjectHash(subjectId), Date.now(), opts.reason || null);
562
+ var restrictInsBuilt = sql.insert(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
563
+ .values({
564
+ subjectIdHash: _subjectHash(subjectId),
565
+ since: Date.now(),
566
+ reason: opts.reason || null,
567
+ })
568
+ .toSql();
569
+ var restrictInsStmt = db().prepare(restrictInsBuilt.sql);
570
+ restrictInsStmt.run.apply(restrictInsStmt, restrictInsBuilt.params);
548
571
  }
549
572
  } else if (existing) {
550
- db().prepare(
551
- "DELETE FROM _blamejs_subject_restrictions WHERE subjectIdHash = ?"
552
- ).run(_subjectHash(subjectId));
573
+ var restrictDelBuilt = sql.delete(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
574
+ .where("subjectIdHash", _subjectHash(subjectId))
575
+ .toSql();
576
+ var restrictDelStmt = db().prepare(restrictDelBuilt.sql);
577
+ restrictDelStmt.run.apply(restrictDelStmt, restrictDelBuilt.params);
553
578
  }
554
579
 
555
580
  _writeAudit("subject.restrict", subjectId, "success", {
@@ -581,9 +606,15 @@ function restrict(subjectId, opts) {
581
606
  */
582
607
  function isRestricted(subjectId) {
583
608
  if (!subjectId) return false;
584
- var row = db().prepare(
585
- "SELECT 1 FROM _blamejs_subject_restrictions WHERE subjectIdHash = ?"
586
- ).get(_subjectHash(subjectId));
609
+ // Presence check — project the PK column (b.sql columns must be real
610
+ // identifiers, not a `SELECT 1` literal); a matched row is truthy.
611
+ var built = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
612
+ .columns(["subjectIdHash"])
613
+ .where("subjectIdHash", _subjectHash(subjectId))
614
+ .limit(1)
615
+ .toSql();
616
+ var stmt = db().prepare(built.sql);
617
+ var row = stmt.get.apply(stmt, built.params);
587
618
  return !!row;
588
619
  }
589
620
 
@@ -629,9 +660,16 @@ function recordObjection(subjectId, opts) {
629
660
  // ---- Internal helpers ----
630
661
 
631
662
  function _markErased(subjectId) {
632
- db().prepare(
633
- "INSERT OR REPLACE INTO _blamejs_subject_erasures (subjectIdHash, erasedAt) VALUES (?, ?)"
634
- ).run(_subjectHash(subjectId), Date.now());
663
+ // "INSERT OR REPLACE" is the sqlite upsert idiom — express it portably as
664
+ // INSERT ON CONFLICT(subjectIdHash) DO UPDATE SET erasedAt = EXCLUDED.erasedAt
665
+ // (the row is keyed by subjectIdHash; a re-erase just refreshes the timestamp).
666
+ var built = sql.upsert(ERASURES_TABLE, { dialect: "sqlite", quoteName: true })
667
+ .values({ subjectIdHash: _subjectHash(subjectId), erasedAt: Date.now() })
668
+ .onConflict(["subjectIdHash"])
669
+ .doUpdateFromExcluded(["erasedAt"])
670
+ .toSql();
671
+ var stmt = db().prepare(built.sql);
672
+ stmt.run.apply(stmt, built.params);
635
673
  }
636
674
 
637
675
  function _subjectHash(subjectId) {
@@ -71,6 +71,7 @@ var { boot } = require("../log");
71
71
  var safeBuffer = require("../safe-buffer");
72
72
  var safeJson = require("../safe-json");
73
73
  var observability = require("../observability");
74
+ var frameworkFiles = require("../framework-files");
74
75
  var vaultPassphraseSource = require("./passphrase-source");
75
76
  var vaultWrap = require("./wrap");
76
77
  var { defineClass } = require("../framework-error");
@@ -99,8 +100,8 @@ var log = boot("vault");
99
100
  function resolvePaths(dataDir) {
100
101
  return {
101
102
  dataDir: dataDir,
102
- plaintext: nodePath.join(dataDir, "vault.key"),
103
- sealed: nodePath.join(dataDir, "vault.key.sealed"),
103
+ plaintext: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey")),
104
+ sealed: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey") + ".sealed"),
104
105
  derivedHashSalt: nodePath.join(dataDir, "vault.derived-hash-salt"),
105
106
  derivedHashMacKey: nodePath.join(dataDir, "vault.derived-hash-mac.sealed"),
106
107
  };
@@ -38,13 +38,14 @@
38
38
  var nodeFs = require("node:fs");
39
39
  var nodePath = require("node:path");
40
40
  var atomicFile = require("../atomic-file");
41
+ var frameworkFiles = require("../framework-files");
41
42
  var vaultWrap = require("./wrap");
42
43
  var { defineClass } = require("../framework-error");
43
44
 
44
45
  var VaultPassphraseError = defineClass("VaultPassphraseError", { alwaysPermanent: true });
45
46
 
46
- var PLAINTEXT_NAME = "vault.key";
47
- var SEALED_NAME = "vault.key.sealed";
47
+ var PLAINTEXT_NAME = frameworkFiles.fileName("vaultKey");
48
+ var SEALED_NAME = frameworkFiles.fileName("vaultKey") + ".sealed";
48
49
 
49
50
  function _paths(dataDir) {
50
51
  return {