@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
@@ -166,6 +166,29 @@ var BodyParserError = defineClass("BodyParserError", { withStatusCode: true });
166
166
  // in play — consistent prototype-pollution defense across the framework.
167
167
  var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
168
168
 
169
+ // Materialize a header/parameter map from request-derived [key, value]
170
+ // pairs WITHOUT a computed member write (`target[key] = value`). A
171
+ // request-keyed computed write is the CWE-915 unsafe-reflection /
172
+ // CWE-1321 prototype-pollution sink: an attacker who controls the key
173
+ // (Content-Type parameter name, multipart part-header name,
174
+ // Content-Disposition parameter name) can target `__proto__` /
175
+ // `constructor` / `prototype` and corrupt the prototype chain. Poisoned
176
+ // keys are dropped, the remaining pairs are funneled through
177
+ // `Object.fromEntries`, and the result carries no prototype chain
178
+ // (`Object.create(null)`) so even a key that slipped a future POISONED_KEYS
179
+ // gap cannot reach Object.prototype. The returned map has plain-object
180
+ // shape (string keys → values) so existing named-property reads
181
+ // (`.boundary`, `.charset`, `["content-disposition"]`, `.name`,
182
+ // `.filename`) are unchanged.
183
+ function _mapFromPairs(pairs) {
184
+ var safe = [];
185
+ for (var i = 0; i < pairs.length; i++) {
186
+ if (POISONED_KEYS.has(pairs[i][0])) continue;
187
+ safe.push(pairs[i]);
188
+ }
189
+ return Object.assign(Object.create(null), Object.fromEntries(safe));
190
+ }
191
+
169
192
  // ---- defaults ----
170
193
 
171
194
  var DEFAULTS = Object.freeze({
@@ -221,7 +244,11 @@ function _contentType(req) {
221
244
  if (typeof ct !== "string") return { type: "", params: {} };
222
245
  var idx = ct.indexOf(";");
223
246
  var type = (idx === -1 ? ct : ct.slice(0, idx)).trim().toLowerCase();
224
- var params = {};
247
+ // Collect [name, value] pairs, then materialize via _mapFromPairs so a
248
+ // request-controlled parameter name (e.g. `boundary` / `charset` / an
249
+ // attacker-supplied `__proto__`) is never used as a computed-write key
250
+ // (CWE-915 / CWE-1321). Poisoned names are dropped at materialization.
251
+ var paramPairs = [];
225
252
  if (idx !== -1) {
226
253
  var rest = ct.slice(idx + 1);
227
254
  // RFC 9110 §8.3 + §5.6.6 — parameter values may be quoted-string
@@ -238,10 +265,10 @@ function _contentType(req) {
238
265
  var v = p.slice(eq + 1).trim();
239
266
  var _unq = structuredFields.unquoteSfString(v);
240
267
  if (_unq !== null) v = _unq;
241
- params[k] = v;
268
+ paramPairs.push([k, v]);
242
269
  }
243
270
  }
244
- return { type: type, params: params };
271
+ return { type: type, params: _mapFromPairs(paramPairs) };
245
272
  }
246
273
 
247
274
  function _typeMatches(actual, allowed) {
@@ -583,7 +610,13 @@ function _parseMultipartHeaders(rawHeaders) {
583
610
  // §5.5 — header field values MUST NOT contain CR, LF, or NUL bytes.
584
611
  // We refuse the part outright (caller surfaces the throw as 400 + drop).
585
612
  var lines = rawHeaders.split("\r\n");
586
- var out = {};
613
+ // Collect [name, value] pairs; materialize via _mapFromPairs so the
614
+ // request-controlled header name is never a computed-write key
615
+ // (CWE-915 / CWE-1321 — a part header literally named `__proto__` would
616
+ // otherwise pollute the prototype chain). Later headers of the same
617
+ // name keep last-wins (the prior `out[k] = v` overwrite semantics:
618
+ // Object.fromEntries takes the last pair for a duplicate key).
619
+ var headerPairs = [];
587
620
  for (var i = 0; i < lines.length; i++) {
588
621
  var line = lines[i];
589
622
  if (!line) continue;
@@ -609,9 +642,9 @@ function _parseMultipartHeaders(rawHeaders) {
609
642
  );
610
643
  }
611
644
  }
612
- out[k] = v;
645
+ headerPairs.push([k, v]);
613
646
  }
614
- return out;
647
+ return _mapFromPairs(headerPairs);
615
648
  }
616
649
 
617
650
  // Percent-decode an RFC 5987 ext-value's value segment under iso-8859-1.
@@ -672,14 +705,20 @@ function _parseHeaderParams(headerValue, filenameCharsets) {
672
705
  // is present, it takes precedence over the legacy `filename=`
673
706
  // companion (RFC 6266 §4.3). We surface the decoded value at
674
707
  // `filename` so downstream consumers don't need parser-aware code.
675
- var out = { _value: "" };
676
- if (!headerValue) return out;
708
+ if (!headerValue) return _mapFromPairs([["_value", ""]]);
677
709
  // RFC 6266 §4.1 + RFC 9110 §5.6.6 — parameter values may be
678
710
  // quoted-string (e.g. `filename="weird;name.txt"`). Bare
679
711
  // `.split(";")` would slice through the quoted semicolon and
680
712
  // corrupt the filename. Quote-aware shared splitter.
681
713
  var parts = structuredFields.splitTopLevel(headerValue, ";");
682
- out._value = parts[0].trim().toLowerCase();
714
+ // Collect [name, value] pairs, then materialize via _mapFromPairs so a
715
+ // request-controlled Content-Disposition parameter name (or its
716
+ // ext-value `name*` bare form) is never a computed-write key
717
+ // (CWE-915 / CWE-1321). `_value` carries the disposition type;
718
+ // `filename` (when an ext-value decoded) takes precedence over the
719
+ // legacy `filename=` companion (RFC 6266 §4.3), preserved by appending
720
+ // it last so Object.fromEntries' last-wins resolves it.
721
+ var paramPairs = [["_value", parts[0].trim().toLowerCase()]];
683
722
  var extName = null;
684
723
  for (var i = 1; i < parts.length; i++) {
685
724
  var p = parts[i].trim();
@@ -694,14 +733,14 @@ function _parseHeaderParams(headerValue, filenameCharsets) {
694
733
  if (decoded !== null) {
695
734
  var bareKey = k.slice(0, -1);
696
735
  if (bareKey === "filename") extName = decoded;
697
- out[bareKey] = decoded;
736
+ paramPairs.push([bareKey, decoded]);
698
737
  }
699
738
  continue;
700
739
  }
701
- out[k] = v;
740
+ paramPairs.push([k, v]);
702
741
  }
703
- if (extName !== null) out.filename = extName;
704
- return out;
742
+ if (extName !== null) paramPairs.push(["filename", extName]);
743
+ return _mapFromPairs(paramPairs);
705
744
  }
706
745
 
707
746
  async function _parseMultipart(req, opts, ctParams) {
@@ -1173,20 +1212,27 @@ async function _parseMultipart(req, opts, ctParams) {
1173
1212
  var fbuf = Buffer.concat(currentBuf);
1174
1213
  var text = fbuf.toString("utf8");
1175
1214
  // Repeated field name → array, matching urlencoded parser.
1176
- if (Object.prototype.hasOwnProperty.call(fields, currentField)) {
1177
- // lgtm[js/remote-property-injection] `currentField` is gated
1178
- // upstream at lib/middleware/body-parser.js:867 by
1179
- // POISONED_KEYS (__proto__ / constructor / prototype) which
1180
- // refuses the multipart part with a 400 BodyParserError before
1181
- // `currentField` is ever assigned. Reachable values cannot
1182
- // pollute the prototype chain.
1183
- if (Array.isArray(fields[currentField])) fields[currentField].push(text);
1184
- else fields[currentField] = [fields[currentField], text];
1215
+ // `currentField` is request-controlled, so the accumulation
1216
+ // never uses it as a computed-write key (`fields[key] = v`),
1217
+ // which is the CWE-915 / CWE-1321 sink: it is merged through
1218
+ // Object.fromEntries + Object.assign instead. The upstream
1219
+ // POISONED_KEYS gate (the multipart-poisoned-field check above)
1220
+ // already rejects __proto__ / constructor / prototype field
1221
+ // names with a 400 before reaching here; the entries-merge is
1222
+ // the structural backstop.
1223
+ var fieldName = currentField;
1224
+ var prior = Object.prototype.hasOwnProperty.call(fields, fieldName)
1225
+ ? fields[fieldName] : undefined;
1226
+ var nextValue;
1227
+ if (prior === undefined) {
1228
+ nextValue = text;
1229
+ } else if (Array.isArray(prior)) {
1230
+ prior.push(text);
1231
+ nextValue = prior;
1185
1232
  } else {
1186
- // lgtm[js/remote-property-injection] — see upstream POISONED_KEYS
1187
- // gate at lib/middleware/body-parser.js:867.
1188
- fields[currentField] = text;
1233
+ nextValue = [prior, text];
1189
1234
  }
1235
+ Object.assign(fields, Object.fromEntries([[fieldName, nextValue]]));
1190
1236
  }
1191
1237
  currentHeaders = null;
1192
1238
  currentField = null;
@@ -91,25 +91,36 @@ function _parseCookieHeader(header) {
91
91
  // just splits the name=value pairs. Keys that appear multiple times
92
92
  // resolve to the FIRST occurrence (browsers send pairs left-to-right
93
93
  // by registration order; the first is the most-specific path).
94
- // Output object has no prototype chain `Object.create(null)` defends
95
- // against `__proto__` / `constructor` / `prototype` cookie-name keys
96
- // polluting the prototype before the hasOwnProperty gate runs.
97
- var out = Object.create(null);
98
- if (typeof header !== "string" || header.length === 0) return out;
94
+ // Collect [name, value] pairs, then materialize the cookie map via
95
+ // Object.fromEntries onto a null-prototype object. The cookie name is
96
+ // attacker-controlled (Cookie request header), so it is never used as a
97
+ // computed-write key (`out[name] = value` / `seen[name] = true`) — that
98
+ // is the CWE-915 unsafe-reflection / CWE-1321 prototype-pollution sink.
99
+ // First-occurrence-wins de-duplication tracks names in a Set (add/has
100
+ // are method calls, not tainted-key property writes); POISONED names
101
+ // (`__proto__` / `constructor` / `prototype`) are dropped; and the
102
+ // null-prototype accumulator means even a slipped name cannot reach
103
+ // Object.prototype.
104
+ if (typeof header !== "string" || header.length === 0) return Object.create(null);
99
105
  var parts = header.split(/;\s*/);
106
+ var seen = new Set();
107
+ var pairs = [];
100
108
  for (var i = 0; i < parts.length; i++) {
101
109
  var p = parts[i];
102
110
  var eq = p.indexOf("=");
103
111
  if (eq === -1) continue;
104
112
  var k = p.slice(0, eq).trim();
105
- if (k.length === 0 || Object.prototype.hasOwnProperty.call(out, k)) continue;
113
+ if (k.length === 0) continue;
114
+ if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
115
+ if (seen.has(k)) continue; // first-occurrence wins
116
+ seen.add(k);
106
117
  var v = p.slice(eq + 1).trim();
107
118
  if (v.length >= 2 && v.charCodeAt(0) === 0x22 && v.charCodeAt(v.length - 1) === 0x22) {
108
119
  v = v.slice(1, -1);
109
120
  }
110
- out[k] = v;
121
+ pairs.push([k, v]);
111
122
  }
112
- return out;
123
+ return Object.assign(Object.create(null), Object.fromEntries(pairs));
113
124
  }
114
125
 
115
126
  // `_isHttps` defers to `requestHelpers.requestProtocol` so the
@@ -125,11 +125,13 @@ function _writeReject(req, res, message, reason, onDeny, problemMode) {
125
125
  * destination list, including `webidentity` (FedCM credentialed
126
126
  * requests). `deniedDest` refuses chosen destinations outright on the
127
127
  * gated methods — a FedCM `webidentity` Sec-Fetch-Dest hitting a route
128
- * that is not an identity endpoint is refused. `allowStorageAccess:
129
- * false` refuses the Storage Access API escalation (a cross-site request
130
- * carrying `Sec-Fetch-Storage-Access: active` / `inactive`) on routes
131
- * that do not participate in the Storage Access flow. Both are opt-in;
132
- * leaving them unset preserves the prior behavior exactly.
128
+ * that is not an identity endpoint is refused. The Storage Access API
129
+ * escalation (a cross-site request carrying `Sec-Fetch-Storage-Access:
130
+ * active` / `inactive`) is REFUSED BY DEFAULT (v0.15.0) on routes that do
131
+ * not participate in the Storage Access flow; operators running an
132
+ * embedded-iframe SaaS that legitimately uses the API opt back in with
133
+ * `allowStorageAccess: true`. `deniedDest` stays opt-in (unset = no
134
+ * destination is denied outright).
133
135
  *
134
136
  * @opts
135
137
  * {
@@ -138,7 +140,7 @@ function _writeReject(req, res, message, reason, onDeny, problemMode) {
138
140
  * allowMissing: boolean, // default true
139
141
  * allowedDest: string[], // cross-site allowlist of Sec-Fetch-Dest values
140
142
  * deniedDest: string[], // Sec-Fetch-Dest values refused on gated methods regardless of site (e.g. ["webidentity"])
141
- * allowStorageAccess: boolean, // default truefalse refuses Sec-Fetch-Storage-Access: active|inactive
143
+ * allowStorageAccess: boolean, // default false — refuses Sec-Fetch-Storage-Access: active|inactive; pass true to opt back in for Storage-Access-flow routes
142
144
  * strictDest: boolean, // default false — true throws at config time on an allowedDest/deniedDest value outside the known Sec-Fetch-Dest vocabulary
143
145
  * allowedNavigate: boolean, // default true
144
146
  * methods: string[], // default POST/PUT/DELETE/PATCH
@@ -178,7 +180,15 @@ function create(opts) {
178
180
  var allowCrossSite = opts.allowCrossSite === true;
179
181
  var allowMissing = opts.allowMissing !== false;
180
182
  var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
181
- var allowStorageAccess = opts.allowStorageAccess !== false;
183
+ // Storage Access escalation default-deny (v0.15.0): a cross-site
184
+ // credentialed request carrying Sec-Fetch-Storage-Access: active|inactive
185
+ // is REFUSED by default on the gated methods, because that header signals
186
+ // the embedded context can reach unpartitioned cross-site cookies — a
187
+ // capability a route that does not participate in the Storage Access flow
188
+ // should not silently honor. Operators running an embedded-iframe SaaS
189
+ // that legitimately uses the Storage Access API opt back in with
190
+ // allowStorageAccess: true.
191
+ var allowStorageAccess = opts.allowStorageAccess === true;
182
192
  // deniedDest → a null-prototype membership map; an operator-supplied
183
193
  // destination string is never assigned onto a plain object, so no
184
194
  // reserved name (__proto__ / constructor / prototype) can pollute it.
@@ -47,6 +47,7 @@ var validateOpts = require("../validate-opts");
47
47
  var safeBuffer = require("../safe-buffer");
48
48
  var safeJson = require("../safe-json");
49
49
  var safeSql = require("../safe-sql");
50
+ var sql = require("../sql");
50
51
  var bCrypto = require("../crypto");
51
52
  var cryptoField = require("../crypto-field");
52
53
  var vault = require("../vault");
@@ -250,17 +251,23 @@ function dbStore(opts) {
250
251
  "dbStore: opts.db must be a sqlite-shaped database with a `prepare(sql)` method", true);
251
252
  }
252
253
  var tableNameRaw = opts.tableName !== undefined ? opts.tableName : "blamejs_idempotency_keys";
253
- // Quote-and-validate via safeSql.quoteIdentifier runs
254
- // validateIdentifier internally + emits the dialect-correct quoted
255
- // form. Identifier always reaches SQL through the quoted form.
256
- var qTable;
257
- try { qTable = safeSql.quoteIdentifier(tableNameRaw, "sqlite"); }
254
+ // Validate the operator-supplied table name up front so a bad
255
+ // identifier fails at construction with the stable
256
+ // idempotency/bad-table-name code (b.sql would otherwise raise its own
257
+ // SqlBuilderError deeper in the first build). b.sql then quotes the
258
+ // name by construction in every statement it emits for this store
259
+ // (quoteName:true — this is a direct sqlite handle, not a
260
+ // clusterStorage rewrite target, so the name is quoted, not left bare).
261
+ try { safeSql.validateIdentifier(tableNameRaw, { allowReserved: true }); }
258
262
  catch (sqlErr) {
259
263
  throw new IdempotencyError("idempotency/bad-table-name",
260
264
  "dbStore: opts.tableName is not a valid SQL identifier: " +
261
265
  (sqlErr && sqlErr.message ? sqlErr.message : String(sqlErr)), true);
262
266
  }
263
- var qIndex = safeSql.quoteIdentifier(tableNameRaw + "_expires_idx", "sqlite");
267
+ // b.sql opts for every statement this store builds against the local
268
+ // sqlite handle: sqlite dialect (native `?` placeholders, double-quoted
269
+ // identifiers) + quoteName so the operator table name is emitted quoted.
270
+ var sqlOpts = { dialect: "sqlite", quoteName: true };
264
271
  var doInit = opts.init !== false;
265
272
  var hashKeys = opts.hashKeys !== false;
266
273
  var sealReq = opts.seal !== false;
@@ -342,38 +349,46 @@ function dbStore(opts) {
342
349
  }
343
350
 
344
351
  if (doInit) {
345
- db.prepare("CREATE TABLE IF NOT EXISTS " + qTable + " (" +
346
- "k TEXT PRIMARY KEY, " +
347
- "fingerprint TEXT NOT NULL, " +
348
- "status_code INTEGER NOT NULL, " +
349
- "headers TEXT NOT NULL, " +
350
- "body TEXT NOT NULL, " +
351
- "expires_at INTEGER NOT NULL)").run();
352
- db.prepare("CREATE INDEX IF NOT EXISTS " + qIndex + " ON " +
353
- qTable + "(expires_at)").run();
352
+ db.prepare(sql.createTable(tableNameRaw, [
353
+ { name: "k", type: "text", primaryKey: true },
354
+ { name: "fingerprint", type: "text", notNull: true },
355
+ { name: "status_code", type: "int", notNull: true },
356
+ { name: "headers", type: "text", notNull: true },
357
+ { name: "body", type: "text", notNull: true },
358
+ { name: "expires_at", type: "int", notNull: true },
359
+ ], sqlOpts).sql).run();
360
+ db.prepare(sql.createIndex(tableNameRaw + "_expires_idx", tableNameRaw,
361
+ ["expires_at"], sqlOpts).sql).run();
354
362
  }
355
363
 
356
- // Prepared statements. status_code + expires_at stay non-sealed
357
- // so audit/forensic SELECTs don't have to unseal-everything. The
358
- // `k` column is selected even when not strictly needed for read
359
- // because cryptoField.unsealRow uses it as the rowId in AAD when
360
- // the table is AAD-bound.
361
- var stmtGet = db.prepare(
362
- "SELECT k, fingerprint, status_code, headers, body, expires_at FROM " +
363
- qTable + " WHERE k = ?");
364
- var stmtUpsert = db.prepare(
365
- "INSERT INTO " + qTable +
366
- "(k, fingerprint, status_code, headers, body, expires_at) " +
367
- "VALUES (?, ?, ?, ?, ?, ?) " +
368
- "ON CONFLICT(k) DO UPDATE SET " +
369
- " fingerprint = excluded.fingerprint, " +
370
- " status_code = excluded.status_code, " +
371
- " headers = excluded.headers, " +
372
- " body = excluded.body, " +
373
- " expires_at = excluded.expires_at");
374
- var stmtDeleteStale = db.prepare("DELETE FROM " + qTable +
375
- " WHERE k = ? AND expires_at <= ?");
376
- var stmtDelete = db.prepare("DELETE FROM " + qTable + " WHERE k = ?");
364
+ // Prepared statements, composed once through b.sql and reused per call.
365
+ // b.sql binds concrete values into a params array; for a reusable
366
+ // prepared statement we keep only the emitted SQL text (its `?`
367
+ // placeholders) and bind fresh values at run time, so a build-time
368
+ // sentinel value is just a placeholder slot. status_code + expires_at
369
+ // stay non-sealed so audit/forensic SELECTs don't have to unseal-
370
+ // everything. The `k` column is selected even when not strictly needed
371
+ // for read because cryptoField.unsealRow uses it as the rowId in AAD
372
+ // when the table is AAD-bound.
373
+ var _slot = 0; // sentinel bind value; the prepared statement rebinds at call time
374
+ var stmtGet = db.prepare(sql.select(tableNameRaw, sqlOpts)
375
+ .columns(["k", "fingerprint", "status_code", "headers", "body", "expires_at"])
376
+ .where("k", _slot)
377
+ .toSql().sql);
378
+ var stmtUpsert = db.prepare(sql.upsert(tableNameRaw, sqlOpts)
379
+ .columns(["k", "fingerprint", "status_code", "headers", "body", "expires_at"])
380
+ .values({ k: _slot, fingerprint: _slot, status_code: _slot,
381
+ headers: _slot, body: _slot, expires_at: _slot })
382
+ .onConflict(["k"])
383
+ .doUpdateFromExcluded(["fingerprint", "status_code", "headers", "body", "expires_at"])
384
+ .toSql().sql);
385
+ var stmtDeleteStale = db.prepare(sql.delete(tableNameRaw, sqlOpts)
386
+ .where("k", _slot)
387
+ .where("expires_at", "<=", _slot)
388
+ .toSql().sql);
389
+ var stmtDelete = db.prepare(sql.delete(tableNameRaw, sqlOpts)
390
+ .where("k", _slot)
391
+ .toSql().sql);
377
392
 
378
393
  function _k(rawKey) {
379
394
  if (!hashKeys) return rawKey;
@@ -484,8 +499,9 @@ function dbStore(opts) {
484
499
  }
485
500
  var migrated = 0;
486
501
  var skipped = 0;
487
- var rows = db.prepare("SELECT k, fingerprint, status_code, headers, body, expires_at FROM " +
488
- qTable).all();
502
+ var rows = db.prepare(sql.select(tableNameRaw, sqlOpts)
503
+ .columns(["k", "fingerprint", "status_code", "headers", "body", "expires_at"])
504
+ .toSql().sql).all();
489
505
  for (var i = 0; i < rows.length; i += 1) {
490
506
  var r = rows[i];
491
507
  // If headers/body already start with vault.aad: this row is
@@ -57,13 +57,63 @@
57
57
  * Audit: every limit hit emits system.ratelimit.block with the key + path.
58
58
  */
59
59
  var C = require("../constants");
60
+ var frameworkSchema = require("../framework-schema");
60
61
  var lazyRequire = require("../lazy-require");
61
62
  var requestHelpers = require("../request-helpers");
62
63
  var safeAsync = require("../safe-async");
64
+ var sql = require("../sql");
63
65
  var validateOpts = require("../validate-opts");
64
66
  var clusterStorage = require("../cluster-storage");
65
67
  var denyResponse = require("./deny-response").denyResponse;
66
68
 
69
+ // Cluster-backend table — resolved through frameworkSchema.tableName so a
70
+ // configured table prefix (b.frameworkSchema.setTablePrefix) is honored.
71
+ // The name is identity-mapped in LOCAL_TO_EXTERNAL, so clusterStorage's
72
+ // resolveTables leaves it untouched at dispatch and the resolved name is
73
+ // what reaches the backend on both single-node + cluster sides.
74
+ var RATE_LIMIT_TABLE = "_blamejs_rate_limit_counters"; // allow:hand-rolled-sql — canonical logical table-name declaration
75
+ function _rateLimitSqlTable() { return frameworkSchema.tableName(RATE_LIMIT_TABLE); }
76
+
77
+ // b.sql opts for every cluster-backend statement: thread the ACTIVE backend
78
+ // dialect (clusterStorage.dialect() — "sqlite" single-node, "postgres" |
79
+ // "mysql" in cluster mode) so the emitted identifier quoting and dialect
80
+ // idioms (ON CONFLICT ... DO UPDATE vs ON DUPLICATE KEY UPDATE) match the
81
+ // backend the SQL dispatches to. b.sql defaults to "sqlite", which works on
82
+ // Postgres only by accident (both double-quote identifiers) and emits the
83
+ // wrong quoting + ON CONFLICT (which MySQL rejects) on MySQL.
84
+ // clusterStorage.execute still rewrites table names + translates `?`
85
+ // placeholders at dispatch; this controls only the builder-side quoting +
86
+ // idiom selection.
87
+ function _rateLimitSqlOpts() { return { dialect: clusterStorage.dialect() }; }
88
+
89
+ // Dialect-aware references for the conflict-action CASE expressions in
90
+ // take(). The fixed-window counter's update is per-column conditional (a new
91
+ // window resets count to 1; the same window increments), so it can't reduce
92
+ // to doUpdateFromExcluded — it needs a CASE that reads BOTH the proposed row
93
+ // and the existing row. Those two references are spelled differently per
94
+ // dialect and b.sql passes a doUpdate({col: rawExpr}) expression through
95
+ // verbatim (it is NOT EXCLUDED->VALUES translated on MySQL), so the caller
96
+ // must emit the dialect-correct tokens itself:
97
+ // - proposed-row column: EXCLUDED."<col>" (Postgres/SQLite) vs
98
+ // VALUES(`<col>`) (MySQL ON DUPLICATE KEY UPDATE)
99
+ // - existing-row column: "<table>"."<col>" (Postgres/SQLite) vs
100
+ // `<table>`.`<col>` (MySQL)
101
+ // Identifiers here are framework-controlled constants (the table name + the
102
+ // three counter columns), never operator input, so the inline quoting is
103
+ // closed over a fixed set of names.
104
+ function _conflictRefs(dialect, table) {
105
+ if (dialect === "mysql") {
106
+ return {
107
+ proposed: function (col) { return "VALUES(`" + col + "`)"; },
108
+ existing: function (col) { return "`" + table + "`.`" + col + "`"; },
109
+ };
110
+ }
111
+ return {
112
+ proposed: function (col) { return "EXCLUDED.\"" + col + "\""; },
113
+ existing: function (col) { return "\"" + table + "\".\"" + col + "\""; },
114
+ };
115
+ }
116
+
67
117
  var audit = lazyRequire(function () { return require("../audit"); });
68
118
  var logger = lazyRequire(function () { return require("../log").boot("rate-limit"); });
69
119
 
@@ -260,10 +310,10 @@ function _clusterBackend(opts) {
260
310
  if (now - lastPruneAt < pruneIntervalMs) return;
261
311
  lastPruneAt = now;
262
312
  var cutoff = now - windowMs;
263
- clusterStorage.execute(
264
- "DELETE FROM _blamejs_rate_limit_counters WHERE windowStart < ?",
265
- [cutoff]
266
- ).catch(function (e) {
313
+ var built = sql.delete(_rateLimitSqlTable(), _rateLimitSqlOpts())
314
+ .where("windowStart", "<", cutoff)
315
+ .toSql();
316
+ clusterStorage.execute(built.sql, built.params).catch(function (e) {
267
317
  try {
268
318
  logger().warn("rate-limit prune failed: " + ((e && e.message) || String(e)));
269
319
  } catch (_e) { /* logger best-effort */ }
@@ -274,29 +324,49 @@ function _clusterBackend(opts) {
274
324
  var now = Date.now();
275
325
  var windowStart = Math.floor(now / windowMs) * windowMs;
276
326
 
277
- // Atomic increment: a fresh window resets count to 1; an existing
278
- // row in the same window gets count + 1. Postgres + SQLite both
279
- // support ON CONFLICT...DO UPDATE...RETURNING.
280
- var result = await clusterStorage.execute(
281
- "INSERT INTO _blamejs_rate_limit_counters (key, windowStart, count) " +
282
- "VALUES (?, ?, 1) " +
283
- "ON CONFLICT (key) DO UPDATE SET " +
284
- " count = CASE " +
285
- " WHEN excluded.windowStart > _blamejs_rate_limit_counters.windowStart " +
286
- " THEN 1 " +
287
- " ELSE _blamejs_rate_limit_counters.count + 1 " +
288
- " END, " +
289
- " windowStart = CASE " +
290
- " WHEN excluded.windowStart > _blamejs_rate_limit_counters.windowStart " +
291
- " THEN excluded.windowStart " +
292
- " ELSE _blamejs_rate_limit_counters.windowStart " +
293
- " END " +
294
- "RETURNING count, windowStart",
295
- [key, windowStart]
296
- );
297
- var row = result.rows && result.rows[0];
298
- var count = row ? row.count : 1;
299
- var rowWindow = row ? row.windowStart : windowStart;
327
+ // Atomic increment: a fresh window resets count to 1; an existing row in
328
+ // the same window gets count + 1. The per-column conflict action is a
329
+ // CASE that reads the proposed row AND the existing row, so it goes
330
+ // through the STRUCTURED upsert().doUpdate({...}) form with the dialect
331
+ // threaded b.sql then renders ON CONFLICT...DO UPDATE...RETURNING
332
+ // (Postgres/SQLite) or ON DUPLICATE KEY UPDATE + a readback SELECT
333
+ // (MySQL). The CASE bodies spell the proposed-row (EXCLUDED / VALUES())
334
+ // and existing-row (table self-reference) tokens per dialect via
335
+ // _conflictRefs so the same logic compiles on every backend. No `?` in
336
+ // the CASE bodies; the count seed of 1 binds as the third inserted value.
337
+ var t = _rateLimitSqlTable();
338
+ var dialect = clusterStorage.dialect();
339
+ var refs = _conflictRefs(dialect, t);
340
+ var newerWindow = refs.proposed("windowStart") + " > " + refs.existing("windowStart");
341
+ var countExpr = "CASE WHEN " + newerWindow + " THEN 1 ELSE " +
342
+ refs.existing("count") + " + 1 END";
343
+ var windowExpr = "CASE WHEN " + newerWindow + " THEN " + refs.proposed("windowStart") +
344
+ " ELSE " + refs.existing("windowStart") + " END";
345
+ var built = sql.upsert(t, _rateLimitSqlOpts())
346
+ .columns(["key", "windowStart", "count"])
347
+ .values({ key: key, windowStart: windowStart, count: 1 })
348
+ .onConflict(["key"])
349
+ .doUpdate({ count: countExpr, windowStart: windowExpr })
350
+ .returning(["count", "windowStart"])
351
+ .toSql();
352
+ var row;
353
+ if (built.readbackSql) {
354
+ // MySQL: ON DUPLICATE KEY UPDATE has no RETURNING. Run the upsert,
355
+ // then the readback SELECT b.sql emits (keyed on the conflict key) to
356
+ // learn the post-upsert count/windowStart. clusterStorage.execute
357
+ // coerces the framework int columns (count/windowStart) back to JS
358
+ // numbers on both reads.
359
+ await clusterStorage.execute(built.sql, built.params);
360
+ var readback = await clusterStorage.execute(built.readbackSql.sql, built.readbackSql.params);
361
+ row = readback.rows && readback.rows[0];
362
+ } else {
363
+ var result = await clusterStorage.execute(built.sql, built.params);
364
+ row = result.rows && result.rows[0];
365
+ }
366
+ // count/windowStart are framework int columns coerced to JS numbers by
367
+ // clusterStorage; the absent-row fall-back keeps the verdict math finite.
368
+ var count = row ? Number(row.count) : 1;
369
+ var rowWindow = row ? Number(row.windowStart) : windowStart;
300
370
 
301
371
  _maybePrune();
302
372
 
@@ -318,10 +388,10 @@ function _clusterBackend(opts) {
318
388
  }
319
389
 
320
390
  async function reset(key) {
321
- await clusterStorage.execute(
322
- "DELETE FROM _blamejs_rate_limit_counters WHERE key = ?",
323
- [key]
324
- );
391
+ var built = sql.delete(_rateLimitSqlTable(), _rateLimitSqlOpts())
392
+ .where("key", key)
393
+ .toSql();
394
+ await clusterStorage.execute(built.sql, built.params);
325
395
  }
326
396
 
327
397
  function close() { /* no resources to release */ }
@@ -417,7 +487,7 @@ function create(opts) {
417
487
  // pass "RateLimit-", or a gateway's own prefix. Kept as a matched pair.
418
488
  var headerPrefix = (typeof opts.headerPrefix === "string" && opts.headerPrefix.length > 0)
419
489
  ? opts.headerPrefix : "X-RateLimit-";
420
- var limitHeader = headerPrefix + "Limit";
490
+ var limitHeader = headerPrefix + "Limit"; // allow:hand-rolled-sql — HTTP response-header name (X-RateLimit-Limit), not a SQL LIMIT clause
421
491
  var remainingHeader = headerPrefix + "Remaining";
422
492
  var skipPaths = opts.skipPaths || [];
423
493
  // Throw at create(): each entry must be a string prefix or a RegExp.
@@ -10,8 +10,12 @@
10
10
  * Referrer-Policy: no-referrer — don't leak full URL to outbound links
11
11
  * Permissions-Policy — disable common-attack APIs (camera, geolocation, payment, etc.)
12
12
  * Cross-Origin-Opener-Policy: same-origin
13
- * Cross-Origin-Embedder-Policy: require-corp / credentialless (off by default — breaks images from CDNs; credentialless is the
14
- * CR 2024-12 relaxed mode that lets cross-origin no-cors requests load without CORP markers as long as they don't carry credentials)
13
+ * Cross-Origin-Embedder-Policy: credentialless (default-onwith COOP
14
+ * same-origin this yields cross-origin isolation; credentialless is the
15
+ * relaxed enforcing mode that lets cross-origin no-cors requests load
16
+ * without CORP markers as long as they don't carry credentials, so CDN
17
+ * images/fonts keep working. Pass coep: "require-corp" to tighten, or
18
+ * coep: false to disable.)
15
19
  * Cross-Origin-Resource-Policy: same-origin
16
20
  * Origin-Agent-Cluster: ?1 — origin-keyed agent cluster; extra process isolation
17
21
  * X-DNS-Prefetch-Control: off — don't pre-resolve DNS for off-page links
@@ -173,8 +177,9 @@ function _validatePermissionsPolicy(value) {
173
177
  * nosniff, X-Frame-Options DENY, Referrer-Policy no-referrer, an
174
178
  * extensive Permissions-Policy denylist (camera / geolocation /
175
179
  * payment / Privacy-Sandbox attribution-reporting / bluetooth /
176
- * etc.), COOP same-origin, CORP same-origin, Origin-Agent-Cluster
177
- * `?1`, and a strict default CSP with `require-trusted-types-for
180
+ * etc.), COOP same-origin, COEP credentialless (cross-origin isolation
181
+ * on by default; pass `coep: false` to disable), CORP same-origin,
182
+ * Origin-Agent-Cluster `?1`, and a strict default CSP with `require-trusted-types-for
178
183
  * 'script'`. Each header can be softened by passing the option
179
184
  * value or disabled by passing `false`. Mount FIRST (after
180
185
  * `requestId`) so headers are set before any response could be
@@ -233,7 +238,18 @@ function create(opts) {
233
238
  var refPolicy = opts.referrerPolicy === undefined ? "no-referrer" : opts.referrerPolicy;
234
239
  var permPolicy = opts.permissionsPolicy === undefined ? DEFAULT_PERMISSIONS.join(", ") : opts.permissionsPolicy;
235
240
  var coop = opts.coop === undefined ? "same-origin" : opts.coop;
236
- var coep = opts.coep === undefined ? false : opts.coep;
241
+ // COEP default-on (v0.15.0): emit Cross-Origin-Embedder-Policy:
242
+ // credentialless. With COOP same-origin this completes cross-origin
243
+ // isolation (crossOriginIsolated === true), re-enabling SharedArrayBuffer
244
+ // / high-resolution timers while closing the Spectre-class cross-origin
245
+ // read surface. `credentialless` (HTML spec, shipped Chrome 110+) is the
246
+ // least-breaking enforcing mode: cross-origin no-cors subresources (CDN
247
+ // images, fonts) still load — they're fetched WITHOUT credentials rather
248
+ // than requiring an explicit CORP/CORS opt-in, so existing pages keep
249
+ // working where `require-corp` would have broken them. Operators serving
250
+ // credentialed cross-origin subresources pass coep: "require-corp" (and
251
+ // add CORP/CORS headers), or coep: false to opt out of COEP entirely.
252
+ var coep = opts.coep === undefined ? "credentialless" : opts.coep;
237
253
  var corp = opts.corp === undefined ? "same-origin" : opts.corp;
238
254
  var oac = opts.originAgentCluster === undefined ? "?1" : opts.originAgentCluster;
239
255
  var dpc = opts.dnsPrefetchControl === undefined ? "off" : opts.dnsPrefetchControl;