@blamejs/core 0.15.13 → 0.15.15

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 (208) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/index.js +2 -0
  3. package/lib/a2a-tasks.js +38 -6
  4. package/lib/agent-event-bus.js +13 -0
  5. package/lib/agent-idempotency.js +5 -1
  6. package/lib/agent-snapshot.js +32 -2
  7. package/lib/ai-aedt-bias-audit.js +2 -1
  8. package/lib/ai-content-detect.js +1 -3
  9. package/lib/ai-frontier-protocol.js +1 -1
  10. package/lib/ai-model-manifest.js +1 -1
  11. package/lib/ai-output.js +16 -7
  12. package/lib/api-snapshot.js +4 -1
  13. package/lib/app-shutdown.js +7 -1
  14. package/lib/archive-gz.js +9 -0
  15. package/lib/archive-read.js +9 -7
  16. package/lib/archive-tar-read.js +51 -8
  17. package/lib/archive.js +4 -2
  18. package/lib/asn1-der.js +70 -22
  19. package/lib/atomic-file.js +204 -2
  20. package/lib/audit-chain.js +54 -5
  21. package/lib/audit-daily-review.js +12 -2
  22. package/lib/audit-sign.js +2 -1
  23. package/lib/audit-tools.js +108 -23
  24. package/lib/audit.js +7 -2
  25. package/lib/auth/access-lock.js +2 -2
  26. package/lib/auth/bot-challenge.js +1 -1
  27. package/lib/auth/ciba.js +71 -8
  28. package/lib/auth/dpop.js +15 -1
  29. package/lib/auth/fido-mds3.js +30 -13
  30. package/lib/auth/jwt.js +21 -5
  31. package/lib/auth/lockout.js +18 -2
  32. package/lib/auth/oauth.js +8 -2
  33. package/lib/auth/passkey.js +1 -1
  34. package/lib/auth/password.js +1 -1
  35. package/lib/auth/saml.js +42 -12
  36. package/lib/auth/sd-jwt-vc.js +24 -4
  37. package/lib/auth/status-list.js +14 -2
  38. package/lib/auth/step-up.js +9 -1
  39. package/lib/auth-bot-challenge.js +21 -2
  40. package/lib/backup/bundle.js +7 -2
  41. package/lib/backup/crypto.js +23 -8
  42. package/lib/backup/index.js +41 -20
  43. package/lib/backup/manifest.js +7 -1
  44. package/lib/break-glass.js +41 -22
  45. package/lib/cbor.js +34 -11
  46. package/lib/cdn-cache-control.js +7 -3
  47. package/lib/cert.js +5 -3
  48. package/lib/cli.js +5 -1
  49. package/lib/cloud-events.js +3 -2
  50. package/lib/cluster-storage.js +7 -3
  51. package/lib/codepoint-class.js +17 -0
  52. package/lib/compliance-eaa.js +1 -1
  53. package/lib/compliance-sanctions.js +9 -7
  54. package/lib/compliance.js +1 -1
  55. package/lib/config-drift.js +22 -8
  56. package/lib/content-credentials.js +11 -8
  57. package/lib/content-digest.js +11 -4
  58. package/lib/cookies.js +10 -2
  59. package/lib/cose.js +20 -0
  60. package/lib/crdt.js +2 -1
  61. package/lib/crypto-field.js +48 -24
  62. package/lib/crypto.js +18 -4
  63. package/lib/csp.js +13 -0
  64. package/lib/daemon.js +4 -1
  65. package/lib/data-act.js +27 -4
  66. package/lib/db-file-lifecycle.js +14 -6
  67. package/lib/db-query.js +102 -11
  68. package/lib/db.js +32 -15
  69. package/lib/dora.js +43 -13
  70. package/lib/dr-runbook.js +1 -1
  71. package/lib/dsa.js +2 -1
  72. package/lib/dsr.js +22 -8
  73. package/lib/early-hints.js +19 -0
  74. package/lib/eat.js +5 -1
  75. package/lib/external-db.js +60 -4
  76. package/lib/fda-21cfr11.js +30 -5
  77. package/lib/flag-providers.js +6 -2
  78. package/lib/forms.js +1 -1
  79. package/lib/gate-contract.js +46 -5
  80. package/lib/gdpr-ropa.js +18 -9
  81. package/lib/graphql-federation.js +17 -4
  82. package/lib/guard-all.js +2 -2
  83. package/lib/guard-dsn.js +1 -1
  84. package/lib/guard-envelope.js +1 -1
  85. package/lib/guard-html.js +9 -11
  86. package/lib/guard-imap-command.js +1 -1
  87. package/lib/guard-jmap.js +1 -1
  88. package/lib/guard-json.js +14 -6
  89. package/lib/guard-mail-move.js +1 -1
  90. package/lib/guard-managesieve-command.js +1 -1
  91. package/lib/guard-pop3-command.js +1 -1
  92. package/lib/guard-smtp-command.js +1 -1
  93. package/lib/guard-svg.js +8 -9
  94. package/lib/html-balance.js +7 -3
  95. package/lib/http-client-cookie-jar.js +33 -12
  96. package/lib/http-client.js +225 -53
  97. package/lib/iab-tcf.js +3 -2
  98. package/lib/importmap-integrity.js +41 -1
  99. package/lib/incident-report.js +9 -6
  100. package/lib/json-patch.js +1 -1
  101. package/lib/json-path.js +24 -3
  102. package/lib/jtd.js +2 -2
  103. package/lib/legal-hold.js +24 -8
  104. package/lib/log.js +2 -2
  105. package/lib/mail-agent.js +2 -2
  106. package/lib/mail-arf.js +1 -1
  107. package/lib/mail-auth.js +27 -4
  108. package/lib/mail-bimi.js +16 -16
  109. package/lib/mail-bounce.js +3 -3
  110. package/lib/mail-crypto-smime.js +71 -6
  111. package/lib/mail-deploy.js +9 -5
  112. package/lib/mail-dkim.js +20 -7
  113. package/lib/mail-greylist.js +2 -4
  114. package/lib/mail-helo.js +2 -4
  115. package/lib/mail-journal.js +11 -8
  116. package/lib/mail-mdn.js +8 -4
  117. package/lib/mail-rbl.js +2 -4
  118. package/lib/mail-scan.js +3 -5
  119. package/lib/mail-server-jmap.js +4 -1
  120. package/lib/mail-server-registry.js +1 -1
  121. package/lib/mail-server-tls.js +9 -2
  122. package/lib/mail-spam-score.js +2 -4
  123. package/lib/mail-store-fts.js +1 -1
  124. package/lib/mail.js +22 -2
  125. package/lib/markup-tokenizer.js +24 -0
  126. package/lib/mcp.js +6 -4
  127. package/lib/mdoc.js +26 -3
  128. package/lib/metrics.js +14 -3
  129. package/lib/middleware/api-encrypt.js +2 -2
  130. package/lib/middleware/body-parser.js +10 -4
  131. package/lib/middleware/bot-guard.js +26 -18
  132. package/lib/middleware/clear-site-data.js +5 -1
  133. package/lib/middleware/compose-pipeline.js +39 -5
  134. package/lib/middleware/compression.js +9 -0
  135. package/lib/middleware/cors.js +32 -23
  136. package/lib/middleware/csrf-protect.js +60 -21
  137. package/lib/middleware/daily-byte-quota.js +6 -4
  138. package/lib/middleware/fetch-metadata.js +28 -4
  139. package/lib/middleware/network-allowlist.js +61 -30
  140. package/lib/middleware/rate-limit.js +25 -16
  141. package/lib/middleware/scim-server.js +2 -1
  142. package/lib/middleware/security-headers.js +24 -6
  143. package/lib/middleware/speculation-rules.js +6 -3
  144. package/lib/middleware/tus-upload.js +2 -2
  145. package/lib/money.js +1 -1
  146. package/lib/mtls-ca.js +10 -6
  147. package/lib/network-dns-resolver.js +1 -1
  148. package/lib/network-dns.js +9 -2
  149. package/lib/network-dnssec.js +2 -1
  150. package/lib/network-smtp-policy.js +23 -5
  151. package/lib/network-tls.js +27 -5
  152. package/lib/network-tsig.js +2 -2
  153. package/lib/nis2-report.js +1 -1
  154. package/lib/nist-crosswalk.js +1 -1
  155. package/lib/ntp-check.js +28 -0
  156. package/lib/numeric-bounds.js +9 -0
  157. package/lib/object-store/azure-blob.js +1 -2
  158. package/lib/object-store/gcs-bucket-ops.js +4 -2
  159. package/lib/object-store/gcs.js +6 -4
  160. package/lib/object-store/http-put.js +1 -2
  161. package/lib/object-store/http-request.js +30 -1
  162. package/lib/object-store/local.js +37 -17
  163. package/lib/object-store/sigv4.js +1 -2
  164. package/lib/observability-otlp-exporter.js +20 -4
  165. package/lib/outbox.js +11 -4
  166. package/lib/parsers/safe-xml.js +1 -1
  167. package/lib/parsers/safe-yaml.js +21 -3
  168. package/lib/pipl-cn.js +11 -8
  169. package/lib/queue-local.js +10 -3
  170. package/lib/redact.js +7 -3
  171. package/lib/request-helpers.js +347 -36
  172. package/lib/resource-access-lock.js +3 -3
  173. package/lib/restore-bundle.js +46 -18
  174. package/lib/restore-rollback.js +10 -4
  175. package/lib/restore.js +19 -0
  176. package/lib/retention.js +20 -4
  177. package/lib/router.js +17 -4
  178. package/lib/safe-ical.js +2 -2
  179. package/lib/safe-icap.js +1 -1
  180. package/lib/safe-json.js +70 -0
  181. package/lib/safe-sieve.js +1 -1
  182. package/lib/safe-vcard.js +1 -1
  183. package/lib/sandbox-worker.js +6 -0
  184. package/lib/sandbox.js +1 -1
  185. package/lib/scheduler.js +17 -1
  186. package/lib/self-update-standalone-verifier.js +16 -0
  187. package/lib/session.js +62 -120
  188. package/lib/sql.js +25 -3
  189. package/lib/static.js +65 -13
  190. package/lib/template.js +7 -5
  191. package/lib/tenant-quota.js +52 -19
  192. package/lib/tsa.js +5 -2
  193. package/lib/vault/index.js +5 -0
  194. package/lib/vault/passphrase-ops.js +22 -26
  195. package/lib/vault/passphrase-source.js +8 -3
  196. package/lib/vault/rotate.js +13 -18
  197. package/lib/vault/seal-pem-file.js +4 -1
  198. package/lib/vc.js +1 -1
  199. package/lib/vendor/MANIFEST.json +10 -10
  200. package/lib/vendor/public-suffix-list.dat +23 -10
  201. package/lib/vendor/public-suffix-list.data.js +5498 -5494
  202. package/lib/webhook.js +16 -1
  203. package/lib/websocket.js +1 -1
  204. package/lib/worm.js +1 -1
  205. package/lib/ws-client.js +83 -46
  206. package/lib/x509-chain.js +91 -0
  207. package/package.json +1 -1
  208. package/sbom.cdx.json +6 -6
@@ -771,9 +771,27 @@ function parse(input, opts) {
771
771
  }
772
772
 
773
773
  function _stripEolComment(text) {
774
- // Strip ` #...` comments (must be preceded by whitespace) at end of line.
775
- var match = text.match(/^(.*?)(\s+#.*)?$/);
776
- return safeBuffer.stripTrailingHspace(match && match[1] != null ? match[1] : text);
774
+ // Strip ` #...` comments (a YAML end-of-line comment must be preceded by
775
+ // whitespace) via a single linear scan. The previous /^(.*?)(\s+#.*)?$/
776
+ // backtracks polynomially on a long inline-whitespace run with no '#'
777
+ // (each position re-tries `\s+#`) — a ReDoS lever on attacker-supplied
778
+ // YAML. This finds the first whitespace-preceded '#' and cuts the
779
+ // preceding whitespace run, identical output without the catastrophic
780
+ // backtracking.
781
+ for (var i = 1; i < text.length; i++) {
782
+ if (text.charCodeAt(i) !== 0x23 /* # */) continue;
783
+ var prev = text.charCodeAt(i - 1);
784
+ // inline whitespace: space / tab / vtab / formfeed / CR
785
+ if (prev !== 0x20 && prev !== 0x09 && prev !== 0x0b && prev !== 0x0c && prev !== 0x0d) continue;
786
+ var end = i;
787
+ while (end > 0) {
788
+ var c = text.charCodeAt(end - 1);
789
+ if (c === 0x20 || c === 0x09 || c === 0x0b || c === 0x0c || c === 0x0d) end--;
790
+ else break;
791
+ }
792
+ return safeBuffer.stripTrailingHspace(text.slice(0, end));
793
+ }
794
+ return safeBuffer.stripTrailingHspace(text);
777
795
  }
778
796
 
779
797
  // ---- Block scalars (| literal, > folded) ----
package/lib/pipl-cn.js CHANGED
@@ -108,14 +108,17 @@ function _requireRecordedAt(value, label) {
108
108
  * and determine the lawful mechanism the transfer requires. PIPL Art. 38(1)
109
109
  * permits three bases for moving personal information out of the PRC — the
110
110
  * CAC standard contract (SCC), a CAC security assessment (Art. 40), or
111
- * certification by a CAC-accredited body but the Measures for Security
112
- * Assessment of Outbound Data Transfers make the security assessment
113
- * MANDATORY (the operator may NOT self-select the standard contract or
114
- * certification) when the exporter is a critical-information-infrastructure
115
- * operator (CIIO), exports "important data", handles personal information
116
- * of more than 1,000,000 individuals, or has cumulatively exported PI of
117
- * more than 100,000 individuals or sensitive PI of more than 10,000
118
- * individuals since 1 January of the preceding year.
111
+ * certification by a CAC-accredited body. Under the CAC Provisions on
112
+ * Promoting and Regulating Cross-Border Data Flows (effective 2024, which
113
+ * relaxed the 2022 thresholds) the security assessment is MANDATORY (the
114
+ * operator may NOT self-select the standard contract or certification) when
115
+ * the exporter is a critical-information-infrastructure operator (CIIO),
116
+ * exports "important data", or counting cumulatively since 1 January of
117
+ * the current year transfers the personal information of more than
118
+ * 1,000,000 individuals (non-sensitive) or the sensitive personal
119
+ * information of more than 10,000 individuals. The 100,000–1,000,000
120
+ * non-sensitive band is the standard-contract / certification tier, NOT a
121
+ * security-assessment trigger.
119
122
  *
120
123
  * The builder validates the operator-supplied facts, computes
121
124
  * `securityAssessmentRequired` against those thresholds, resolves the
@@ -406,7 +406,13 @@ function create(config) {
406
406
  .where("_id", jobId)
407
407
  .where("status", "inflight")
408
408
  .toSql();
409
- await store.execute(doneBuilt.sql, doneBuilt.params);
409
+ var doneRes = await store.execute(doneBuilt.sql, doneBuilt.params);
410
+ // Only run the post-completion side effects if THIS call actually flipped
411
+ // the row inflight→done. If the status guard matched 0 rows the job was
412
+ // already completed or re-leased to another worker (its lease expired and
413
+ // sweepExpired re-queued it) — a stale completer must NOT re-enqueue the
414
+ // cron repeat (duplicate firing) or release flow children twice.
415
+ if ((doneRes.rowCount || 0) === 0) return false;
410
416
 
411
417
  // Repeat-in-queue: cron-recurring job re-enqueues itself for the
412
418
  // next firing time. Failures (which take the fail() path) don't
@@ -513,9 +519,10 @@ function create(config) {
513
519
  [nowMs + retryDelayMs])
514
520
  .setRaw("finishedAt", "CASE WHEN " + attemptsLt + " THEN NULL ELSE ? END", [nowMs])
515
521
  .where("_id", jobId)
522
+ .where("status", "inflight") // lease-ownership guard — a stale fail() must not clobber a job re-leased to another worker after this one's lease expired (mirrors complete()/extendLease)
516
523
  .toSql();
517
- await store.execute(failBuilt.sql, failBuilt.params);
518
- return true;
524
+ var failRes = await store.execute(failBuilt.sql, failBuilt.params);
525
+ return (failRes.rowCount || 0) > 0;
519
526
  }
520
527
 
521
528
  async function sweepExpired() {
package/lib/redact.js CHANGED
@@ -278,12 +278,16 @@ function redact(value, opts) {
278
278
  function _redact(value, depth, maxDepth, marker, parentKey) {
279
279
  if (depth > maxDepth) return marker;
280
280
  if (value === null || value === undefined) return value;
281
+ // A sensitive parent key collapses the ENTIRE value — scalar OR composite —
282
+ // to the marker. Checking before the type branches is what stops a secret
283
+ // under e.g. `authorization: ["Bearer …"]` / `password: {…}` from slipping
284
+ // through the array/object branches (which recurse with parentKey=null), the
285
+ // way a scalar already does (CWE-532 telemetry egress).
286
+ if (parentKey && _isSensitiveFieldName(parentKey)) return marker;
281
287
  if (typeof value === "string") {
282
- if (parentKey && _isSensitiveFieldName(parentKey)) return marker;
283
288
  return _redactValue(value);
284
289
  }
285
290
  if (typeof value === "number" || typeof value === "boolean") {
286
- if (parentKey && _isSensitiveFieldName(parentKey)) return marker;
287
291
  return value;
288
292
  }
289
293
  if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
@@ -513,7 +517,7 @@ function classifyDefaults(opts) {
513
517
  "redact.classifyDefaults: patterns[" + p + "] must be a string, got " +
514
518
  typeof patterns[p]);
515
519
  }
516
- if (!CLASSIFIER_PATTERNS[patterns[p]] &&
520
+ if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_PATTERNS, patterns[p]) &&
517
521
  !(opts.extra && opts.extra[patterns[p]])) {
518
522
  throw new DlpError("redact-dlp/unknown-pattern",
519
523
  "redact.classifyDefaults: unknown pattern '" + patterns[p] +
@@ -44,6 +44,11 @@
44
44
  var structuredFields = require("./structured-fields");
45
45
  var pick = require("./pick");
46
46
  var codepointClass = require("./codepoint-class");
47
+ var lazyRequire = require("./lazy-require");
48
+ // Lazy — ssrf-guard pulls in the network/DNS stack, and request-helpers is
49
+ // required very early in the boot graph. Only touched at middleware-construction
50
+ // time by trustedClientIp(), never on the hot path.
51
+ var _ssrfGuard = lazyRequire(function () { return require("./ssrf-guard"); });
47
52
 
48
53
  var HTTP_STATUS = Object.freeze({
49
54
  OK: 0xC8,
@@ -214,15 +219,24 @@ function resolveActorWithOverride(callerOpts, baseOverride) {
214
219
  *
215
220
  * Resolve the originating client IP from a request. Default reads
216
221
  * only `req.socket.remoteAddress` — `X-Forwarded-For` is ignored
217
- * because without a sanitizing reverse proxy it's
218
- * attacker-forgeable. Behind a trusted proxy, operators opt in via
219
- * `trustProxy: true` (use the leftmost XFF hop) or
220
- * `trustProxy: <N>` (skip N trusted hops from the right and return
221
- * the Nth-from-rightmost). Returns `null` when no address can be
222
- * read never throws.
222
+ * because without a sanitizing reverse proxy it's attacker-forgeable.
223
+ *
224
+ * For an access-control decision (allowlist, rate-limit key, IP-bound
225
+ * grant), pass `trustProxy` as a PREDICATE `function(addr) => boolean`
226
+ * naming your trusted reverse proxies. The header is then honored only
227
+ * when the immediate TCP peer is itself a trusted proxy, and the client
228
+ * is the first untrusted address walking the chain right-to-left. A
229
+ * direct attacker cannot forge it — this is the only peer-gated form.
230
+ *
231
+ * The legacy `trustProxy: true` (leftmost XFF hop) and `trustProxy: <N>`
232
+ * (Nth-from-rightmost) forms do NOT verify the peer: a client connecting
233
+ * directly can forge any value. They are safe only when an upstream you
234
+ * control terminates and rewrites X-Forwarded-For on every request — never
235
+ * for a security decision on an internet-facing listener. Prefer the
236
+ * predicate form. Returns `null` when no address can be read — never throws.
223
237
  *
224
238
  * @opts
225
- * trustProxy: boolean | number // false (default) | true | hop count
239
+ * trustProxy: boolean | number | function // false (default) | predicate (peer-gated) | legacy true/hop-count
226
240
  *
227
241
  * @example
228
242
  * var req = {
@@ -232,30 +246,55 @@ function resolveActorWithOverride(callerOpts, baseOverride) {
232
246
  * b.requestHelpers.clientIp(req);
233
247
  * // → "10.0.0.1" (forwarded headers ignored by default)
234
248
  *
235
- * b.requestHelpers.clientIp(req, { trustProxy: true });
236
- * // → "203.0.113.7" (leftmost XFF hop)
249
+ * var fromTrusted = function (a) { return a.indexOf("10.") === 0; };
250
+ * b.requestHelpers.clientIp(req, { trustProxy: fromTrusted });
251
+ * // → "203.0.113.7" (peer 10.0.0.1 trusted; first untrusted hop)
237
252
  *
238
- * b.requestHelpers.clientIp(req, { trustProxy: 1 });
239
- * // "10.0.0.5" (1 trusted hop from the right)
253
+ * var forged = { socket: { remoteAddress: "198.51.100.66" },
254
+ * headers: { "x-forwarded-for": "203.0.113.7" } };
255
+ * b.requestHelpers.clientIp(forged, { trustProxy: fromTrusted });
256
+ * // → "198.51.100.66" (peer untrusted → forged header ignored)
240
257
  *
241
258
  * b.requestHelpers.clientIp(undefined);
242
259
  * // → null
243
260
  */
244
261
  function clientIp(req, opts) {
245
262
  if (!req) return null;
263
+ var socketAddr =
264
+ (req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
265
+ : (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
266
+ : null;
246
267
  var trust = opts && opts.trustProxy;
247
268
  if (trust && req.headers) {
248
269
  var xff = req.headers["x-forwarded-for"];
249
270
  if (xff) {
250
271
  var hops = parseListHeader(xff);
251
- if (trust === true) return hops[0];
252
- if (typeof trust === "number" && trust >= 1 && hops.length >= trust) {
253
- return hops[hops.length - trust];
272
+ if (hops.length) {
273
+ if (typeof trust === "function") {
274
+ // Peer-gated resolution: `trust(addr)` names the trusted reverse
275
+ // proxies. X-Forwarded-For is honored ONLY when the immediate TCP
276
+ // peer is itself a trusted proxy; the real client is then the first
277
+ // untrusted address walking the chain right-to-left (each hop is
278
+ // appended by the proxy that observed it). A direct attacker — whose
279
+ // socket peer is not a trusted proxy — cannot forge the result: the
280
+ // forgeable header is ignored and we fall through to the socket
281
+ // address. This is the only form safe for an access-control decision.
282
+ if (socketAddr && trust(socketAddr)) {
283
+ for (var i = hops.length - 1; i >= 0; i--) {
284
+ if (!trust(hops[i])) return hops[i];
285
+ }
286
+ return hops[0]; // entire chain trusted — earliest claimed client
287
+ }
288
+ // peer is not a trusted proxy → ignore forgeable XFF, fall through
289
+ } else if (trust === true) {
290
+ return hops[0];
291
+ } else if (typeof trust === "number" && trust >= 1 && hops.length >= trust) {
292
+ return hops[hops.length - trust];
293
+ }
254
294
  }
255
295
  }
256
296
  }
257
- if (req.socket && typeof req.socket.remoteAddress === "string") return req.socket.remoteAddress;
258
- if (req.connection && typeof req.connection.remoteAddress === "string") return req.connection.remoteAddress;
297
+ if (socketAddr) return socketAddr;
259
298
  // Express-shaped requests expose the resolved client address as `req.ip`
260
299
  // (Express derives it from the socket, honoring its own trust-proxy
261
300
  // setting) without a `socket.remoteAddress` surface. Fall back to it so a
@@ -266,6 +305,253 @@ function clientIp(req, opts) {
266
305
  return null;
267
306
  }
268
307
 
308
+ /**
309
+ * @primitive b.requestHelpers.trustedClientIp
310
+ * @signature b.requestHelpers.trustedClientIp(opts?)
311
+ * @since 0.15.14
312
+ * @related b.requestHelpers.clientIp
313
+ *
314
+ * Build a peer-gated client-IP resolver for an access-control decision
315
+ * (allowlist, rate-limit key, IP-bound grant). The bare `trustProxy`
316
+ * forms of `clientIp` are forgeable; this is the shape every gate shares
317
+ * so the trust model is identical across them. Returns
318
+ * `{ resolve(req), peerGated }`: `resolve` reads the client IP, `peerGated`
319
+ * is true when `trustedProxies` or `clientIpResolver` was supplied — a
320
+ * gate uses it to refuse a bare `trustProxy` at construction (fail closed).
321
+ *
322
+ * With `clientIpResolver(req)` the operator owns resolution entirely. With
323
+ * `trustedProxies` (CIDRs of the reverse proxies), `X-Forwarded-For` is
324
+ * honored only when the immediate peer is one of them. With neither, only
325
+ * the socket address is used and forwarded headers are ignored.
326
+ *
327
+ * @opts
328
+ * trustedProxies: string | string[], // CIDRs — peer-gate X-Forwarded-For
329
+ * clientIpResolver: function(req): string|null, // own resolution entirely
330
+ *
331
+ * @example
332
+ * var tip = b.requestHelpers.trustedClientIp({ trustedProxies: ["10.0.0.0/8"] });
333
+ * var ip = tip.resolve(req); // peer-gated; forged XFF from a direct caller ignored
334
+ */
335
+ // Build the trusted-proxy predicate shared by trustedClientIp / trustedProtocol.
336
+ // Validates each CIDR (a CIDR is valid iff it contains its own network address,
337
+ // reusing the same matcher the predicate uses so format rules can't diverge) and
338
+ // returns fn(addr)=>boolean, or null when no trustedProxies were given. `where`
339
+ // names the calling helper for the error message.
340
+ function _trustedProxyPredicate(trustedProxies, where) {
341
+ if (!trustedProxies || !trustedProxies.length) return null;
342
+ var ssrfGuard = _ssrfGuard();
343
+ for (var i = 0; i < trustedProxies.length; i++) {
344
+ var cidr = trustedProxies[i];
345
+ var slash = typeof cidr === "string" ? cidr.indexOf("/") : -1;
346
+ if (slash === -1 || !ssrfGuard.cidrContains(cidr, cidr.slice(0, slash))) {
347
+ throw new TypeError(where + ": trustedProxies[" + i + "] is not a valid CIDR, got " + JSON.stringify(cidr));
348
+ }
349
+ }
350
+ return function (addr) {
351
+ // Fold an IPv4-mapped IPv6 peer (::ffff:a.b.c.d, common on a dual-stack
352
+ // listener) to its dotted IPv4 form so it matches an IPv4 trustedProxies
353
+ // CIDR — cidrContains rejects a cross-family compare, so without this a
354
+ // mapped proxy peer reads as untrusted and X-Forwarded-* is ignored. Only
355
+ // the ::ffff:0:0/96 block folds (canonicalizeHost leaves NAT64 / 6to4 as
356
+ // IPv6), so this can't widen the trusted set.
357
+ var canon = ssrfGuard.canonicalizeHost(addr);
358
+ for (var j = 0; j < trustedProxies.length; j++) {
359
+ if (ssrfGuard.cidrContains(trustedProxies[j], canon)) return true;
360
+ }
361
+ return false;
362
+ };
363
+ }
364
+
365
+ function _normTrustedProxies(opts) {
366
+ return Array.isArray(opts.trustedProxies) ? opts.trustedProxies.slice()
367
+ : (typeof opts.trustedProxies === "string" && opts.trustedProxies.length ? [opts.trustedProxies] : []);
368
+ }
369
+
370
+ function trustedClientIp(opts) {
371
+ opts = opts || {};
372
+ var resolver = opts.clientIpResolver;
373
+ if (resolver != null && typeof resolver !== "function") {
374
+ throw new TypeError("trustedClientIp: clientIpResolver must be a function(req) => ip|null");
375
+ }
376
+ var predicate = _trustedProxyPredicate(_normTrustedProxies(opts), "trustedClientIp");
377
+ return {
378
+ peerGated: !!(resolver || predicate),
379
+ resolve: function (req) {
380
+ if (resolver) return resolver(req);
381
+ if (predicate) return clientIp(req, { trustProxy: predicate });
382
+ return clientIp(req, { trustProxy: false });
383
+ },
384
+ };
385
+ }
386
+
387
+ // IP-prefix masking constants — named so the bit-arithmetic stays readable.
388
+ // /24 IPv4 is the original IP-geolocation bucket and matches the legacy
389
+ // carrier-NAT pool stride; /64 IPv6 is the customer LAN every RIR allocates
390
+ // (RFC 4291 §2.5.4), so tightening below it punishes IPv6 privacy-extension
391
+ // address rotation.
392
+ var IP_BITS_PER_BYTE = 8; // bits per byte; protocol constant, not a byte size
393
+ var IPV4_OCTET_COUNT = 4;
394
+ var IPV4_OCTET_RANGE = 256; // 0..255 inclusive; v4 octet domain
395
+ var IPV4_TOTAL_BITS = 32; // IPv4 address width in bits
396
+ var IPV4_DEFAULT_PREFIX = 24; // /24 carrier-NAT pool stride
397
+ var IPV6_GROUP_COUNT = 8; // 8 16-bit groups in v6
398
+ var IPV6_BYTE_COUNT = 16; // 16 bytes in v6
399
+ var IPV6_DEFAULT_PREFIX = 64; // /64 customer LAN per RFC 4291 §2.5.4
400
+ var IP_BYTE_MASK = 0xff;
401
+ var IP_HEX_RADIX = 16; // base-16 radix
402
+ var V4_MAPPED_V6_PREFIX = "::ffff:";
403
+
404
+ function _maskIpv4(ip, prefix) {
405
+ // ip = "a.b.c.d"; prefix is bits to keep (1..32).
406
+ var parts = String(ip).split(".");
407
+ if (parts.length !== IPV4_OCTET_COUNT) return null;
408
+ var n = 0;
409
+ for (var i = 0; i < IPV4_OCTET_COUNT; i++) {
410
+ var oct = parseInt(parts[i], 10);
411
+ if (!Number.isInteger(oct) || oct < 0 || oct >= IPV4_OCTET_RANGE) return null;
412
+ n = (n * IPV4_OCTET_RANGE) + oct;
413
+ }
414
+ // Apply prefix mask.
415
+ var mask = prefix === 0 ? 0 : (-1 >>> (IPV4_TOTAL_BITS - prefix)) << (IPV4_TOTAL_BITS - prefix);
416
+ // Bitwise on 32-bit unsigned. JS coerces to 32-bit signed, so use
417
+ // unsigned right shift to recover.
418
+ var masked = (n & mask) >>> 0;
419
+ return ((masked >>> IP_BITS_PER_BYTE * 3) & IP_BYTE_MASK) + "." +
420
+ ((masked >>> IP_BITS_PER_BYTE * 2) & IP_BYTE_MASK) + "." +
421
+ ((masked >>> IP_BITS_PER_BYTE) & IP_BYTE_MASK) + "." +
422
+ (masked & IP_BYTE_MASK) + "/" + prefix;
423
+ }
424
+
425
+ function _maskIpv6(ip, prefix) {
426
+ // Expand to 8 16-bit groups. Accept :: shorthand. Reject if invalid.
427
+ var raw = String(ip).toLowerCase();
428
+ // Strip an embedded zone id (fe80::1%eth0); not part of the address.
429
+ var pct = raw.indexOf("%");
430
+ if (pct !== -1) raw = raw.substring(0, pct);
431
+ var doubleColonAt = raw.indexOf("::");
432
+ var groups;
433
+ if (doubleColonAt === -1) {
434
+ groups = raw.split(":");
435
+ if (groups.length !== IPV6_GROUP_COUNT) return null;
436
+ } else {
437
+ var left = raw.substring(0, doubleColonAt).split(":");
438
+ var right = raw.substring(doubleColonAt + 2).split(":");
439
+ if (left.length === 1 && left[0] === "") left = [];
440
+ if (right.length === 1 && right[0] === "") right = [];
441
+ var fillCount = IPV6_GROUP_COUNT - left.length - right.length;
442
+ if (fillCount < 0) return null;
443
+ var middle = [];
444
+ for (var fi = 0; fi < fillCount; fi++) middle.push("0");
445
+ groups = left.concat(middle).concat(right);
446
+ }
447
+ // Each group is 1–4 hex chars.
448
+ var bytes = [];
449
+ for (var gi = 0; gi < IPV6_GROUP_COUNT; gi++) {
450
+ var g = groups[gi];
451
+ if (typeof g !== "string" || g.length === 0 || g.length > 4 || /[^0-9a-f]/.test(g)) return null;
452
+ var v = parseInt(g, IP_HEX_RADIX);
453
+ if (!Number.isInteger(v) || v < 0 || v > 0xffff) return null;
454
+ bytes.push((v >> IP_BITS_PER_BYTE) & IP_BYTE_MASK);
455
+ bytes.push(v & IP_BYTE_MASK);
456
+ }
457
+ // Apply prefix in bits.
458
+ var keepBytes = Math.floor(prefix / IP_BITS_PER_BYTE);
459
+ var keepBits = prefix % IP_BITS_PER_BYTE;
460
+ for (var bi = 0; bi < IPV6_BYTE_COUNT; bi++) {
461
+ if (bi < keepBytes) continue;
462
+ if (bi === keepBytes && keepBits > 0) {
463
+ var m = (IP_BYTE_MASK << (IP_BITS_PER_BYTE - keepBits)) & IP_BYTE_MASK;
464
+ bytes[bi] = bytes[bi] & m;
465
+ } else {
466
+ bytes[bi] = 0;
467
+ }
468
+ }
469
+ // Re-emit as colon-hex (no compression — deterministic for hashing).
470
+ var out = [];
471
+ for (var oi = 0; oi < IPV6_BYTE_COUNT; oi += 2) {
472
+ out.push(((bytes[oi] << IP_BITS_PER_BYTE) | bytes[oi + 1]).toString(IP_HEX_RADIX));
473
+ }
474
+ return out.join(":") + "/" + prefix;
475
+ }
476
+
477
+ /**
478
+ * @primitive b.requestHelpers.ipPrefix
479
+ * @signature b.requestHelpers.ipPrefix(ip)
480
+ * @since 0.15.15
481
+ * @related b.requestHelpers.clientIp, b.requestHelpers.trustedClientIp
482
+ *
483
+ * Mask a client IP to its subnet bucket: a <code>/24</code> for IPv4 (the
484
+ * carrier-NAT pool stride) and a <code>/64</code> for IPv6 (the customer-LAN
485
+ * prefix RIRs allocate, RFC 4291 §2.5.4). Returns the canonical
486
+ * <code>"network/prefix"</code> string, or <code>""</code> for a non-string /
487
+ * empty / unparseable input. An IPv4-mapped IPv6 address
488
+ * (<code>::ffff:1.2.3.4</code>) folds to its dotted form so it buckets the
489
+ * same regardless of how a proxy reported it.
490
+ *
491
+ * This is the masking the session device-fingerprint's built-in
492
+ * <code>clientIpPrefix</code> field hashes (so roaming carriers that flip the
493
+ * public IP within a subnet don't log a user out). Exposed so an operator who
494
+ * drops to a function-form fingerprint field — for a custom mask width, or to
495
+ * combine the prefix with other signals — reuses this exact algorithm instead
496
+ * of re-deriving the /24 + /64 masking (and silently diverging).
497
+ *
498
+ * @example
499
+ * b.requestHelpers.ipPrefix("203.0.113.47"); // → "203.0.113.0/24"
500
+ * b.requestHelpers.ipPrefix("2001:db8::1"); // → "2001:db8:0:0/64"
501
+ */
502
+ function ipPrefix(ip) {
503
+ if (typeof ip !== "string" || ip.length === 0) return "";
504
+ // IPv4-mapped IPv6 (::ffff:1.2.3.4) — strip the wrapper so the v4 mask
505
+ // applies. Same bucket regardless of how the proxy reported it.
506
+ var lower = ip.toLowerCase();
507
+ if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
508
+ return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), IPV4_DEFAULT_PREFIX) || "";
509
+ }
510
+ if (ip.indexOf(":") !== -1) return _maskIpv6(ip, IPV6_DEFAULT_PREFIX) || "";
511
+ if (ip.indexOf(".") !== -1) return _maskIpv4(ip, IPV4_DEFAULT_PREFIX) || "";
512
+ return "";
513
+ }
514
+
515
+ /**
516
+ * @primitive b.requestHelpers.trustedProtocol
517
+ * @signature b.requestHelpers.trustedProtocol(opts?)
518
+ * @since 0.15.14
519
+ * @related b.requestHelpers.requestProtocol, b.requestHelpers.trustedClientIp
520
+ *
521
+ * Peer-gated companion to trustedClientIp for the request scheme. The
522
+ * Secure-cookie / HSTS / secure-context decisions hinge on whether a request
523
+ * arrived over HTTPS; behind a TLS-terminating proxy that comes from
524
+ * X-Forwarded-Proto, which is forgeable unless the immediate peer is a trusted
525
+ * proxy. Returns `{ resolve(req)=>"http"|"https", peerGated }`. With
526
+ * `trustedProxies` (CIDRs) the header is honored only from a trusted peer; with
527
+ * `protocolResolver(req)` the operator owns the decision; with neither only the
528
+ * real TLS socket is consulted (forwarded headers ignored).
529
+ *
530
+ * @opts
531
+ * trustedProxies: string | string[],
532
+ * protocolResolver: function(req): "http"|"https",
533
+ *
534
+ * @example
535
+ * var tp = b.requestHelpers.trustedProtocol({ trustedProxies: ["10.0.0.0/8"] });
536
+ * tp.resolve(req); // "https" only when X-Forwarded-Proto came via a trusted peer
537
+ */
538
+ function trustedProtocol(opts) {
539
+ opts = opts || {};
540
+ var resolver = opts.protocolResolver;
541
+ if (resolver != null && typeof resolver !== "function") {
542
+ throw new TypeError("trustedProtocol: protocolResolver must be a function(req) => 'http'|'https'");
543
+ }
544
+ var predicate = _trustedProxyPredicate(_normTrustedProxies(opts), "trustedProtocol");
545
+ return {
546
+ peerGated: !!(resolver || predicate),
547
+ resolve: function (req) {
548
+ if (resolver) return resolver(req);
549
+ if (predicate) return requestProtocol(req, { trustProxy: predicate });
550
+ return requestProtocol(req, { trustProxy: false });
551
+ },
552
+ };
553
+ }
554
+
269
555
  /**
270
556
  * @primitive b.requestHelpers.requestProtocol
271
557
  * @signature b.requestHelpers.requestProtocol(req, opts?)
@@ -274,14 +560,17 @@ function clientIp(req, opts) {
274
560
  *
275
561
  * Resolve the inbound transport scheme. Default returns `"https"`
276
562
  * when `req.socket.encrypted` is set, otherwise `"http"`. Behind a
277
- * trusted reverse proxy that terminates TLS, set `trustProxy: true`
278
- * to read the leftmost `X-Forwarded-Proto` hop instead — without
279
- * the explicit opt-in the framework refuses to pick up the
280
- * attacker-forgeable header. Always returns a string; on bad input
281
- * falls back to `"http"`.
563
+ * trusted reverse proxy that terminates TLS, pass `trustProxy` as a
564
+ * PREDICATE `function(addr)=>boolean` naming your proxies:
565
+ * `X-Forwarded-Proto` is then honored only when the immediate peer is
566
+ * a trusted proxy, so a direct caller can't forge it (use
567
+ * `b.requestHelpers.trustedProtocol` to build this). The legacy
568
+ * `trustProxy: true` reads the leftmost hop without checking the peer —
569
+ * forgeable, safe only behind an edge that rewrites the header. Always
570
+ * returns a string; on bad input falls back to `"http"`.
282
571
  *
283
572
  * @opts
284
- * trustProxy: boolean // false (default) | true
573
+ * trustProxy: boolean | function // false (default) | predicate (peer-gated) | legacy true
285
574
  *
286
575
  * @example
287
576
  * var req = { socket: { encrypted: true } };
@@ -305,7 +594,22 @@ function requestProtocol(req, opts) {
305
594
  var fwd = req.headers["x-forwarded-proto"];
306
595
  if (typeof fwd === "string" && fwd.length > 0) {
307
596
  var hops = parseListHeader(fwd, { lowercase: true });
308
- if (hops.length > 0) return hops[0];
597
+ if (hops.length > 0) {
598
+ if (typeof trust === "function") {
599
+ // Peer-gated: honor X-Forwarded-Proto only when the immediate TCP
600
+ // peer is a trusted proxy. A direct caller's forged header is
601
+ // ignored — fall through to the real TLS socket. The only form safe
602
+ // for a Secure-cookie / HSTS / secure-context decision.
603
+ var peer =
604
+ (req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
605
+ : (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
606
+ : null;
607
+ if (peer && trust(peer)) return hops[0];
608
+ // peer not a trusted proxy → ignore forgeable header, fall through
609
+ } else {
610
+ return hops[0]; // legacy true/number — spoofable, see docstring
611
+ }
612
+ }
309
613
  }
310
614
  }
311
615
  if (req.socket && req.socket.encrypted) return "https";
@@ -611,8 +915,6 @@ function captureResponseStatus(res, onEnd) {
611
915
  return origEnd;
612
916
  }
613
917
 
614
- var Q_VALUE_RE = /(?:^|;|\s)q\s*=\s*([0-9]*\.?[0-9]+)/i;
615
-
616
918
  /**
617
919
  * @primitive b.requestHelpers.parseQualityList
618
920
  * @signature b.requestHelpers.parseQualityList(headerValue, opts?)
@@ -655,24 +957,30 @@ function parseQualityList(headerValue, opts) {
655
957
  if (typeof headerValue !== "string" || headerValue.length === 0) return [];
656
958
  opts = opts || {};
657
959
  var caseSensitive = opts.caseSensitive === true;
658
- var parts = headerValue.split(",");
960
+ // Quote-aware split: a parameter value may be a quoted-string containing ','
961
+ // or ';' or 'q=' (RFC 7231 §5.3.1 / RFC 9110), which must not split an element
962
+ // or be mis-read as the q-value. splitUnquoted respects double-quoted runs.
963
+ var parts = structuredFields.splitUnquoted(headerValue, ",");
659
964
  var out = [];
660
965
  for (var i = 0; i < parts.length; i++) {
661
966
  var p = parts[i].trim();
662
967
  if (p.length === 0) continue;
663
- var semi = p.indexOf(";");
664
- var value, q;
665
- if (semi === -1) {
666
- value = caseSensitive ? p : p.toLowerCase();
667
- q = 1;
668
- } else {
669
- var head = p.slice(0, semi).trim();
670
- value = caseSensitive ? head : head.toLowerCase();
671
- var rest = p.slice(semi + 1).trim();
672
- var qm = rest.match(Q_VALUE_RE);
968
+ var segs = structuredFields.splitUnquoted(p, ";");
969
+ var head = segs[0].trim();
970
+ var value = caseSensitive ? head : head.toLowerCase();
971
+ var q = 1;
972
+ // The q parameter is the named token `q` separating media-type params from
973
+ // accept-ext; the FIRST `q` parameter is the quality. Only a parameter
974
+ // literally named `q` counts — never a `q=`-shaped substring of another
975
+ // parameter's name or quoted value.
976
+ for (var s = 1; s < segs.length; s++) {
977
+ var kv = structuredFields.parseKeyValuePiece(segs[s], "=", true);
978
+ if (kv.key !== "q") continue;
979
+ var qm = String(kv.value).trim().match(/^([0-9]*\.?[0-9]+)/);
673
980
  q = qm ? parseFloat(qm[1]) : 1;
674
981
  if (isNaN(q) || q < 0) q = 0;
675
982
  if (q > 1) q = 1;
983
+ break;
676
984
  }
677
985
  out.push({ value: value, q: q });
678
986
  }
@@ -890,7 +1198,10 @@ module.exports = {
890
1198
  parseListHeader: parseListHeader,
891
1199
  // proxy-trust primitives (default refuses forwarded headers)
892
1200
  clientIp: clientIp,
1201
+ trustedClientIp: trustedClientIp,
1202
+ ipPrefix: ipPrefix,
893
1203
  requestProtocol: requestProtocol,
1204
+ trustedProtocol: trustedProtocol,
894
1205
  appendVary: appendVary,
895
1206
  // CVE-2026-21710 wrap — safe alternative to req.headersDistinct
896
1207
  safeHeadersDistinct: safeHeadersDistinct,
@@ -49,7 +49,7 @@ function create(opts) {
49
49
  validateOpts.requireNonEmptyString(opts.resource, "resource",
50
50
  ResourceAccessLockError, "resource-access-lock/no-resource");
51
51
  var startMode = opts.startMode || "open";
52
- if (!VALID_MODES[startMode]) {
52
+ if (!Object.prototype.hasOwnProperty.call(VALID_MODES, startMode)) {
53
53
  throw new ResourceAccessLockError(
54
54
  "resource-access-lock/bad-start-mode",
55
55
  "startMode must be one of: " + Object.keys(VALID_MODES).join(" / "));
@@ -71,12 +71,12 @@ function create(opts) {
71
71
  function permits(action) {
72
72
  if (mode === "open") return true;
73
73
  if (mode === "locked") return false;
74
- return !!READ_ACTIONS[action];
74
+ return !!Object.prototype.hasOwnProperty.call(READ_ACTIONS, action);
75
75
  }
76
76
 
77
77
  function set(newMode, ctx) {
78
78
  ctx = ctx || {};
79
- if (!VALID_MODES[newMode]) {
79
+ if (!Object.prototype.hasOwnProperty.call(VALID_MODES, newMode)) {
80
80
  throw new ResourceAccessLockError(
81
81
  "resource-access-lock/bad-mode",
82
82
  "set: mode must be one of: " + Object.keys(VALID_MODES).join(" / "));