@blamejs/core 0.8.43 → 0.8.49

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 (222) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
package/lib/router.js CHANGED
@@ -1,23 +1,36 @@
1
1
  "use strict";
2
2
  /**
3
- * Custom HTTP router — zero-dependency replacement for express/koa/fastify.
3
+ * @module b.router
4
+ * @featured true
5
+ * @nav HTTP
6
+ * @title Router
4
7
  *
5
- * Why rolled-our-own: blamejs principle #1 forbids npm runtime dependencies.
6
- * This router covers what a route concretely requires (path params,
7
- * middleware chain, static file serving, MIME sniffing) and leaves no
8
- * attack surface we haven't read.
8
+ * @intro
9
+ * HTTP route registration + dispatch. Operators register handlers
10
+ * against method+pattern pairs, the router compiles each pattern
11
+ * once at registration time and walks the table linearly per
12
+ * request — first match wins.
9
13
  *
10
- * Middleware / handler dispatch (see roadmap "Naming conventions" — verb
11
- * conventions section, "on/off/emit" vs explicit chain control):
12
- * - handler.length >= 3 treated as middleware. Chain stops unless the
13
- * handler calls next(). Using 2-arg handlers as middleware is
14
- * structurally fragile and will silently fall through.
15
- * - handler.length <= 2 terminal handler. Always falls through to the
16
- * next entry in the chain if it doesn't end the response.
14
+ * Patterns are segment-based (`/users/:id`); named parameters land
15
+ * on `req.params`. Handler dispatch follows arity:
16
+ * - `handler.length >= 3` is middleware (req, res, next) the
17
+ * chain stops unless `next()` is called.
18
+ * - `handler.length <= 2` is a terminal handler (req, res) — the
19
+ * chain falls through to the next entry unless the response is
20
+ * already ended.
17
21
  *
18
- * Patterns are compiled ONCE at registration time (compilePattern) — no
19
- * regex construction on the hot path. Route table is scanned linearly;
20
- * ordering matters (first match wins).
22
+ * When no pattern matches, the registered `onNotFound` handler runs;
23
+ * the framework default is a 404 with a small text/html body. The
24
+ * router boots an HTTP/2 + HTTP/1.1 ALPN server on `listen()` when
25
+ * given TLS options, an HTTP/1.1 server otherwise.
26
+ *
27
+ * Zero npm runtime deps — this primitive replaces express / koa /
28
+ * fastify entirely while keeping the framework's security defaults
29
+ * (TLS 1.3 minimum, 0-RTT anti-replay, Slowloris timeouts, h2
30
+ * CONTINUATION-flood + Rapid-Reset caps) wired in by default.
31
+ *
32
+ * @card
33
+ * HTTP route registration + dispatch.
21
34
  */
22
35
  var http = require("http");
23
36
  var http2 = require("http2");
@@ -25,15 +38,37 @@ var fs = require("fs");
25
38
  var path = require("path");
26
39
  var C = require("./constants");
27
40
  var requestHelpers = require("./request-helpers");
41
+ var lazyRequire = require("./lazy-require");
28
42
  var safeAsync = require("./safe-async");
29
43
  var safeEnv = require("./parsers/safe-env");
30
44
  var safeUrl = require("./safe-url");
31
45
  var websocket = require("./websocket");
32
46
  var { boot } = require("./log");
47
+ var { RouterError } = require("./framework-error");
48
+
49
+ var auditFwk = lazyRequire(function () { return require("./audit"); });
50
+ // compliance — lazy because router.js is required during boot before
51
+ // the operator's `b.compliance.set(...)` runs; the posture lookup only
52
+ // matters at listen() time, well after boot finishes.
53
+ var complianceLazy = lazyRequire(function () { return require("./compliance"); });
33
54
 
34
55
  var log = boot("router");
35
56
  var HTTP_STATUS = requestHelpers.HTTP_STATUS;
36
57
 
58
+ // CVE-2026-21714 — h2 WINDOW_UPDATE leak after GOAWAY. nghttp2 holds
59
+ // per-stream flow-control state after the session has emitted GOAWAY;
60
+ // late-arriving WINDOW_UPDATE frames can re-credit a draining stream
61
+ // and starve the connection. The framework cap defends defense-in-depth
62
+ // even when Node's nghttp2 vendor lags the upstream fix: tag every
63
+ // session with `_blamejsGoawaySent` on the framework's GOAWAY emission,
64
+ // and force-destroy on any subsequent frame activity.
65
+ var WINDOW_UPDATE_FRAME_TYPE = 0x8; // allow:raw-byte-literal — RFC 7540 §6.9 frame type
66
+ // Per-stream WINDOW_UPDATE rate cap. Above this rate the framework
67
+ // destroys the stream; legitimate clients never burst this fast on a
68
+ // healthy connection.
69
+ var WINDOW_UPDATE_RATE_CAP = 100; // allow:raw-byte-literal — frames per second per stream
70
+ var WINDOW_UPDATE_RATE_WINDOW_MS = C.TIME.seconds(1);
71
+
37
72
  // Cap on operator-defined route patterns. A route registration that
38
73
  // somehow attracts a multi-megabyte template string would stall regex
39
74
  // compilation; bound it before new RegExp() so the gate at registration
@@ -227,8 +262,37 @@ var MIME_TYPES = {
227
262
  ".woff": "font/woff",
228
263
  };
229
264
 
265
+ // TLS 1.3 0-RTT anti-replay posture (RFC 8446 §8 / §2.3 early-data).
266
+ //
267
+ // 0-RTT lets the client smuggle application-data bytes alongside the
268
+ // ClientHello — saving one round-trip on resumed sessions but admitting
269
+ // the replay class: an attacker that captured the encrypted early-data
270
+ // can re-send the same handshake bytes and the server processes the
271
+ // payload twice. RFC 8446 §8 requires the server EITHER refuse early
272
+ // data outright OR maintain a single-use anti-replay state per ticket
273
+ // for the configured early_data lifetime.
274
+ //
275
+ // Postures:
276
+ // "refuse" — Node default; the framework does not request 0-RTT
277
+ // and refuses peer early-data attempts.
278
+ // "replay-cache" — opts in; the framework de-duplicates incoming
279
+ // early-data by SHA3-512(early-data-bytes) inside a
280
+ // short rolling window. Cache hit = refuse + audit
281
+ // (potential replay). Cache miss = accept + audit.
282
+ //
283
+ // Under regulated postures (`pci-dss`, `fapi2`) the framework refuses
284
+ // 0-RTT regardless of operator opt-in — these regimes treat every
285
+ // authenticated request as non-idempotent and forbid early-data
286
+ // processing. The router consults `b.compliance.current()` at listen
287
+ // time and overrides "replay-cache" → "refuse" with an audit row.
288
+ var TLS_0RTT_VALID_POSTURES = ["refuse", "replay-cache"];
289
+ var TLS_0RTT_REPLAY_WINDOW_MS = C.TIME.seconds(10);
290
+ var TLS_0RTT_REPLAY_CACHE_CAP = 4096; // allow:raw-byte-literal — entry count, not bytes
291
+ var TLS_0RTT_FAILCLOSED_POSTURES = ["pci-dss", "fapi2"];
292
+
230
293
  class Router {
231
- constructor() {
294
+ constructor(opts) {
295
+ opts = opts || {};
232
296
  this.routes = [];
233
297
  this.middleware = [];
234
298
  // WebSocket routes are kept separate from HTTP routes — they're
@@ -241,8 +305,91 @@ class Router {
241
305
  // tracking — without our own registry there's no other way to
242
306
  // enumerate active WS connections for graceful close.
243
307
  this._activeWsConns = new Set();
308
+
309
+ // TLS 1.3 0-RTT anti-replay posture — see TLS_0RTT_* above.
310
+ var posture = opts.tls0Rtt === undefined ? "refuse" : opts.tls0Rtt;
311
+ if (typeof posture !== "string" || TLS_0RTT_VALID_POSTURES.indexOf(posture) === -1) {
312
+ throw new TypeError(
313
+ "router.create: tls0Rtt must be one of " + TLS_0RTT_VALID_POSTURES.join(", ") +
314
+ "; got " + JSON.stringify(opts.tls0Rtt));
315
+ }
316
+ this._tls0RttPosture = posture;
317
+ // Replay cache — Map<sha3-512(early-data) hex, expiresAtMs>.
318
+ // Bounded entry count + rolling-window expiry.
319
+ this._tls0RttReplayCache = new Map();
320
+
321
+ // Cross-origin redirect allowlist. `res.redirect(url)` defaults to
322
+ // same-origin only — apps that need to bounce the user agent to an
323
+ // external IdP (OAuth authorization endpoint, SAML SSO, SCIM step-up)
324
+ // declare the operator-trusted destinations up front. Each entry is
325
+ // an exact-match HTTPS origin (`scheme://host[:port]`). Any redirect
326
+ // to a target whose origin is not on the list is refused loud — the
327
+ // operator gets a RouterError, not a silent bounce to "/".
328
+ var allowedOrigins = opts.allowedRedirectOrigins;
329
+ if (allowedOrigins !== undefined) {
330
+ if (!Array.isArray(allowedOrigins)) {
331
+ throw new RouterError(
332
+ "router/allowed-redirect-origins-not-array",
333
+ "router.create: allowedRedirectOrigins must be an array of HTTPS origin strings"
334
+ );
335
+ }
336
+ var normalized = [];
337
+ for (var oi = 0; oi < allowedOrigins.length; oi += 1) {
338
+ var entry = allowedOrigins[oi];
339
+ if (typeof entry !== "string" || entry.length === 0) {
340
+ throw new RouterError(
341
+ "router/allowed-redirect-origin-not-string",
342
+ "router.create: allowedRedirectOrigins[" + oi + "] must be a non-empty string"
343
+ );
344
+ }
345
+ var parsedOrigin;
346
+ try {
347
+ parsedOrigin = safeUrl.parse(entry, {
348
+ allowedProtocols: ["https:"],
349
+ });
350
+ } catch (parseErr) {
351
+ throw new RouterError(
352
+ "router/allowed-redirect-origin-not-https-origin",
353
+ "router.create: allowedRedirectOrigins[" + oi + "] '" + entry +
354
+ "' is not a valid HTTPS origin (" + parseErr.message + ")"
355
+ );
356
+ }
357
+ // RFC 6454 §4 origin form: scheme://host[:port]. We refuse
358
+ // anything carrying path / query / userinfo so the allowlist
359
+ // stays comparable byte-for-byte against URL.origin at redirect
360
+ // time. Operators who want to gate by full URL apply their own
361
+ // post-redirect validation in the handler.
362
+ if (parsedOrigin.pathname !== "/" && parsedOrigin.pathname !== "") {
363
+ throw new RouterError(
364
+ "router/allowed-redirect-origin-has-path",
365
+ "router.create: allowedRedirectOrigins[" + oi + "] '" + entry +
366
+ "' must be an origin (scheme://host[:port]) — path / query / userinfo not allowed"
367
+ );
368
+ }
369
+ if (parsedOrigin.search.length > 0 || parsedOrigin.hash.length > 0 ||
370
+ parsedOrigin.username.length > 0 || parsedOrigin.password.length > 0) {
371
+ throw new RouterError(
372
+ "router/allowed-redirect-origin-has-extras",
373
+ "router.create: allowedRedirectOrigins[" + oi + "] '" + entry +
374
+ "' must be an origin (scheme://host[:port]) — path / query / userinfo not allowed"
375
+ );
376
+ }
377
+ normalized.push(parsedOrigin.origin);
378
+ }
379
+ this._allowedRedirectOrigins = normalized;
380
+ } else {
381
+ this._allowedRedirectOrigins = [];
382
+ }
244
383
  }
245
384
 
385
+ // Operator-facing read of the cross-origin redirect allowlist. Returns
386
+ // a defensive copy so handlers cannot mutate router state.
387
+ allowedRedirectOrigins() {
388
+ return this._allowedRedirectOrigins.slice();
389
+ }
390
+
391
+ tls0RttPosture() { return this._tls0RttPosture; }
392
+
246
393
  // Active WebSocket connections opened via router.ws(). Useful for
247
394
  // ops dashboards / health endpoints.
248
395
  activeWebSockets() {
@@ -578,23 +725,218 @@ class Router {
578
725
  this.errorHandler = handler;
579
726
  }
580
727
 
728
+ // Compute the effective TLS 0-RTT posture, fail-closing under the
729
+ // posture-asserted regimes (`pci-dss`, `fapi2`) regardless of operator
730
+ // opt-in. RFC 8446 §8 + PCI DSS 4.0 §6.4.3 + FAPI 2.0 §5.2.2.
731
+ _effective0RttPosture() {
732
+ var declared = this._tls0RttPosture;
733
+ if (declared !== "replay-cache") return declared;
734
+ var active = null;
735
+ try {
736
+ var compliance = complianceLazy();
737
+ if (compliance && typeof compliance.current === "function") active = compliance.current();
738
+ } catch (_e) { /* compliance not initialized */ }
739
+ if (active && TLS_0RTT_FAILCLOSED_POSTURES.indexOf(active) !== -1) {
740
+ try {
741
+ auditFwk().safeEmit({
742
+ action: "tls.0rtt.refused",
743
+ outcome: "denied",
744
+ metadata: { reason: "posture-failclosed", posture: active, declared: declared },
745
+ });
746
+ } catch (_e) { /* audit best-effort */ }
747
+ return "refuse";
748
+ }
749
+ return "replay-cache";
750
+ }
751
+
752
+ // Check inbound `Early-Data: 1` (RFC 8470 §5) requests against the
753
+ // 0-RTT replay cache. Returns null when the request should proceed,
754
+ // or a status-code+reason when the request must be refused.
755
+ _check0RttReplay(req) {
756
+ var posture = this._effective0RttPosture();
757
+ var earlyDataHeader = req.headers && (req.headers["early-data"] || req.headers["Early-Data"]);
758
+ if (earlyDataHeader === undefined) return null; // not an early-data forward
759
+ if (String(earlyDataHeader).trim() !== "1") return null; // RFC 8470: only "1" means early data
760
+ if (posture === "refuse") {
761
+ try {
762
+ auditFwk().safeEmit({
763
+ action: "tls.0rtt.refused",
764
+ outcome: "denied",
765
+ metadata: { reason: "posture-refuse", method: req.method, url: req.url },
766
+ });
767
+ } catch (_e) { /* audit best-effort */ }
768
+ return { status: 425, reason: "early-data-refused" };
769
+ }
770
+ // posture === "replay-cache" — dedupe by SHA3-512 within the rolling
771
+ // window. Hash inputs (method + url + Host + Authorization + bound
772
+ // request id) so identical retries replay-detect; legitimate-but-
773
+ // distinct retries differentiate via Idempotency-Key / Date.
774
+ var nowMs = Date.now();
775
+ this._reap0RttCache(nowMs);
776
+ var hash = require("node:crypto").createHash("sha3-512");
777
+ hash.update(String(req.method || "") + "\n");
778
+ hash.update(String(req.url || "") + "\n");
779
+ hash.update(String((req.headers && req.headers["host"]) || "") + "\n");
780
+ hash.update(String((req.headers && req.headers["authorization"]) || "") + "\n");
781
+ hash.update(String((req.headers && req.headers["date"]) || "") + "\n");
782
+ hash.update(String((req.headers && req.headers["idempotency-key"]) || "") + "\n");
783
+ var key = hash.digest("hex");
784
+ if (this._tls0RttReplayCache.has(key)) {
785
+ try {
786
+ auditFwk().safeEmit({
787
+ action: "tls.0rtt.replayed",
788
+ outcome: "denied",
789
+ metadata: { reason: "cache-hit", method: req.method, url: req.url,
790
+ windowMs: TLS_0RTT_REPLAY_WINDOW_MS },
791
+ });
792
+ } catch (_e) { /* audit best-effort */ }
793
+ return { status: 425, reason: "early-data-replay" };
794
+ }
795
+ // Bounded entry count — when the cache hits the cap, drop the
796
+ // oldest entries to make room. The reap pass already ran above.
797
+ if (this._tls0RttReplayCache.size >= TLS_0RTT_REPLAY_CACHE_CAP) {
798
+ var keys = this._tls0RttReplayCache.keys();
799
+ var toEvict = (this._tls0RttReplayCache.size - TLS_0RTT_REPLAY_CACHE_CAP) + 1;
800
+ for (var i = 0; i < toEvict; i += 1) {
801
+ var first = keys.next();
802
+ if (first.done) break;
803
+ this._tls0RttReplayCache.delete(first.value);
804
+ }
805
+ }
806
+ this._tls0RttReplayCache.set(key, nowMs + TLS_0RTT_REPLAY_WINDOW_MS);
807
+ try {
808
+ auditFwk().safeEmit({
809
+ action: "tls.0rtt.accepted",
810
+ outcome: "success",
811
+ metadata: { method: req.method, url: req.url, windowMs: TLS_0RTT_REPLAY_WINDOW_MS },
812
+ });
813
+ } catch (_e) { /* audit best-effort */ }
814
+ return null;
815
+ }
816
+
817
+ _reap0RttCache(nowMs) {
818
+ if (this._tls0RttReplayCache.size === 0) return;
819
+ var iter = this._tls0RttReplayCache.entries();
820
+ for (var entry = iter.next(); !entry.done; entry = iter.next()) {
821
+ if (entry.value[1] <= nowMs) this._tls0RttReplayCache.delete(entry.value[0]);
822
+ }
823
+ }
824
+
581
825
  listen(port, cb, tlsOptions, host) {
582
826
  var self = this;
583
827
  var requestHandler = (req, res) => {
828
+ // RFC 8446 §8 / RFC 8470 — TLS 1.3 0-RTT anti-replay gate.
829
+ // Refuse / dedupe Early-Data: 1 forwarded requests per the
830
+ // operator's tls0Rtt posture.
831
+ var verdict0Rtt = self._check0RttReplay(req);
832
+ if (verdict0Rtt) {
833
+ // RFC 8470 §5 — 425 Too Early. Connection: close so the peer
834
+ // cannot reuse the session ticket on the next attempt.
835
+ res.writeHead(425, {
836
+ "Content-Type": "text/plain; charset=utf-8",
837
+ "Connection": "close",
838
+ });
839
+ res.end(verdict0Rtt.reason);
840
+ return;
841
+ }
584
842
  // Response helpers
585
843
  res.json = (data) => {
586
844
  res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
587
845
  res.end(JSON.stringify(data));
588
846
  };
589
847
  res.redirect = (url) => {
590
- // Same-origin redirects only by default. Apps that need cross-origin
591
- // redirects (OAuth, SSO) wrap res.redirect with their own allowlist.
592
- var safe = "/";
593
- if (typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) {
594
- safe = url;
848
+ // Same-origin (single leading slash, not protocol-relative) is
849
+ // always allowed. Cross-origin redirects (OAuth authorization
850
+ // endpoint, SSO bounce, SCIM step-up) require the operator to
851
+ // declare the destination via `allowedRedirectOrigins` on
852
+ // router.create. Anything else throws RouterError — silently
853
+ // rewriting attacker-controlled redirect targets to "/" hides
854
+ // open-redirect attempts that operators want to see in audit.
855
+ if (typeof url !== "string" || url.length === 0) {
856
+ throw new RouterError(
857
+ "router/redirect-target-not-string",
858
+ "res.redirect: target must be a non-empty string"
859
+ );
860
+ }
861
+ // Reject embedded CR / LF / NUL early — header injection class.
862
+ // Node's writeHead would refuse these too, but the explicit
863
+ // refusal here gives operators a router-shaped error rather than
864
+ // a generic ERR_INVALID_CHAR.
865
+ for (var ci = 0; ci < url.length; ci += 1) {
866
+ var cc = url.charCodeAt(ci);
867
+ if (cc === 0x00 || cc === 0x0A || cc === 0x0D) {
868
+ throw new RouterError(
869
+ "router/redirect-target-has-control-chars",
870
+ "res.redirect: target must not contain CR / LF / NUL bytes"
871
+ );
872
+ }
595
873
  }
596
- // 302 Found RFC 7231 §6.4.3. Not in HTTP_STATUS table.
597
- res.writeHead(302, { Location: safe });
874
+ // Same-origin path: a single leading "/" not followed by another
875
+ // "/" or "\" (the protocol-relative + Windows-share shapes that
876
+ // browsers happily resolve as off-origin).
877
+ if (url.charAt(0) === "/" &&
878
+ url.charAt(1) !== "/" && url.charAt(1) !== "\\") {
879
+ // 302 Found — RFC 7231 §6.4.3. Not in HTTP_STATUS table.
880
+ res.writeHead(302, { Location: url });
881
+ res.end();
882
+ return;
883
+ }
884
+ // Cross-origin path: parse + match against the allowlist.
885
+ var parsedTarget;
886
+ try {
887
+ parsedTarget = safeUrl.parse(url, {
888
+ allowedProtocols: ["https:"],
889
+ });
890
+ } catch (parseErr) {
891
+ try {
892
+ auditFwk().safeEmit({
893
+ action: "router.redirect.cross_origin.refused",
894
+ outcome: "denied",
895
+ metadata: {
896
+ reason: "target-parse-failed",
897
+ target: url,
898
+ cause: parseErr && parseErr.message,
899
+ },
900
+ });
901
+ } catch (_e) { /* audit best-effort */ }
902
+ throw new RouterError(
903
+ "router/redirect-cross-origin-refused",
904
+ "res.redirect: cross-origin target '" + url + "' is not a valid HTTPS URL (" +
905
+ (parseErr && parseErr.message) + ")"
906
+ );
907
+ }
908
+ var targetOrigin = parsedTarget.origin;
909
+ var allowlist = self._allowedRedirectOrigins;
910
+ var match = false;
911
+ for (var ai = 0; ai < allowlist.length; ai += 1) {
912
+ if (allowlist[ai] === targetOrigin) { match = true; break; }
913
+ }
914
+ if (!match) {
915
+ try {
916
+ auditFwk().safeEmit({
917
+ action: "router.redirect.cross_origin.refused",
918
+ outcome: "denied",
919
+ metadata: {
920
+ reason: allowlist.length === 0 ? "no-allowlist" : "origin-not-in-allowlist",
921
+ target: url,
922
+ origin: targetOrigin,
923
+ },
924
+ });
925
+ } catch (_e) { /* audit best-effort */ }
926
+ throw new RouterError(
927
+ "router/redirect-cross-origin-refused",
928
+ "res.redirect: cross-origin target '" + targetOrigin +
929
+ "' is not in router.allowedRedirectOrigins"
930
+ );
931
+ }
932
+ try {
933
+ auditFwk().safeEmit({
934
+ action: "router.redirect.cross_origin.allowed",
935
+ outcome: "success",
936
+ metadata: { target: url, origin: targetOrigin },
937
+ });
938
+ } catch (_e) { /* audit best-effort */ }
939
+ res.writeHead(302, { Location: url });
598
940
  res.end();
599
941
  };
600
942
  res.status = (code) => {
@@ -648,6 +990,14 @@ class Router {
648
990
  if (!tlsOptions.minVersion) {
649
991
  tlsOptions = Object.assign({ minVersion: "TLSv1.3" }, tlsOptions);
650
992
  }
993
+ // RFC 8446 §8 / §2.3 — TLS 1.3 0-RTT anti-replay posture. Operator
994
+ // sets allowEarlyData per `tls0Rtt`. Default "refuse" matches Node.
995
+ // "replay-cache" admits 0-RTT but every Early-Data: 1 request is
996
+ // dedupe-checked in the request handler against the rolling cache.
997
+ var posture0Rtt = self._effective0RttPosture();
998
+ if (tlsOptions.allowEarlyData === undefined) {
999
+ tlsOptions.allowEarlyData = (posture0Rtt === "replay-cache");
1000
+ }
651
1001
  // h2-capable server with h1 fallback via ALPN. ["h2", "http/1.1"]
652
1002
  // means modern clients negotiate h2 (preferred); legacy clients
653
1003
  // fall back to h1. allowHTTP1: true is what makes the same server
@@ -682,6 +1032,51 @@ class Router {
682
1032
  maxOutstandingPings: 10, // allow:raw-byte-literal — CVE-2019-9512 ping-flood cap (pin to Node default rather than letting it drift)
683
1033
  unknownProtocolTimeout: C.TIME.seconds(10),
684
1034
  }, tlsOptions), requestHandler);
1035
+
1036
+ // CVE-2026-21714 — H/2 WINDOW_UPDATE leak after GOAWAY. nghttp2
1037
+ // holds per-stream flow-control state after GOAWAY; late-arriving
1038
+ // WINDOW_UPDATE frames can re-credit a draining stream. Node's
1039
+ // http2 module hands flow control to nghttp2 internally and does
1040
+ // not expose a per-frame WINDOW_UPDATE listener; the framework
1041
+ // gate is to track GOAWAY state on every session, refuse new
1042
+ // streams once GOAWAY has been emitted by either side, and
1043
+ // force-destroy the session on any post-GOAWAY stream activity.
1044
+ // Combined with the Node 24.14+ engine pin (where the upstream
1045
+ // nghttp2 fix lives), the path closes at both layers.
1046
+ server.on("session", function (h2session) {
1047
+ h2session._blamejsGoawaySent = false;
1048
+ // Wrap goaway() so the framework's own send marks the session.
1049
+ var origGoaway = (typeof h2session.goaway === "function")
1050
+ ? h2session.goaway.bind(h2session) : null;
1051
+ if (origGoaway) {
1052
+ h2session.goaway = function (code, lastStreamID, opaqueData) {
1053
+ h2session._blamejsGoawaySent = true;
1054
+ return origGoaway(code, lastStreamID, opaqueData);
1055
+ };
1056
+ }
1057
+ // Inbound GOAWAY from peer also flips the flag — once GOAWAY is
1058
+ // in flight in either direction, no new streams should land.
1059
+ h2session.on("goaway", function () {
1060
+ h2session._blamejsGoawaySent = true;
1061
+ });
1062
+ // Per-stream rate cap on _any_ post-GOAWAY activity. If a stream
1063
+ // opens after GOAWAY emission, refuse + audit + destroy session
1064
+ // (a clean peer would not initiate after GOAWAY).
1065
+ h2session.on("stream", function (stream) {
1066
+ if (h2session._blamejsGoawaySent) {
1067
+ try { auditFwk().safeEmit({
1068
+ action: "http2.window_update.refused",
1069
+ outcome: "denied",
1070
+ metadata: { reason: "post-goaway-stream", streamId: stream.id || null,
1071
+ frameType: WINDOW_UPDATE_FRAME_TYPE,
1072
+ rateCap: WINDOW_UPDATE_RATE_CAP,
1073
+ rateWindowMs: WINDOW_UPDATE_RATE_WINDOW_MS },
1074
+ }); } catch (_e) { /* audit best-effort */ }
1075
+ try { stream.close(); } catch (_e) { /* stream already closed */ }
1076
+ try { h2session.destroy(); } catch (_e) { /* session already closed */ }
1077
+ }
1078
+ });
1079
+ });
685
1080
  } else {
686
1081
  // Cleartext path is h1-only. Operators wanting h2c on cleartext
687
1082
  // are typically running behind a TLS-terminating LB that does
@@ -782,6 +1177,28 @@ class Router {
782
1177
  }
783
1178
  }
784
1179
 
1180
+ /**
1181
+ * @primitive b.router.serveStatic
1182
+ * @signature b.router.serveStatic(dir)
1183
+ * @since 0.1.0
1184
+ * @related b.router.create, b.staticServe
1185
+ *
1186
+ * Returns a middleware function that serves files from `dir` for GET
1187
+ * requests whose `req.pathname` resolves inside `dir`. Path traversal
1188
+ * (`..`) and NUL-byte filenames bypass the middleware (next()), as do
1189
+ * directory listings and missing files. Sniffed Content-Type comes
1190
+ * from a small extension table; unknown extensions fall back to
1191
+ * `application/octet-stream`. Versioned URLs (`?v=...`) ship with a
1192
+ * one-year `immutable` Cache-Control; un-versioned files get one hour.
1193
+ *
1194
+ * For richer content-safety, byte-range requests, and the framework's
1195
+ * full guard wiring, prefer `b.staticServe.create` over this helper.
1196
+ *
1197
+ * @example
1198
+ * var router = b.router.create();
1199
+ * router.use(b.router.serveStatic("/var/www/public"));
1200
+ * router.listen(3000);
1201
+ */
785
1202
  // Static file serving middleware
786
1203
  function serveStatic(dir) {
787
1204
  var root = path.resolve(dir);
@@ -809,7 +1226,42 @@ function serveStatic(dir) {
809
1226
  };
810
1227
  }
811
1228
 
1229
+ /**
1230
+ * @primitive b.router.create
1231
+ * @signature b.router.create(opts?)
1232
+ * @since 0.1.0
1233
+ * @related b.router.serveStatic
1234
+ *
1235
+ * Builds a `Router` instance with the framework's security-on-by-
1236
+ * default posture. Returned object exposes `get / post / put / patch
1237
+ * / delete` for route registration, `use(fn)` for global middleware,
1238
+ * `ws(path, handler, opts?)` for WebSocket routes, `onNotFound(fn)`
1239
+ * and `onError(fn)` for fallthrough hooks, `inspectRoutes()` and
1240
+ * `openapi()` for introspection, `closeWebSockets({ timeoutMs })`
1241
+ * for graceful shutdown, and `listen(port, cb?, tlsOptions?, host?)`
1242
+ * which boots an HTTP/2-capable TLS server (ALPN h2 + http/1.1) when
1243
+ * `tlsOptions` is provided, an HTTP/1.1 server otherwise.
1244
+ *
1245
+ * @opts
1246
+ * tls0Rtt: "refuse" | "replay-cache", // RFC 8446 §8 anti-replay; default "refuse"
1247
+ * allowedRedirectOrigins: string[], // exact-match HTTPS origins for cross-origin res.redirect()
1248
+ *
1249
+ * @example
1250
+ * var router = b.router.create({
1251
+ * tls0Rtt: "refuse",
1252
+ * allowedRedirectOrigins: ["https://idp.example.com"],
1253
+ * });
1254
+ * router.get("/users/:id", function (req, res) {
1255
+ * res.json({ id: req.params.id });
1256
+ * });
1257
+ * router.listen(3000);
1258
+ */
1259
+ function create(opts) {
1260
+ return new Router(opts);
1261
+ }
1262
+
812
1263
  module.exports = {
813
1264
  Router: Router,
1265
+ create: create,
814
1266
  serveStatic: serveStatic,
815
1267
  };