@blamejs/core 0.15.12 → 0.15.14

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 (365) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/index.js +2 -0
  3. package/lib/a2a-tasks.js +45 -29
  4. package/lib/acme.js +6 -5
  5. package/lib/agent-event-bus.js +18 -4
  6. package/lib/agent-idempotency.js +7 -6
  7. package/lib/agent-orchestrator.js +2 -5
  8. package/lib/agent-saga.js +3 -5
  9. package/lib/agent-snapshot.js +32 -2
  10. package/lib/agent-tenant.js +2 -5
  11. package/lib/ai-adverse-decision.js +2 -15
  12. package/lib/ai-aedt-bias-audit.js +2 -1
  13. package/lib/ai-capability.js +1 -6
  14. package/lib/ai-content-detect.js +1 -3
  15. package/lib/ai-dp.js +1 -5
  16. package/lib/ai-frontier-protocol.js +1 -1
  17. package/lib/ai-input.js +2 -2
  18. package/lib/ai-model-manifest.js +1 -1
  19. package/lib/ai-output.js +16 -7
  20. package/lib/ai-pref.js +3 -8
  21. package/lib/ai-quota.js +3 -14
  22. package/lib/api-key.js +37 -28
  23. package/lib/api-snapshot.js +4 -1
  24. package/lib/app-shutdown.js +7 -1
  25. package/lib/archive-adapters.js +2 -4
  26. package/lib/archive-entry-policy.js +32 -0
  27. package/lib/archive-gz.js +9 -0
  28. package/lib/archive-read.js +14 -24
  29. package/lib/archive-tar-read.js +56 -24
  30. package/lib/archive.js +6 -12
  31. package/lib/arg-parser.js +7 -6
  32. package/lib/asn1-der.js +70 -22
  33. package/lib/asyncapi-traits.js +2 -6
  34. package/lib/atomic-file.js +312 -33
  35. package/lib/audit-chain.js +183 -54
  36. package/lib/audit-daily-review.js +36 -16
  37. package/lib/audit-emit.js +82 -0
  38. package/lib/audit-sign.js +258 -0
  39. package/lib/audit-tools.js +108 -23
  40. package/lib/audit.js +91 -2
  41. package/lib/auth/access-lock.js +7 -29
  42. package/lib/auth/bot-challenge.js +1 -1
  43. package/lib/auth/ciba.js +43 -4
  44. package/lib/auth/dpop.js +29 -36
  45. package/lib/auth/fido-mds3.js +14 -16
  46. package/lib/auth/jwt-external.js +26 -8
  47. package/lib/auth/jwt.js +15 -17
  48. package/lib/auth/lockout.js +24 -27
  49. package/lib/auth/oauth.js +67 -45
  50. package/lib/auth/oid4vci.js +55 -32
  51. package/lib/auth/oid4vp.js +3 -2
  52. package/lib/auth/openid-federation.js +6 -6
  53. package/lib/auth/passkey.js +4 -4
  54. package/lib/auth/password.js +8 -2
  55. package/lib/auth/saml.js +70 -40
  56. package/lib/auth/sd-jwt-vc-holder.js +2 -14
  57. package/lib/auth/sd-jwt-vc-issuer.js +2 -14
  58. package/lib/auth/sd-jwt-vc.js +25 -5
  59. package/lib/auth/status-list.js +21 -9
  60. package/lib/auth/step-up.js +15 -13
  61. package/lib/auth-bot-challenge.js +27 -39
  62. package/lib/backup/bundle.js +18 -20
  63. package/lib/backup/crypto.js +23 -8
  64. package/lib/backup/index.js +55 -67
  65. package/lib/backup/manifest.js +7 -1
  66. package/lib/bounded-map.js +112 -1
  67. package/lib/breach-deadline.js +1 -11
  68. package/lib/break-glass.js +41 -22
  69. package/lib/cache.js +7 -18
  70. package/lib/cbor.js +36 -12
  71. package/lib/cdn-cache-control.js +15 -12
  72. package/lib/cert.js +8 -13
  73. package/lib/chain-writer.js +162 -47
  74. package/lib/cli.js +10 -2
  75. package/lib/client-hints.js +7 -9
  76. package/lib/cloud-events.js +43 -33
  77. package/lib/cluster-storage.js +9 -41
  78. package/lib/cms-codec.js +2 -1
  79. package/lib/codepoint-class.js +84 -1
  80. package/lib/compliance-ai-act-logging.js +1 -6
  81. package/lib/compliance-ai-act-transparency.js +2 -4
  82. package/lib/compliance-eaa.js +2 -12
  83. package/lib/compliance-sanctions-fetcher.js +21 -28
  84. package/lib/compliance-sanctions.js +11 -21
  85. package/lib/compliance.js +3 -10
  86. package/lib/config-drift.js +24 -20
  87. package/lib/content-credentials.js +11 -8
  88. package/lib/content-digest.js +21 -12
  89. package/lib/cookies.js +15 -13
  90. package/lib/cose.js +39 -11
  91. package/lib/cra-report.js +1 -11
  92. package/lib/crdt.js +2 -1
  93. package/lib/crypto-field.js +34 -33
  94. package/lib/crypto.js +138 -7
  95. package/lib/csp.js +239 -3
  96. package/lib/daemon.js +46 -42
  97. package/lib/data-act.js +46 -13
  98. package/lib/db-file-lifecycle.js +14 -6
  99. package/lib/db-query.js +75 -49
  100. package/lib/db.js +66 -27
  101. package/lib/dbsc.js +5 -6
  102. package/lib/ddl-change-control.js +18 -14
  103. package/lib/deprecate.js +4 -5
  104. package/lib/did.js +6 -2
  105. package/lib/dora.js +43 -13
  106. package/lib/dr-runbook.js +1 -1
  107. package/lib/dsa.js +2 -1
  108. package/lib/dsr.js +30 -20
  109. package/lib/early-hints.js +19 -0
  110. package/lib/eat.js +5 -1
  111. package/lib/external-db-migrate.js +21 -22
  112. package/lib/external-db.js +74 -30
  113. package/lib/fda-21cfr11.js +42 -13
  114. package/lib/fdx.js +22 -18
  115. package/lib/file-upload.js +80 -66
  116. package/lib/flag-evaluation-context.js +5 -8
  117. package/lib/flag-providers.js +6 -2
  118. package/lib/forms.js +1 -1
  119. package/lib/framework-error.js +12 -0
  120. package/lib/framework-schema.js +19 -0
  121. package/lib/fsm.js +7 -12
  122. package/lib/gate-contract.js +911 -39
  123. package/lib/gdpr-ropa.js +22 -22
  124. package/lib/graphql-federation.js +18 -5
  125. package/lib/guard-agent-registry.js +2 -1
  126. package/lib/guard-all.js +3 -2
  127. package/lib/guard-archive.js +9 -30
  128. package/lib/guard-auth.js +23 -80
  129. package/lib/guard-cidr.js +20 -96
  130. package/lib/guard-csv.js +135 -196
  131. package/lib/guard-domain.js +23 -106
  132. package/lib/guard-dsn.js +17 -14
  133. package/lib/guard-email.js +46 -53
  134. package/lib/guard-envelope.js +2 -2
  135. package/lib/guard-event-bus-topic.js +2 -1
  136. package/lib/guard-filename.js +12 -60
  137. package/lib/guard-graphql.js +28 -75
  138. package/lib/guard-html-wcag.js +15 -2
  139. package/lib/guard-html.js +74 -128
  140. package/lib/guard-idempotency-key.js +2 -1
  141. package/lib/guard-image.js +280 -77
  142. package/lib/guard-imap-command.js +9 -10
  143. package/lib/guard-jmap.js +1 -1
  144. package/lib/guard-json.js +101 -109
  145. package/lib/guard-jsonpath.js +20 -88
  146. package/lib/guard-jwt.js +32 -114
  147. package/lib/guard-list-id.js +2 -7
  148. package/lib/guard-list-unsubscribe.js +2 -7
  149. package/lib/guard-mail-compose.js +5 -6
  150. package/lib/guard-mail-move.js +3 -2
  151. package/lib/guard-mail-query.js +5 -3
  152. package/lib/guard-mail-sieve.js +6 -7
  153. package/lib/guard-managesieve-command.js +6 -5
  154. package/lib/guard-markdown.js +76 -110
  155. package/lib/guard-message-id.js +5 -6
  156. package/lib/guard-mime.js +20 -99
  157. package/lib/guard-oauth.js +19 -73
  158. package/lib/guard-pdf.js +65 -72
  159. package/lib/guard-pop3-command.js +13 -14
  160. package/lib/guard-posture-chain.js +2 -1
  161. package/lib/guard-regex.js +24 -99
  162. package/lib/guard-saga-config.js +2 -1
  163. package/lib/guard-shell.js +22 -87
  164. package/lib/guard-smtp-command.js +9 -12
  165. package/lib/guard-sql.js +15 -13
  166. package/lib/guard-stream-args.js +2 -1
  167. package/lib/guard-svg.js +103 -149
  168. package/lib/guard-template.js +23 -80
  169. package/lib/guard-tenant-id.js +2 -1
  170. package/lib/guard-text.js +592 -0
  171. package/lib/guard-time.js +27 -95
  172. package/lib/guard-uuid.js +21 -93
  173. package/lib/guard-xml.js +76 -106
  174. package/lib/guard-yaml.js +24 -60
  175. package/lib/html-balance.js +7 -3
  176. package/lib/http-client-cache.js +5 -12
  177. package/lib/http-client-cookie-jar.js +35 -16
  178. package/lib/http-client.js +233 -74
  179. package/lib/http-message-signature.js +3 -2
  180. package/lib/i18n-messageformat.js +5 -7
  181. package/lib/i18n.js +83 -26
  182. package/lib/iab-tcf.js +3 -2
  183. package/lib/importmap-integrity.js +41 -1
  184. package/lib/inbox.js +8 -8
  185. package/lib/incident-report.js +12 -27
  186. package/lib/ip-utils.js +49 -6
  187. package/lib/jobs.js +3 -2
  188. package/lib/json-patch.js +1 -1
  189. package/lib/json-path.js +24 -3
  190. package/lib/jtd.js +2 -2
  191. package/lib/keychain.js +6 -18
  192. package/lib/legal-hold.js +30 -23
  193. package/lib/log-stream-cloudwatch.js +44 -112
  194. package/lib/log-stream-otlp-grpc.js +17 -14
  195. package/lib/log-stream-otlp.js +16 -80
  196. package/lib/log-stream-webhook.js +20 -92
  197. package/lib/log.js +2 -2
  198. package/lib/mail-agent.js +2 -2
  199. package/lib/mail-arc-sign.js +8 -3
  200. package/lib/mail-arf.js +4 -3
  201. package/lib/mail-auth.js +43 -69
  202. package/lib/mail-bimi.js +35 -55
  203. package/lib/mail-bounce.js +3 -3
  204. package/lib/mail-crypto-pgp.js +3 -2
  205. package/lib/mail-crypto-smime.js +71 -6
  206. package/lib/mail-dav.js +8 -35
  207. package/lib/mail-deploy.js +15 -14
  208. package/lib/mail-dkim.js +19 -38
  209. package/lib/mail-greylist.js +17 -30
  210. package/lib/mail-helo.js +4 -7
  211. package/lib/mail-journal.js +13 -9
  212. package/lib/mail-mdn.js +12 -7
  213. package/lib/mail-rbl.js +7 -12
  214. package/lib/mail-scan.js +5 -7
  215. package/lib/mail-send-deliver.js +33 -20
  216. package/lib/mail-server-imap.js +32 -87
  217. package/lib/mail-server-jmap.js +39 -52
  218. package/lib/mail-server-managesieve.js +29 -83
  219. package/lib/mail-server-mx.js +33 -74
  220. package/lib/mail-server-net.js +177 -0
  221. package/lib/mail-server-pop3.js +30 -83
  222. package/lib/mail-server-rate-limit.js +30 -6
  223. package/lib/mail-server-registry.js +1 -1
  224. package/lib/mail-server-submission.js +34 -73
  225. package/lib/mail-server-tls.js +98 -2
  226. package/lib/mail-spam-score.js +9 -12
  227. package/lib/mail-store-fts.js +1 -1
  228. package/lib/mail-store.js +3 -2
  229. package/lib/mail.js +28 -13
  230. package/lib/markup-escape.js +31 -0
  231. package/lib/markup-tokenizer.js +78 -0
  232. package/lib/mcp-tool-registry.js +26 -23
  233. package/lib/mcp.js +7 -5
  234. package/lib/mdoc.js +36 -14
  235. package/lib/metrics.js +46 -33
  236. package/lib/middleware/age-gate.js +3 -23
  237. package/lib/middleware/api-encrypt.js +13 -24
  238. package/lib/middleware/assetlinks.js +2 -7
  239. package/lib/middleware/bearer-auth.js +2 -1
  240. package/lib/middleware/body-parser.js +41 -52
  241. package/lib/middleware/bot-disclose.js +7 -10
  242. package/lib/middleware/bot-guard.js +28 -28
  243. package/lib/middleware/clear-site-data.js +5 -1
  244. package/lib/middleware/compression.js +9 -0
  245. package/lib/middleware/cors.js +32 -23
  246. package/lib/middleware/csrf-protect.js +73 -23
  247. package/lib/middleware/daily-byte-quota.js +12 -30
  248. package/lib/middleware/deny-response.js +19 -1
  249. package/lib/middleware/fetch-metadata.js +35 -4
  250. package/lib/middleware/idempotency-key.js +4 -20
  251. package/lib/middleware/network-allowlist.js +61 -30
  252. package/lib/middleware/rate-limit.js +45 -44
  253. package/lib/middleware/scim-server.js +5 -3
  254. package/lib/middleware/security-headers.js +33 -7
  255. package/lib/middleware/security-txt.js +2 -6
  256. package/lib/middleware/speculation-rules.js +6 -3
  257. package/lib/middleware/tus-upload.js +14 -29
  258. package/lib/middleware/web-app-manifest.js +2 -7
  259. package/lib/migrations.js +4 -13
  260. package/lib/mime-parse.js +34 -17
  261. package/lib/module-loader.js +44 -0
  262. package/lib/money.js +105 -0
  263. package/lib/mtls-ca.js +125 -41
  264. package/lib/network-byte-quota.js +4 -18
  265. package/lib/network-dane.js +8 -6
  266. package/lib/network-dns-resolver.js +98 -7
  267. package/lib/network-dns.js +9 -2
  268. package/lib/network-dnssec.js +18 -17
  269. package/lib/network-heartbeat.js +3 -2
  270. package/lib/network-smtp-policy.js +52 -46
  271. package/lib/network-tls.js +67 -47
  272. package/lib/network-tsig.js +2 -2
  273. package/lib/nis2-report.js +2 -12
  274. package/lib/nist-crosswalk.js +1 -1
  275. package/lib/nonce-store.js +81 -3
  276. package/lib/ntp-check.js +28 -0
  277. package/lib/numeric-bounds.js +31 -8
  278. package/lib/object-store/azure-blob-bucket-ops.js +2 -6
  279. package/lib/object-store/azure-blob.js +6 -47
  280. package/lib/object-store/gcs-bucket-ops.js +4 -2
  281. package/lib/object-store/gcs.js +14 -75
  282. package/lib/object-store/http-put.js +2 -7
  283. package/lib/object-store/http-request.js +157 -0
  284. package/lib/object-store/local.js +37 -17
  285. package/lib/object-store/sigv4-bucket-ops.js +2 -1
  286. package/lib/object-store/sigv4.js +10 -79
  287. package/lib/observability-otlp-exporter.js +29 -29
  288. package/lib/observability.js +95 -0
  289. package/lib/openapi-paths-builder.js +7 -3
  290. package/lib/otel-export.js +7 -10
  291. package/lib/outbox.js +54 -41
  292. package/lib/pagination.js +11 -15
  293. package/lib/parsers/safe-env.js +5 -4
  294. package/lib/parsers/safe-ini.js +10 -4
  295. package/lib/parsers/safe-toml.js +11 -9
  296. package/lib/parsers/safe-xml.js +3 -3
  297. package/lib/parsers/safe-yaml.js +28 -9
  298. package/lib/pick.js +63 -5
  299. package/lib/pipl-cn.js +69 -59
  300. package/lib/privacy-pass.js +8 -6
  301. package/lib/problem-details.js +2 -5
  302. package/lib/pubsub.js +4 -8
  303. package/lib/queue-local.js +10 -3
  304. package/lib/redact.js +9 -4
  305. package/lib/render.js +4 -2
  306. package/lib/request-helpers.js +334 -29
  307. package/lib/resource-access-lock.js +3 -3
  308. package/lib/restore-bundle.js +46 -18
  309. package/lib/restore-rollback.js +10 -4
  310. package/lib/restore.js +24 -22
  311. package/lib/retention.js +23 -9
  312. package/lib/router.js +17 -4
  313. package/lib/safe-async.js +457 -0
  314. package/lib/safe-buffer.js +137 -6
  315. package/lib/safe-decompress.js +2 -1
  316. package/lib/safe-dns.js +2 -1
  317. package/lib/safe-ical.js +14 -28
  318. package/lib/safe-icap.js +1 -1
  319. package/lib/safe-json.js +51 -8
  320. package/lib/safe-jsonpath.js +6 -5
  321. package/lib/safe-mime.js +25 -29
  322. package/lib/safe-redirect.js +2 -5
  323. package/lib/safe-schema.js +7 -6
  324. package/lib/safe-sieve.js +1 -1
  325. package/lib/safe-sql.js +126 -0
  326. package/lib/safe-vcard.js +13 -27
  327. package/lib/sandbox-worker.js +15 -2
  328. package/lib/sandbox.js +4 -3
  329. package/lib/scheduler.js +22 -13
  330. package/lib/sec-cyber.js +82 -37
  331. package/lib/seeders.js +4 -11
  332. package/lib/self-update-standalone-verifier.js +16 -0
  333. package/lib/self-update.js +92 -69
  334. package/lib/session-device-binding.js +112 -66
  335. package/lib/session.js +219 -7
  336. package/lib/sql.js +228 -81
  337. package/lib/sse.js +6 -10
  338. package/lib/static.js +201 -111
  339. package/lib/storage.js +4 -2
  340. package/lib/structured-fields.js +275 -16
  341. package/lib/template.js +7 -5
  342. package/lib/tenant-quota.js +53 -38
  343. package/lib/tsa.js +16 -17
  344. package/lib/validate-opts.js +154 -8
  345. package/lib/vault/index.js +5 -0
  346. package/lib/vault/passphrase-ops.js +22 -26
  347. package/lib/vault/passphrase-source.js +8 -3
  348. package/lib/vault/rotate.js +13 -18
  349. package/lib/vault/seal-pem-file.js +34 -49
  350. package/lib/vault-aad.js +3 -2
  351. package/lib/vc.js +3 -8
  352. package/lib/vendor/MANIFEST.json +10 -10
  353. package/lib/vendor/public-suffix-list.dat +23 -10
  354. package/lib/vendor/public-suffix-list.data.js +5498 -5494
  355. package/lib/vex.js +35 -13
  356. package/lib/web-push-vapid.js +23 -20
  357. package/lib/webhook-dispatcher.js +706 -0
  358. package/lib/webhook.js +59 -19
  359. package/lib/websocket-channels.js +9 -7
  360. package/lib/websocket.js +8 -4
  361. package/lib/worm.js +3 -4
  362. package/lib/ws-client.js +65 -56
  363. package/lib/x509-chain.js +44 -0
  364. package/package.json +1 -1
  365. package/sbom.cdx.json +6 -6
package/lib/redact.js CHANGED
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  var C = require("./constants");
40
+ var safeBuffer = require("./safe-buffer");
40
41
  var lazyRequire = require("./lazy-require");
41
42
  var safeJson = require("./safe-json");
42
43
  var validateOpts = require("./validate-opts");
@@ -149,7 +150,7 @@ var VALUE_DETECTORS = [
149
150
  name: "connection-string",
150
151
  test: function (v) {
151
152
  if (typeof v !== "string" || v.length < C.BYTES.bytes(8)) return false; // bound BEFORE regex test
152
- if (v.length > C.BYTES.kib(8)) return false;
153
+ if (safeBuffer.byteLengthOf(v) > C.BYTES.kib(8)) return false;
153
154
  // user may be empty (e.g. redis://:password@host); password
154
155
  // segment is required to flag this as a credentialed URI.
155
156
  return /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:/?#]*:[^\s@/?#]+@/.test(v);
@@ -277,12 +278,16 @@ function redact(value, opts) {
277
278
  function _redact(value, depth, maxDepth, marker, parentKey) {
278
279
  if (depth > maxDepth) return marker;
279
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;
280
287
  if (typeof value === "string") {
281
- if (parentKey && _isSensitiveFieldName(parentKey)) return marker;
282
288
  return _redactValue(value);
283
289
  }
284
290
  if (typeof value === "number" || typeof value === "boolean") {
285
- if (parentKey && _isSensitiveFieldName(parentKey)) return marker;
286
291
  return value;
287
292
  }
288
293
  if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
@@ -512,7 +517,7 @@ function classifyDefaults(opts) {
512
517
  "redact.classifyDefaults: patterns[" + p + "] must be a string, got " +
513
518
  typeof patterns[p]);
514
519
  }
515
- if (!CLASSIFIER_PATTERNS[patterns[p]] &&
520
+ if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_PATTERNS, patterns[p]) &&
516
521
  !(opts.extra && opts.extra[patterns[p]])) {
517
522
  throw new DlpError("redact-dlp/unknown-pattern",
518
523
  "redact.classifyDefaults: unknown pattern '" + patterns[p] +
package/lib/render.js CHANGED
@@ -28,6 +28,8 @@
28
28
  * Server-side HTML / JSON / XML response helpers.
29
29
  */
30
30
 
31
+ var validateOpts = require("./validate-opts");
32
+
31
33
  var DEFAULT_CHARSET = "utf-8";
32
34
 
33
35
  function _alreadyDone(res) {
@@ -53,8 +55,8 @@ function _writeResponse(res, status, headers, body) {
53
55
  function _mergedHeaders(base, extra) {
54
56
  if (!extra) return base;
55
57
  var out = {};
56
- for (var k in base) { if (Object.prototype.hasOwnProperty.call(base, k)) out[k] = base[k]; }
57
- for (var j in extra) { if (Object.prototype.hasOwnProperty.call(extra, j)) out[j] = extra[j]; }
58
+ validateOpts.assignOwnEnumerable(out, base);
59
+ validateOpts.assignOwnEnumerable(out, extra);
58
60
  return out;
59
61
  }
60
62
 
@@ -42,6 +42,13 @@
42
42
  // every consumer reads HTTP_STATUS.<NAME> rather than the underlying
43
43
  // integer, so the hex form is purely an internal storage detail.
44
44
  var structuredFields = require("./structured-fields");
45
+ var pick = require("./pick");
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"); });
45
52
 
46
53
  var HTTP_STATUS = Object.freeze({
47
54
  OK: 0xC8,
@@ -212,15 +219,24 @@ function resolveActorWithOverride(callerOpts, baseOverride) {
212
219
  *
213
220
  * Resolve the originating client IP from a request. Default reads
214
221
  * only `req.socket.remoteAddress` — `X-Forwarded-For` is ignored
215
- * because without a sanitizing reverse proxy it's
216
- * attacker-forgeable. Behind a trusted proxy, operators opt in via
217
- * `trustProxy: true` (use the leftmost XFF hop) or
218
- * `trustProxy: <N>` (skip N trusted hops from the right and return
219
- * the Nth-from-rightmost). Returns `null` when no address can be
220
- * 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.
221
237
  *
222
238
  * @opts
223
- * trustProxy: boolean | number // false (default) | true | hop count
239
+ * trustProxy: boolean | number | function // false (default) | predicate (peer-gated) | legacy true/hop-count
224
240
  *
225
241
  * @example
226
242
  * var req = {
@@ -230,30 +246,55 @@ function resolveActorWithOverride(callerOpts, baseOverride) {
230
246
  * b.requestHelpers.clientIp(req);
231
247
  * // → "10.0.0.1" (forwarded headers ignored by default)
232
248
  *
233
- * b.requestHelpers.clientIp(req, { trustProxy: true });
234
- * // → "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)
235
252
  *
236
- * b.requestHelpers.clientIp(req, { trustProxy: 1 });
237
- * // "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)
238
257
  *
239
258
  * b.requestHelpers.clientIp(undefined);
240
259
  * // → null
241
260
  */
242
261
  function clientIp(req, opts) {
243
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;
244
267
  var trust = opts && opts.trustProxy;
245
268
  if (trust && req.headers) {
246
269
  var xff = req.headers["x-forwarded-for"];
247
270
  if (xff) {
248
271
  var hops = parseListHeader(xff);
249
- if (trust === true) return hops[0];
250
- if (typeof trust === "number" && trust >= 1 && hops.length >= trust) {
251
- 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
+ }
252
294
  }
253
295
  }
254
296
  }
255
- if (req.socket && typeof req.socket.remoteAddress === "string") return req.socket.remoteAddress;
256
- if (req.connection && typeof req.connection.remoteAddress === "string") return req.connection.remoteAddress;
297
+ if (socketAddr) return socketAddr;
257
298
  // Express-shaped requests expose the resolved client address as `req.ip`
258
299
  // (Express derives it from the socket, honoring its own trust-proxy
259
300
  // setting) without a `socket.remoteAddress` surface. Fall back to it so a
@@ -264,6 +305,125 @@ function clientIp(req, opts) {
264
305
  return null;
265
306
  }
266
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
+ /**
388
+ * @primitive b.requestHelpers.trustedProtocol
389
+ * @signature b.requestHelpers.trustedProtocol(opts?)
390
+ * @since 0.15.14
391
+ * @related b.requestHelpers.requestProtocol, b.requestHelpers.trustedClientIp
392
+ *
393
+ * Peer-gated companion to trustedClientIp for the request scheme. The
394
+ * Secure-cookie / HSTS / secure-context decisions hinge on whether a request
395
+ * arrived over HTTPS; behind a TLS-terminating proxy that comes from
396
+ * X-Forwarded-Proto, which is forgeable unless the immediate peer is a trusted
397
+ * proxy. Returns `{ resolve(req)=>"http"|"https", peerGated }`. With
398
+ * `trustedProxies` (CIDRs) the header is honored only from a trusted peer; with
399
+ * `protocolResolver(req)` the operator owns the decision; with neither only the
400
+ * real TLS socket is consulted (forwarded headers ignored).
401
+ *
402
+ * @opts
403
+ * trustedProxies: string | string[],
404
+ * protocolResolver: function(req): "http"|"https",
405
+ *
406
+ * @example
407
+ * var tp = b.requestHelpers.trustedProtocol({ trustedProxies: ["10.0.0.0/8"] });
408
+ * tp.resolve(req); // "https" only when X-Forwarded-Proto came via a trusted peer
409
+ */
410
+ function trustedProtocol(opts) {
411
+ opts = opts || {};
412
+ var resolver = opts.protocolResolver;
413
+ if (resolver != null && typeof resolver !== "function") {
414
+ throw new TypeError("trustedProtocol: protocolResolver must be a function(req) => 'http'|'https'");
415
+ }
416
+ var predicate = _trustedProxyPredicate(_normTrustedProxies(opts), "trustedProtocol");
417
+ return {
418
+ peerGated: !!(resolver || predicate),
419
+ resolve: function (req) {
420
+ if (resolver) return resolver(req);
421
+ if (predicate) return requestProtocol(req, { trustProxy: predicate });
422
+ return requestProtocol(req, { trustProxy: false });
423
+ },
424
+ };
425
+ }
426
+
267
427
  /**
268
428
  * @primitive b.requestHelpers.requestProtocol
269
429
  * @signature b.requestHelpers.requestProtocol(req, opts?)
@@ -272,14 +432,17 @@ function clientIp(req, opts) {
272
432
  *
273
433
  * Resolve the inbound transport scheme. Default returns `"https"`
274
434
  * when `req.socket.encrypted` is set, otherwise `"http"`. Behind a
275
- * trusted reverse proxy that terminates TLS, set `trustProxy: true`
276
- * to read the leftmost `X-Forwarded-Proto` hop instead — without
277
- * the explicit opt-in the framework refuses to pick up the
278
- * attacker-forgeable header. Always returns a string; on bad input
279
- * falls back to `"http"`.
435
+ * trusted reverse proxy that terminates TLS, pass `trustProxy` as a
436
+ * PREDICATE `function(addr)=>boolean` naming your proxies:
437
+ * `X-Forwarded-Proto` is then honored only when the immediate peer is
438
+ * a trusted proxy, so a direct caller can't forge it (use
439
+ * `b.requestHelpers.trustedProtocol` to build this). The legacy
440
+ * `trustProxy: true` reads the leftmost hop without checking the peer —
441
+ * forgeable, safe only behind an edge that rewrites the header. Always
442
+ * returns a string; on bad input falls back to `"http"`.
280
443
  *
281
444
  * @opts
282
- * trustProxy: boolean // false (default) | true
445
+ * trustProxy: boolean | function // false (default) | predicate (peer-gated) | legacy true
283
446
  *
284
447
  * @example
285
448
  * var req = { socket: { encrypted: true } };
@@ -303,7 +466,22 @@ function requestProtocol(req, opts) {
303
466
  var fwd = req.headers["x-forwarded-proto"];
304
467
  if (typeof fwd === "string" && fwd.length > 0) {
305
468
  var hops = parseListHeader(fwd, { lowercase: true });
306
- if (hops.length > 0) return hops[0];
469
+ if (hops.length > 0) {
470
+ if (typeof trust === "function") {
471
+ // Peer-gated: honor X-Forwarded-Proto only when the immediate TCP
472
+ // peer is a trusted proxy. A direct caller's forged header is
473
+ // ignored — fall through to the real TLS socket. The only form safe
474
+ // for a Secure-cookie / HSTS / secure-context decision.
475
+ var peer =
476
+ (req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
477
+ : (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
478
+ : null;
479
+ if (peer && trust(peer)) return hops[0];
480
+ // peer not a trusted proxy → ignore forgeable header, fall through
481
+ } else {
482
+ return hops[0]; // legacy true/number — spoofable, see docstring
483
+ }
484
+ }
307
485
  }
308
486
  }
309
487
  if (req.socket && req.socket.encrypted) return "https";
@@ -468,6 +646,91 @@ function resolveRoute(req) {
468
646
  return qIdx === -1 ? url : url.slice(0, qIdx);
469
647
  }
470
648
 
649
+ /**
650
+ * @primitive b.requestHelpers.makeSkipMatcher
651
+ * @signature b.requestHelpers.makeSkipMatcher(opts, label)
652
+ * @since 0.15.13
653
+ * @status stable
654
+ * @related b.requestHelpers.resolveRoute
655
+ *
656
+ * Build a `(req) => boolean` path-match predicate shared by the state-change
657
+ * guards (`csrfProtect` / `fetchMetadata` / `botGuard` / `rateLimit`) AND the
658
+ * route-exemption / mount checks in `auth.accessLock`, `middleware.ageGate`,
659
+ * `middleware.botDisclose`, and `middleware.dailyByteQuota` — so a single route
660
+ * can be exempted (or a middleware mounted on a path subset) without each caller
661
+ * re-rolling the loop. `opts.skipPaths` entries are validated at build time —
662
+ * each must be a string or a RegExp — so an operator typo dies at boot, not on
663
+ * the first request; the optional `opts.skip(req)` predicate is validated the
664
+ * same way.
665
+ *
666
+ * A STRING entry matches on a SEGMENT BOUNDARY, not a raw prefix: `"/api"`
667
+ * matches `/api` and `/api/x` but NOT `/apixyz` — a raw `startsWith` would skip
668
+ * the guard on an unintended sibling path (a guard-bypass class). An entry that
669
+ * already ends in `/` is itself a segment prefix. Pass `exact: true` to require
670
+ * a whole-path match (no descendant). A RegExp entry uses `.test(path)`. The
671
+ * tested path is `req.pathname || req.url || req.originalUrl || "/"` with the
672
+ * query string stripped (matching is on the path, never the query). A `skip`
673
+ * predicate that throws is treated as "do not skip", so a buggy exemption can
674
+ * only keep the guard ON, never silently bypass it.
675
+ *
676
+ * @opts
677
+ * skipPaths: Array<string|RegExp>, // string = segment-boundary match; RegExp = .test(path)
678
+ * exact: boolean, // string entries match whole-path only (no descendant). default false
679
+ * skip: function, // (req) => boolean, optional route-aware predicate
680
+ *
681
+ * @example
682
+ * var shouldSkip = b.requestHelpers.makeSkipMatcher(
683
+ * { skipPaths: ["/healthz", /^\/webhooks\//] }, "middleware.csrfProtect");
684
+ * if (shouldSkip(req)) return next();
685
+ */
686
+ // _skipStrMatch — does the request path match a single STRING skip entry?
687
+ // SEGMENT-BOUNDARY semantics, NOT a raw `startsWith`: entry "/api" matches
688
+ // "/api" and "/api/x" but NOT "/apixyz" (a raw prefix would wrongly skip the
689
+ // guard on the sibling path — a guard-bypass class). An entry that already
690
+ // ends in "/" is itself a segment prefix ("/webhooks/" matches "/webhooks/x").
691
+ // `exact` restricts to a whole-path equality (no descendant match).
692
+ function _skipStrMatch(path, entry, exact) {
693
+ if (exact) return path === entry;
694
+ if (entry.charAt(entry.length - 1) === "/") return path.indexOf(entry) === 0;
695
+ return path === entry || path.indexOf(entry + "/") === 0;
696
+ }
697
+
698
+ function makeSkipMatcher(opts, label) {
699
+ opts = opts || {};
700
+ label = label || "makeSkipMatcher";
701
+ var skipPaths = opts.skipPaths || [];
702
+ if (!Array.isArray(skipPaths)) {
703
+ throw new TypeError(label + ": skipPaths must be an array of string prefixes or RegExp");
704
+ }
705
+ for (var i = 0; i < skipPaths.length; i++) {
706
+ if (typeof skipPaths[i] !== "string" && !(skipPaths[i] instanceof RegExp)) {
707
+ throw new TypeError(label + ": skipPaths[" + i + "] must be a string prefix or RegExp, got " +
708
+ typeof skipPaths[i]);
709
+ }
710
+ }
711
+ var skipFn = opts.skip;
712
+ if (skipFn !== undefined && skipFn !== null && typeof skipFn !== "function") {
713
+ throw new TypeError(label + ": skip must be a function (req) => boolean");
714
+ }
715
+ var exact = opts.exact === true;
716
+ return function _shouldSkip(req) {
717
+ var path = (req && (req.pathname || req.url || req.originalUrl)) || "/";
718
+ var qpos = path.indexOf("?");
719
+ if (qpos !== -1) path = path.slice(0, qpos); // match on the path, never the query string
720
+ for (var j = 0; j < skipPaths.length; j++) {
721
+ var entry = skipPaths[j];
722
+ if (typeof entry === "string" ? _skipStrMatch(path, entry, exact) : entry.test(path)) {
723
+ return true;
724
+ }
725
+ }
726
+ if (skipFn) {
727
+ try { return skipFn(req) === true; }
728
+ catch (_e) { return false; }
729
+ }
730
+ return false;
731
+ };
732
+ }
733
+
471
734
  /**
472
735
  * @primitive b.requestHelpers.captureResponseStatus
473
736
  * @signature b.requestHelpers.captureResponseStatus(res, onEnd)
@@ -667,11 +930,8 @@ function extractBearer(req) {
667
930
  if (raw.indexOf(",") !== -1) return null;
668
931
  // Reject ASCII control characters BEFORE prefix-matching so a header
669
932
  // like "Bearer\rinjected" never reaches consumers.
670
- for (var ci = 0; ci < raw.length; ci += 1) {
671
- var cc = raw.charCodeAt(ci);
672
- if (cc === 0x00 || cc === 0x0A || cc === 0x0D || cc === 0x09 || cc < 0x20 || cc === 0x7F) {
673
- return null;
674
- }
933
+ if (codepointClass.firstControlCharOffset(raw, { forbidTab: true }) !== -1) {
934
+ return null;
675
935
  }
676
936
  // RFC 6750 §2.1 — auth-scheme is case-insensitive. The "Bearer "
677
937
  // prefix + at least one token byte must be present; the literal
@@ -747,15 +1007,58 @@ function safeHeadersDistinct(req) {
747
1007
  // skip __proto__ / constructor / prototype as keys — they are the
748
1008
  // exact strings that triggered the upstream getter throw, and we
749
1009
  // refuse to surface them as accessible header names.
750
- if (lower === "__proto__" || lower === "constructor" || lower === "prototype") continue;
1010
+ if (pick.isPoisonedKey(lower)) continue;
751
1011
  if (out[lower]) out[lower].push(value);
752
1012
  else out[lower] = [value];
753
1013
  }
754
1014
  return out;
755
1015
  }
756
1016
 
1017
+ /**
1018
+ * @primitive b.requestHelpers.makeResourceAuditEmitter
1019
+ * @signature b.requestHelpers.makeResourceAuditEmitter(sink, resourceKind, idFor?)
1020
+ * @since 0.15.13
1021
+ * @status stable
1022
+ * @related b.requestHelpers.extractActorContext
1023
+ *
1024
+ * Build a drop-silent audit emitter `(action, key, outcome, metadata, req)` for
1025
+ * a request-scoped resource. The emitter is disabled when `sink` is falsy (the
1026
+ * operator supplied no audit instance), so a primitive can wire it
1027
+ * unconditionally and let the operator opt in by passing `opts.audit`. Each
1028
+ * event carries `resource: { kind, id }` and, when a request is passed, the
1029
+ * actor extracted from it (`extractActorContext`); a throwing sink is swallowed
1030
+ * so audit emission can never break the request the event describes.
1031
+ *
1032
+ * The auth lockout / bot-challenge and session device-binding primitives emit
1033
+ * this exact shape, varying only in the resource kind and how the id derives
1034
+ * from the per-call key. `idFor(key)` maps the per-call key to the resource id
1035
+ * (default: the key verbatim); pass it when the id needs a prefix or transform.
1036
+ *
1037
+ * @example
1038
+ * var emitAudit = b.requestHelpers.makeResourceAuditEmitter(
1039
+ * opts.audit, "auth.lockout", function (key) { return ns + ":" + key; });
1040
+ * emitAudit("locked", key, "denied", { attempts: n }, req);
1041
+ */
1042
+ function makeResourceAuditEmitter(sink, resourceKind, idFor) {
1043
+ return function (action, key, outcome, metadata, req) {
1044
+ if (!sink) return;
1045
+ try {
1046
+ var event = {
1047
+ action: action,
1048
+ outcome: outcome,
1049
+ resource: { kind: resourceKind, id: idFor ? idFor(key) : key },
1050
+ metadata: metadata || {},
1051
+ };
1052
+ if (req) event.actor = extractActorContext(req);
1053
+ sink.safeEmit(event);
1054
+ } catch (_e) { /* audit best-effort — never let a sink throw escape */ }
1055
+ };
1056
+ }
1057
+
757
1058
  module.exports = {
758
1059
  resolveRoute: resolveRoute,
1060
+ makeResourceAuditEmitter: makeResourceAuditEmitter,
1061
+ makeSkipMatcher: makeSkipMatcher,
759
1062
  captureResponseStatus: captureResponseStatus,
760
1063
  extractActorContext: extractActorContext,
761
1064
  resolveActorWithOverride: resolveActorWithOverride,
@@ -763,7 +1066,9 @@ module.exports = {
763
1066
  parseListHeader: parseListHeader,
764
1067
  // proxy-trust primitives (default refuses forwarded headers)
765
1068
  clientIp: clientIp,
1069
+ trustedClientIp: trustedClientIp,
766
1070
  requestProtocol: requestProtocol,
1071
+ trustedProtocol: trustedProtocol,
767
1072
  appendVary: appendVary,
768
1073
  // CVE-2026-21710 wrap — safe alternative to req.headersDistinct
769
1074
  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(" / "));
@@ -48,6 +48,7 @@
48
48
  var nodeFs = require("node:fs");
49
49
  var nodePath = require("node:path");
50
50
  var atomicFile = require("./atomic-file");
51
+ var C = require("./constants");
51
52
  var backupCrypto = require("./backup/crypto");
52
53
  var backupManifest = require("./backup/manifest");
53
54
  var validateOpts = require("./validate-opts");
@@ -146,13 +147,21 @@ async function extract(opts) {
146
147
  // 1. Read + parse + validate manifest
147
148
  _emit(progress, { phase: "read_manifest" });
148
149
  var manifestPath = nodePath.join(bundleDir, "manifest.json");
149
- if (!nodeFs.existsSync(manifestPath)) {
150
- throw new RestoreBundleError("restore-bundle/missing-manifest",
151
- "extract: bundleDir has no manifest.json bundle is incomplete or not a blamejs backup");
152
- }
150
+ // Capped fd-bound read OUTSIDE the parse try/catch, so a missing / oversized
151
+ // bundle manifest surfaces the precise restore-bundle code (not a generic
152
+ // parse error). A backup manifest is small JSON; 4 MiB bounds a hostile one
153
+ // before backupManifest.parse materializes it.
154
+ var manifestRaw = atomicFile.fdSafeReadSync(manifestPath, {
155
+ maxBytes: C.BYTES.mib(4), encoding: "utf8",
156
+ errorFor: function (kind, detail) {
157
+ if (kind === "enoent") return new RestoreBundleError("restore-bundle/missing-manifest", "extract: bundleDir has no manifest.json — bundle is incomplete or not a blamejs backup");
158
+ if (kind === "too-large") return new RestoreBundleError("restore-bundle/bad-manifest", "extract: manifest.json exceeds " + detail.max + " bytes");
159
+ return new RestoreBundleError("restore-bundle/bad-manifest", "extract: manifest unreadable: " + kind);
160
+ },
161
+ });
153
162
  var manifest;
154
163
  try {
155
- manifest = backupManifest.parse(nodeFs.readFileSync(manifestPath, "utf8"));
164
+ manifest = backupManifest.parse(manifestRaw);
156
165
  } catch (e) {
157
166
  if (e && e.isBackupManifestError) throw e;
158
167
  throw new RestoreBundleError("restore-bundle/bad-manifest",
@@ -216,12 +225,20 @@ async function extract(opts) {
216
225
  }
217
226
 
218
227
  var blobPath = nodePath.join(bundleDir, entry.encryptedPath);
219
- if (!nodeFs.existsSync(blobPath)) {
220
- throw new RestoreBundleError("restore-bundle/missing-blob",
221
- "extract: manifest references '" + entry.encryptedPath +
222
- "' but the bundle has no such file");
223
- }
224
- var blob = nodeFs.readFileSync(blobPath);
228
+ // Cap the read to the manifest's declared encryptedSize so an oversize-on-
229
+ // disk blob is refused BEFORE it is read into memory (was: read fully, then
230
+ // compare an OOM window for a huge swapped blob). A valid blob is exactly
231
+ // encryptedSize; the post-read compare still catches the under-size case.
232
+ var blobCap = (typeof entry.encryptedSize === "number" && entry.encryptedSize > 0)
233
+ ? entry.encryptedSize : C.BYTES.gib(8);
234
+ var blob = atomicFile.fdSafeReadSync(blobPath, {
235
+ maxBytes: blobCap,
236
+ errorFor: function (kind, detail) {
237
+ if (kind === "enoent") return new RestoreBundleError("restore-bundle/missing-blob", "extract: manifest references '" + entry.encryptedPath + "' but the bundle has no such file");
238
+ if (kind === "too-large") return new RestoreBundleError("restore-bundle/size-mismatch", "extract: blob '" + entry.encryptedPath + "' has size " + detail.size + " but manifest expected " + entry.encryptedSize);
239
+ return new RestoreBundleError("restore-bundle/missing-blob", "extract: blob '" + entry.encryptedPath + "' unreadable: " + kind);
240
+ },
241
+ });
225
242
  if (blob.length !== entry.encryptedSize) {
226
243
  throw new RestoreBundleError("restore-bundle/size-mismatch",
227
244
  "extract: blob '" + entry.encryptedPath + "' has size " + blob.length +
@@ -235,12 +252,18 @@ async function extract(opts) {
235
252
 
236
253
  var plaintext;
237
254
  try {
238
- plaintext = await backupCrypto.decryptWithPassphrase(blob, passphrase, entry.salt);
255
+ // Bundles written with manifest.aadBound sealed each blob with its
256
+ // relativePath as AEAD associated data — pass the SAME path so a blob
257
+ // remapped to a different manifest entry fails the tag here (the
258
+ // blob-remap / restore-corruption defense). Legacy bundles without
259
+ // the flag decrypt with no AAD (backward compatible).
260
+ var blobAad = manifest.aadBound === true ? Buffer.from(entry.relativePath, "utf8") : undefined;
261
+ plaintext = await backupCrypto.decryptWithPassphrase(blob, passphrase, entry.salt, blobAad);
239
262
  } catch (e) {
240
263
  if (e && e.isBackupCryptoError && e.code === "backup-crypto/decrypt-failed") {
241
264
  throw new RestoreBundleError("restore-bundle/decrypt-failed",
242
265
  "extract: blob '" + entry.encryptedPath + "' did not decrypt — " +
243
- "passphrase rejected or ciphertext tampered");
266
+ "passphrase rejected, ciphertext tampered, or blob remapped to a different path");
244
267
  }
245
268
  throw e;
246
269
  }
@@ -326,11 +349,16 @@ function inspect(opts) {
326
349
  "inspect: opts.bundleDir is required and must exist");
327
350
  }
328
351
  var manifestPath = nodePath.join(opts.bundleDir, "manifest.json");
329
- if (!nodeFs.existsSync(manifestPath)) {
330
- throw new RestoreBundleError("restore-bundle/missing-manifest",
331
- "inspect: bundleDir has no manifest.json");
332
- }
333
- return backupManifest.parse(nodeFs.readFileSync(manifestPath, "utf8"));
352
+ // Capped fd-bound read (no-passphrase preview path, reachable with only read
353
+ // access to the bundle): bound a hostile manifest before parse.
354
+ return backupManifest.parse(atomicFile.fdSafeReadSync(manifestPath, {
355
+ maxBytes: C.BYTES.mib(4), encoding: "utf8",
356
+ errorFor: function (kind) {
357
+ if (kind === "enoent") return new RestoreBundleError("restore-bundle/missing-manifest", "inspect: bundleDir has no manifest.json");
358
+ if (kind === "too-large") return new RestoreBundleError("restore-bundle/bad-manifest", "inspect: manifest.json too large");
359
+ return new RestoreBundleError("restore-bundle/missing-manifest", "inspect: manifest unreadable: " + kind);
360
+ },
361
+ }));
334
362
  }
335
363
 
336
364
  module.exports = {