@daloyjs/core 0.30.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +14 -1
  2. package/bin/daloy.mjs +0 -0
  3. package/dist/adapters/bun.d.ts.map +1 -1
  4. package/dist/adapters/bun.js +5 -1
  5. package/dist/adapters/bun.js.map +1 -1
  6. package/dist/adapters/node.d.ts.map +1 -1
  7. package/dist/adapters/node.js +7 -2
  8. package/dist/adapters/node.js.map +1 -1
  9. package/dist/app.d.ts +47 -36
  10. package/dist/app.d.ts.map +1 -1
  11. package/dist/app.js +229 -59
  12. package/dist/app.js.map +1 -1
  13. package/dist/banner.d.ts +2 -0
  14. package/dist/banner.d.ts.map +1 -1
  15. package/dist/banner.js.map +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +29 -31
  18. package/dist/cli.js.map +1 -1
  19. package/dist/client.d.ts +10 -0
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js.map +1 -1
  22. package/dist/compression.d.ts +2 -2
  23. package/dist/compression.js +2 -2
  24. package/dist/config.d.ts +2 -1
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +2 -1
  27. package/dist/config.js.map +1 -1
  28. package/dist/conn-info.d.ts +5 -5
  29. package/dist/conn-info.js +4 -4
  30. package/dist/contract.d.ts +19 -0
  31. package/dist/contract.d.ts.map +1 -1
  32. package/dist/contract.js +10 -0
  33. package/dist/contract.js.map +1 -1
  34. package/dist/cookie.d.ts +15 -1
  35. package/dist/cookie.d.ts.map +1 -1
  36. package/dist/cookie.js +24 -4
  37. package/dist/cookie.js.map +1 -1
  38. package/dist/dependency.d.ts +1 -1
  39. package/dist/dependency.js +1 -1
  40. package/dist/discriminator.d.ts +9 -0
  41. package/dist/discriminator.d.ts.map +1 -1
  42. package/dist/discriminator.js.map +1 -1
  43. package/dist/docs.d.ts +49 -2
  44. package/dist/docs.d.ts.map +1 -1
  45. package/dist/docs.js +26 -2
  46. package/dist/docs.js.map +1 -1
  47. package/dist/errors.d.ts +89 -0
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js +148 -7
  50. package/dist/errors.js.map +1 -1
  51. package/dist/etag.d.ts +1 -1
  52. package/dist/etag.js +1 -1
  53. package/dist/fetch-guard.d.ts +163 -0
  54. package/dist/fetch-guard.d.ts.map +1 -0
  55. package/dist/fetch-guard.js +265 -0
  56. package/dist/fetch-guard.js.map +1 -0
  57. package/dist/index.d.ts +6 -4
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/ip-restriction.d.ts +17 -0
  62. package/dist/ip-restriction.d.ts.map +1 -1
  63. package/dist/ip-restriction.js +6 -3
  64. package/dist/ip-restriction.js.map +1 -1
  65. package/dist/jwk.d.ts +1 -1
  66. package/dist/jwk.d.ts.map +1 -1
  67. package/dist/jwk.js +3 -3
  68. package/dist/jwt.d.ts +3 -3
  69. package/dist/jwt.d.ts.map +1 -1
  70. package/dist/jwt.js +19 -7
  71. package/dist/jwt.js.map +1 -1
  72. package/dist/load-shedding.d.ts +1 -1
  73. package/dist/load-shedding.js +1 -1
  74. package/dist/logger.d.ts +14 -0
  75. package/dist/logger.d.ts.map +1 -1
  76. package/dist/logger.js +66 -4
  77. package/dist/logger.js.map +1 -1
  78. package/dist/middleware.d.ts +40 -15
  79. package/dist/middleware.d.ts.map +1 -1
  80. package/dist/middleware.js +18 -14
  81. package/dist/middleware.js.map +1 -1
  82. package/dist/multipart.d.ts +22 -0
  83. package/dist/multipart.d.ts.map +1 -1
  84. package/dist/multipart.js +69 -0
  85. package/dist/multipart.js.map +1 -1
  86. package/dist/openapi.d.ts +13 -0
  87. package/dist/openapi.d.ts.map +1 -1
  88. package/dist/openapi.js +5 -0
  89. package/dist/openapi.js.map +1 -1
  90. package/dist/router.d.ts +9 -0
  91. package/dist/router.d.ts.map +1 -1
  92. package/dist/router.js +6 -0
  93. package/dist/router.js.map +1 -1
  94. package/dist/sbom.cdx.json +60 -0
  95. package/dist/sbom.spdx.json +42 -0
  96. package/dist/security-schemes.d.ts +29 -0
  97. package/dist/security-schemes.d.ts.map +1 -1
  98. package/dist/security-schemes.js +11 -0
  99. package/dist/security-schemes.js.map +1 -1
  100. package/dist/security.d.ts +53 -2
  101. package/dist/security.d.ts.map +1 -1
  102. package/dist/security.js +67 -2
  103. package/dist/security.js.map +1 -1
  104. package/dist/session.d.ts +4 -2
  105. package/dist/session.d.ts.map +1 -1
  106. package/dist/session.js +2 -2
  107. package/dist/session.js.map +1 -1
  108. package/dist/streaming.d.ts +8 -0
  109. package/dist/streaming.d.ts.map +1 -1
  110. package/dist/streaming.js.map +1 -1
  111. package/dist/subdomains.d.ts +1 -1
  112. package/dist/subdomains.js +1 -1
  113. package/dist/time-claims.d.ts +1 -2
  114. package/dist/time-claims.d.ts.map +1 -1
  115. package/dist/time-claims.js +1 -2
  116. package/dist/time-claims.js.map +1 -1
  117. package/dist/tracing.d.ts +10 -0
  118. package/dist/tracing.d.ts.map +1 -1
  119. package/dist/tracing.js +5 -0
  120. package/dist/tracing.js.map +1 -1
  121. package/dist/types.d.ts +1 -1
  122. package/dist/websocket.d.ts +87 -0
  123. package/dist/websocket.d.ts.map +1 -1
  124. package/dist/websocket.js +81 -0
  125. package/dist/websocket.js.map +1 -1
  126. package/package.json +31 -10
package/dist/app.js CHANGED
@@ -2,11 +2,12 @@ import { Router } from "./router.js";
2
2
  import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
3
3
  import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
4
4
  import { validate } from "./schema.js";
5
- import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertStrongSecret, timingSafeEqual } from "./security.js";
5
+ import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
6
6
  import { createLogger, noopLogger } from "./logger.js";
7
7
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
8
8
  import { docsContentSecurityPolicy, scalarHtml, swaggerUiHtml, } from "./docs.js";
9
9
  import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
10
+ import { COMPRESSION_HOOK_MARKER } from "./compression.js";
10
11
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
11
12
  import { loadShedding as loadSheddingMiddleware } from "./load-shedding.js";
12
13
  import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
@@ -29,8 +30,8 @@ export function _resetCrashHandlersForTests() {
29
30
  activeCrashLogger = undefined;
30
31
  }
31
32
  /**
32
- * Wave 8 — once-per-process latch for the `secureDefaults: false` warning
33
- * log. Multiple `new App({ secureDefaults: false })` instances in the same
33
+ * Once-per-process latch for the `secureDefaults: false` warning log.
34
+ * Multiple `new App({ secureDefaults: false })` instances in the same
34
35
  * process share one log entry instead of flooding the access log with
35
36
  * duplicate warnings on every construction.
36
37
  */
@@ -40,9 +41,9 @@ export function _resetInsecureDefaultsLogForTests() {
40
41
  insecureDefaultsLoggedThisProcess = false;
41
42
  }
42
43
  /**
43
- * Wave 8 — list of secure-by-default surfaces disabled when
44
- * `secureDefaults: false` is set. Surfaced through the once-per-process
45
- * `error` log so the operator sees exactly which guards are off.
44
+ * List of secure-by-default surfaces disabled when `secureDefaults: false`
45
+ * is set. Surfaced through the once-per-process `error` log so the operator
46
+ * sees exactly which guards are off.
46
47
  */
47
48
  const DISABLED_BY_INSECURE_DEFAULTS = Object.freeze([
48
49
  "secureHeaders auto-install",
@@ -54,6 +55,25 @@ const DISABLED_BY_INSECURE_DEFAULTS = Object.freeze([
54
55
  "cors({ origin: '*' }) refuse-to-boot",
55
56
  "anonymous stateful plugin refuse-to-boot",
56
57
  ]);
58
+ /**
59
+ * The exact RFC 7231 + RFC 5789 HTTP-method allowlist.
60
+ * The framework refuses any other method at route-registration time so
61
+ * `TRACE`, `CONNECT`, and WebDAV verbs (`MKCOL`, `COPY`, `PROPFIND`,
62
+ * `MOVE`, etc.) cannot bypass the strict Content-Type / body-on-GET /
63
+ * TRACE-and-CONNECT-refusal defaults. The set mirrors the `HttpMethod`
64
+ * union exported from `./types.ts`.
65
+ *
66
+ * @internal
67
+ */
68
+ const CANONICAL_HTTP_METHODS = new Set([
69
+ "GET",
70
+ "POST",
71
+ "PUT",
72
+ "PATCH",
73
+ "DELETE",
74
+ "HEAD",
75
+ "OPTIONS",
76
+ ]);
57
77
  const DEFAULTS = {
58
78
  bodyLimitBytes: 1024 * 1024,
59
79
  requestTimeoutMs: 30_000,
@@ -127,7 +147,7 @@ export class App {
127
147
  installedPlugins = new Set();
128
148
  closeHooks = [];
129
149
  closeHooksRun = false;
130
- /** Wave 4 idle-connection close hooks (adapter-registered, sync). */
150
+ /** Idle-connection close hooks (adapter-registered, sync). */
131
151
  idleConnectionCloseHooks = [];
132
152
  pluginInstalledListeners = [];
133
153
  shutdownListeners = [];
@@ -148,13 +168,13 @@ export class App {
148
168
  */
149
169
  corsOriginAllows = [];
150
170
  /**
151
- * Whether the Wave 3 once-only boot guard has run (session + CSRF +
152
- * state-changing-route check). The check is deferred to first request
171
+ * Whether the once-only session + CSRF + state-changing-route boot
172
+ * guard has run. The check is deferred to first request
153
173
  * because route registration and `app.use(csrf(...))` can happen in any
154
174
  * order after construction; doing it on first `fetch()` is the latest
155
175
  * point we still get a 500 before any handler ever runs.
156
176
  */
157
- wave3BootGuard = { checked: false };
177
+ bootGuard = { checked: false };
158
178
  /**
159
179
  * Latched marker stamped after the framework has reported the first
160
180
  * unconfigured-proxy request. Logged once at `warn` level so production
@@ -186,7 +206,7 @@ export class App {
186
206
  this.maybeMountDocs();
187
207
  }
188
208
  /**
189
- * Wave 4 leftover: validate {@link AppOptions.disconnectStatusCode}.
209
+ * Validate {@link AppOptions.disconnectStatusCode}.
190
210
  * Refuses anything outside `[400, 499]` (except `0`, which disables the
191
211
  * rewrite). Throws at construction time so the misconfiguration cannot
192
212
  * survive past boot.
@@ -200,7 +220,7 @@ export class App {
200
220
  }
201
221
  }
202
222
  /**
203
- * Wave 8 — master-flag escape-hatch enforcement. When
223
+ * Master-flag escape-hatch enforcement. When
204
224
  * `secureDefaults: false` is set, the framework:
205
225
  * - refuses-to-construct in production unless
206
226
  * `acknowledgeInsecureDefaults: true` is also set; closes the
@@ -216,7 +236,7 @@ export class App {
216
236
  const inProduction = this.isProduction();
217
237
  if (inProduction && this.options.acknowledgeInsecureDefaults !== true) {
218
238
  throw new Error("app({ secureDefaults: false }) is refused in production. " +
219
- "secureDefaults turns off the entire Wave 1–7 secure-by-default " +
239
+ "secureDefaults turns off the entire secure-by-default " +
220
240
  "surface (auto secureHeaders, cross-origin guard, crash-on-unhandled-rejection, " +
221
241
  "trustProxy guard, csrf/session boot guard, weak-secret refuse-to-boot, " +
222
242
  "cors({ origin: '*' }) refuse-to-boot, anonymous-stateful-plugin refuse-to-boot). " +
@@ -236,7 +256,7 @@ export class App {
236
256
  }
237
257
  }
238
258
  /**
239
- * Install the Wave 2 secure-by-default global hooks. Currently:
259
+ * Install the secure-by-default global hooks. Currently:
240
260
  * - {@link secureHeaders} as a group-level hook so every response carries
241
261
  * the hardened baseline (HSTS, X-Frame-Options, nosniff, default CSP).
242
262
  *
@@ -258,7 +278,7 @@ export class App {
258
278
  auto[AUTO_SECURE_HEADERS_MARKER] = true;
259
279
  this.groupHooks.push(auto);
260
280
  }
261
- // Wave 4 leftover: opt-in load-shedding pressure monitor.
281
+ // Opt-in load-shedding pressure monitor.
262
282
  if (this.options.loadShedding) {
263
283
  const lsOpts = typeof this.options.loadShedding === "object"
264
284
  ? this.options.loadShedding
@@ -267,7 +287,7 @@ export class App {
267
287
  }
268
288
  }
269
289
  /**
270
- * Wave 4 crash-on-unrecoverable-error guard. Installs Node-process-level
290
+ * Crash-on-unrecoverable-error guard. Installs Node-process-level
271
291
  * listeners for `unhandledRejection` and `uncaughtException` that log
272
292
  * through the pluggable logger and call `process.exit(1)`. Idempotent via
273
293
  * a module-level latch so multiple `new App()` instantiations in the same
@@ -343,7 +363,7 @@ export class App {
343
363
  process.env.NODE_ENV === "production");
344
364
  }
345
365
  /**
346
- * Wave 2 cross-origin guard. Rejects state-changing requests (`POST` /
366
+ * Cross-origin guard. Rejects state-changing requests (`POST` /
347
367
  * `PUT` / `PATCH` / `DELETE`) that carry an `Origin` header pointing at a
348
368
  * different origin than the request URL when no {@link cors} hook is
349
369
  * registered (neither at the app level nor on the matched route). Throws
@@ -388,7 +408,7 @@ export class App {
388
408
  `app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
389
409
  }
390
410
  /**
391
- * Wave 3 sync boot guard. Inspects a hook layer being installed via
411
+ * Sync boot guard. Inspects a hook layer being installed via
392
412
  * {@link App.use} and refuses-to-boot when:
393
413
  *
394
414
  * - `cors({ origin: "*" })` is registered while resolved environment is
@@ -431,12 +451,12 @@ export class App {
431
451
  `but security scheme "${auth.scheme}" requires payload authentication. ` +
432
452
  `Remove the route-level opt-out or use a scheme without requirePayloadAuth: true.`);
433
453
  }
434
- resetWave3BootGuardCache() {
435
- this.wave3BootGuard.checked = false;
436
- this.wave3BootGuard.error = undefined;
454
+ resetBootGuardCache() {
455
+ this.bootGuard.checked = false;
456
+ this.bootGuard.error = undefined;
437
457
  }
438
458
  /**
439
- * Wave 3 first-request boot guard. Verifies that the assembled hook
459
+ * First-request boot guard. Verifies that the assembled hook
440
460
  * chain + route table is internally consistent before any user handler
441
461
  * runs. Currently checks: when `session()` is installed and any route
442
462
  * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
@@ -446,13 +466,13 @@ export class App {
446
466
  * `app({ secureDefaults: false })`. Runs once per App between registration
447
467
  * changes; the result is cached so the fast path is a single boolean check.
448
468
  */
449
- assertWave3BootGuards() {
450
- if (this.wave3BootGuard.checked) {
451
- if (this.wave3BootGuard.error)
452
- throw this.wave3BootGuard.error;
469
+ assertBootGuards() {
470
+ if (this.bootGuard.checked) {
471
+ if (this.bootGuard.error)
472
+ throw this.bootGuard.error;
453
473
  return;
454
474
  }
455
- this.wave3BootGuard.checked = true;
475
+ this.bootGuard.checked = true;
456
476
  if (this.options.secureDefaults === false)
457
477
  return;
458
478
  if (this.options.csrf === "off")
@@ -469,11 +489,11 @@ export class App {
469
489
  `(${stateChanging.method} ${stateChanging.path}) but no csrf() hook is installed. ` +
470
490
  `Register csrf() via app.use(csrf({ strategy: "fetch-metadata", allowedOrigins: [...] })), ` +
471
491
  `or pass app({ csrf: "off" }) to acknowledge that this app is not browser-facing.`);
472
- this.wave3BootGuard.error = err;
492
+ this.bootGuard.error = err;
473
493
  throw err;
474
494
  }
475
495
  /**
476
- * Wave 3 per-request guard for spoofed proxy headers. When the App was
496
+ * Per-request guard for spoofed proxy headers. When the App was
477
497
  * constructed without an explicit {@link AppOptions.trustProxy} value
478
498
  * and a request arrives carrying an `X-Forwarded-*` header, refuse to
479
499
  * dispatch it: the rate limiter, audit log, and request-id propagation
@@ -487,7 +507,7 @@ export class App {
487
507
  return;
488
508
  if (this.options.trustProxy !== undefined)
489
509
  return;
490
- // Wave 6: `behindProxy` is the declarative successor — when supplied,
510
+ // `behindProxy` is the declarative successor — when supplied,
491
511
  // the framework already knows how to interpret forwarded headers and
492
512
  // the legacy unconfigured-proxy guard is satisfied.
493
513
  if (this.options.behindProxy !== undefined)
@@ -681,6 +701,20 @@ export class App {
681
701
  * @returns This `App` instance for chaining.
682
702
  */
683
703
  route(def) {
704
+ // Refuse non-canonical HTTP methods at runtime.
705
+ // The TypeScript `HttpMethod` union already constrains the public
706
+ // surface, but an unsafe cast (or a runtime caller in plain JS)
707
+ // could bypass it. Closes the "framework silently routes WebDAV /
708
+ // TRACE / CONNECT" class of bypass-via-extended-method bugs at the
709
+ // framework boundary.
710
+ if (!CANONICAL_HTTP_METHODS.has(def.method)) {
711
+ throw new Error(`app.route(): HTTP method "${String(def.method)}" is not on ` +
712
+ `the RFC 7231 + RFC 5789 allowlist. Allowed: ` +
713
+ `${[...CANONICAL_HTTP_METHODS].join(", ")}. Custom methods ` +
714
+ `(TRACE, CONNECT, WebDAV verbs, etc.) bypass the strict ` +
715
+ `Content-Type, body-on-GET, and TRACE/CONNECT refusal ` +
716
+ `defaults and are not supported.`);
717
+ }
684
718
  if (def.hooks)
685
719
  this.assertSecureHookConfig(def.hooks);
686
720
  const fullPath = joinPath(this.prefix, def.path);
@@ -705,7 +739,7 @@ export class App {
705
739
  path: merged.path,
706
740
  ...securityMarkers,
707
741
  });
708
- this.resetWave3BootGuardCache();
742
+ this.resetBootGuardCache();
709
743
  return this;
710
744
  }
711
745
  /**
@@ -717,10 +751,65 @@ export class App {
717
751
  */
718
752
  ws(path, handler) {
719
753
  const fullPath = joinPath(this.prefix, path);
754
+ const production = this.isProduction();
755
+ const secureDefaults = this.options.secureDefaults !== false;
720
756
  const options = normalizeWebSocketOptions(handler, {
721
- production: this.isProduction(),
722
- secureDefaults: this.options.secureDefaults !== false,
757
+ production,
758
+ secureDefaults,
723
759
  });
760
+ // Pre-upgrade authentication boundary. In production under
761
+ // secureDefaults, a WebSocket route must either make an explicit
762
+ // pre-upgrade decision (`beforeUpgrade`) or acknowledge that the route is
763
+ // intentionally public. This prevents accidental auth in `open()` after
764
+ // the 101 response has already committed the connection.
765
+ if (production &&
766
+ secureDefaults &&
767
+ handler.beforeUpgrade === undefined &&
768
+ handler.acknowledgeUnauthenticated !== true) {
769
+ throw new Error(`app.ws(${JSON.stringify(fullPath)}): production WebSocket routes must ` +
770
+ "authenticate or reject clients before the RFC 6455 upgrade. Add a " +
771
+ "beforeUpgrade hook for authenticated routes, or pass " +
772
+ "{ acknowledgeUnauthenticated: true } for an intentionally public route.");
773
+ }
774
+ // Cross-Site WebSocket Hijacking (CSWSH) guard. Storybook's
775
+ // CVE-2026-27148 — and the Cross-Site WebSocket Hijacking class of bug
776
+ // in general — exploits the fact that browsers attach cookies on a WS
777
+ // upgrade no matter which origin opened the connection. A
778
+ // `beforeUpgrade` hook that only checks cookies/JWTs still accepts the
779
+ // attacker's handshake. Refuse-at-registration unless the route either
780
+ // sets an Origin allowlist or explicitly acknowledges the exposure.
781
+ if (production &&
782
+ secureDefaults &&
783
+ handler.allowedOrigins === undefined &&
784
+ handler.acknowledgeCrossOriginUpgrade !== true) {
785
+ throw new Error(`app.ws(${JSON.stringify(fullPath)}): production WebSocket routes must ` +
786
+ "guard against Cross-Site WebSocket Hijacking (CSWSH). Set " +
787
+ "{ allowedOrigins: \"same-origin\" } or an explicit origin allowlist, " +
788
+ "or pass { acknowledgeCrossOriginUpgrade: true } for an intentionally " +
789
+ "public route. See https://daloyjs.dev/docs/websocket " +
790
+ "and CVE-2026-27148 (Storybook) for the attack pattern.");
791
+ }
792
+ // WebSocket post-upgrade header immutability. Once the RFC
793
+ // 6455 101 handshake has been sent, no further response headers can be
794
+ // added by middleware; mounting header-mutating middleware on a path
795
+ // that also matches a WS route is a documented misconfiguration in
796
+ // upstream frameworks. Refuse-at-registration with a structured error
797
+ // naming both the WS route and the conflicting middleware unless the
798
+ // developer explicitly acknowledges the configuration.
799
+ if (handler.acknowledgeHeaderMutatingMiddleware !== true) {
800
+ const conflicts = detectHeaderMutatingMiddleware([
801
+ this.options.hooks ?? {},
802
+ ...this.groupHooks,
803
+ ]);
804
+ if (conflicts.length > 0) {
805
+ throw new Error(`app.ws(${JSON.stringify(fullPath)}): ${conflicts.join(", ")} ` +
806
+ "middleware is mounted on a path that matches this WebSocket " +
807
+ "route, but no response headers can be added after the RFC 6455 " +
808
+ "upgrade. Either move the middleware below the WebSocket scope, " +
809
+ "or pass { acknowledgeHeaderMutatingMiddleware: true } after " +
810
+ "confirming the middleware does not run on Upgrade requests.");
811
+ }
812
+ }
724
813
  this.webSocketRoutes.add(fullPath, handler, () => ({ ...this.decorations }), options);
725
814
  return this;
726
815
  }
@@ -730,7 +819,7 @@ export class App {
730
819
  * container orchestrator `livenessProbe` configuration — a failing
731
820
  * liveness probe restarts the container.
732
821
  *
733
- * Defaults (Wave 4 secure-by-default):
822
+ * Defaults (secure-by-default):
734
823
  * - path: `/healthz`
735
824
  * - rate-limit: 60 req/min per remote IP, in-memory (per-process)
736
825
  * - auth: opt-in via `token`. In production with `secureDefaults: true`,
@@ -789,7 +878,7 @@ export class App {
789
878
  ? null
790
879
  : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
791
880
  const token = opts.token;
792
- // Wave 4 refuse-to-boot: unauthenticated health/ready probes in
881
+ // Refuse-to-boot: unauthenticated health/ready probes in
793
882
  // production are a documented info-disclosure surface (process uptime,
794
883
  // plugin-ready transitions, internal hostnames in some shops). Force
795
884
  // an explicit acknowledgement.
@@ -852,7 +941,7 @@ export class App {
852
941
  });
853
942
  }
854
943
  /**
855
- * Wave 4 leftover: register a built-in receiver for CSP / Reporting API
944
+ * Register a built-in receiver for CSP / Reporting API
856
945
  * violation reports. Accepts `application/csp-report` and
857
946
  * `application/reports+json` payloads, rate-limits per IP (defaults: 60
858
947
  * req/min), caps body size (default 8 KiB), and forwards parsed reports
@@ -868,7 +957,7 @@ export class App {
868
957
  cspReportRoute(opts = {}) {
869
958
  const path = (opts.path ?? "/__csp-report");
870
959
  const maxBytes = opts.maxBodyBytes ?? 8192;
871
- // Wave 11 — refuse a configured cap above 64 KiB so a misconfigured
960
+ // Refuse a configured cap above 64 KiB so a misconfigured
872
961
  // policy cannot turn the receiver into a DoS-via-report-flood amplifier.
873
962
  const HARD_MAX = 65536;
874
963
  if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
@@ -881,7 +970,7 @@ export class App {
881
970
  ? new Map()
882
971
  : null;
883
972
  const log = this.log;
884
- // Wave 11 — only log report bodies when explicitly enabled. In
973
+ // Only log report bodies when explicitly enabled. In
885
974
  // production this is opt-in; in development the body is included by
886
975
  // default so violations are debuggable.
887
976
  const includeReportBody = opts.logCspReportBodies ?? !this.isProduction();
@@ -993,7 +1082,7 @@ export class App {
993
1082
  child.routes = this.routes;
994
1083
  child.webSocketRoutes = this.webSocketRoutes;
995
1084
  child.routeSecurityMarkers = this.routeSecurityMarkers;
996
- child.wave3BootGuard = this.wave3BootGuard;
1085
+ child.bootGuard = this.bootGuard;
997
1086
  child.log = this.log;
998
1087
  child.prefix = joinPath(this.prefix, prefix);
999
1088
  child.groupHooks = [
@@ -1032,13 +1121,13 @@ export class App {
1032
1121
  * @returns This `App` instance for chaining.
1033
1122
  */
1034
1123
  use(hooks) {
1035
- // Wave 3 boot guards: refuse to start when the new hook layer is a
1124
+ // Boot guards: refuse to start when the new hook layer is a
1036
1125
  // known-misconfigured security primitive in production. These checks
1037
1126
  // run synchronously at registration time so the developer sees the
1038
1127
  // failure during boot, not on first request.
1039
1128
  this.assertSecureHookConfig(hooks);
1040
1129
  // If the developer installs their own secureHeaders(), drop the
1041
- // Wave 2 auto-installed instance so the user's overrides win instead of
1130
+ // auto-installed instance so the user's overrides win instead of
1042
1131
  // being shadowed (the auto one runs first and the per-header
1043
1132
  // "set only if absent" semantics mean the second installation would be
1044
1133
  // a silent no-op).
@@ -1052,7 +1141,7 @@ export class App {
1052
1141
  if (hooks[CORS_HOOK_MARKER] === true) {
1053
1142
  this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
1054
1143
  }
1055
- this.resetWave3BootGuardCache();
1144
+ this.resetBootGuardCache();
1056
1145
  return this;
1057
1146
  }
1058
1147
  /**
@@ -1080,7 +1169,7 @@ export class App {
1080
1169
  * @returns This `App` instance for chaining.
1081
1170
  */
1082
1171
  /**
1083
- * Apply an ordered list of plugin extensions (Wave 6 item 10) to the
1172
+ * Apply an ordered list of plugin extensions to the
1084
1173
  * group-level hook chain. Each extension's `handler` is wrapped into a
1085
1174
  * single-event {@link Hooks} bundle so subsequent route registrations
1086
1175
  * pick it up via the normal hook composition path.
@@ -1095,7 +1184,7 @@ export class App {
1095
1184
  decorate(key, value, opts = {}) {
1096
1185
  if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
1097
1186
  opts.override !== true) {
1098
- // Wave 6 item 9: namespace-protected decorators. Refuse to silently
1187
+ // Namespace-protected decorators. Refuse to silently
1099
1188
  // shadow an existing decoration; emit a once-per-process warn naming
1100
1189
  // both decorators on the explicit-override path.
1101
1190
  throw new Error(`decorate(): key "${key}" is already decorated. ` +
@@ -1145,22 +1234,22 @@ export class App {
1145
1234
  /**
1146
1235
  * Encapsulated plugin registration (Fastify-style). Receives a child App;
1147
1236
  * routes/hooks declared on the child are scoped to the plugin by default
1148
- * (Wave 6 item 15 \u2014 encapsulation default `local`).
1237
+ * (encapsulation default `local`).
1149
1238
  *
1150
- * Wave 6 (`0.24.0`) additions on the plugin descriptor object:
1239
+ * Plugin descriptor object fields (since `0.24.0`):
1151
1240
  *
1152
1241
  * - `dependencies: string[]` \u2014 prerequisite plugin names; the framework
1153
1242
  * refuses-to-boot at registration time when any declared dependency has
1154
- * not been installed first (item 8).
1243
+ * not been installed first.
1155
1244
  * - `seed: string` \u2014 differentiator for parameterized instances of the
1156
- * same plugin (item 16). Dedup key becomes `${name}#${seed}`.
1245
+ * same plugin. Dedup key becomes `${name}#${seed}`.
1157
1246
  * - `stateful: boolean` \u2014 when `true` AND `name` is absent AND the app is
1158
1247
  * in production with `secureDefaults` on, registration refuses-to-boot
1159
1248
  * so silent double-installs of global-state-mutating plugins are caught
1160
- * loud (item 16).
1249
+ * loud.
1161
1250
  * - `extensions: [{ event, handler, before?, after? }]` \u2014 declarative
1162
- * lifecycle-hook ordering with topological-sort + cycle detection
1163
- * (item 10). Refuses-at-registration on cycles.
1251
+ * lifecycle-hook ordering with topological-sort + cycle detection.
1252
+ * Refuses-at-registration on cycles.
1164
1253
  */
1165
1254
  register(plugin, config = {}) {
1166
1255
  const fn = typeof plugin === "function" ? plugin : (plugin.register ?? (() => { }));
@@ -1187,7 +1276,7 @@ export class App {
1187
1276
  this.installedPlugins.add(dedupKey);
1188
1277
  }
1189
1278
  if (descriptor?.extensions && descriptor.extensions.length > 0) {
1190
- // Wave 6 item 10: topological sort with cycle detection.
1279
+ // Topological sort with cycle detection.
1191
1280
  const ordered = topoSortExtensions(descriptor.extensions);
1192
1281
  this.applyExtensions(ordered);
1193
1282
  }
@@ -1292,7 +1381,7 @@ export class App {
1292
1381
  const response = await this.dispatch(request);
1293
1382
  // In-flight responses that finish during draining advertise
1294
1383
  // `Connection: close` so HTTP/1.1 load balancers stop re-using the
1295
- // socket for new requests. Wave 4 connection-draining.
1384
+ // socket for new requests (connection-draining).
1296
1385
  if (this.draining && !response.headers.has("connection")) {
1297
1386
  response.headers.set("connection", "close");
1298
1387
  }
@@ -1323,7 +1412,7 @@ export class App {
1323
1412
  "retry-after": "5",
1324
1413
  // Tell HTTP/1.1 load balancers to close the keep-alive socket
1325
1414
  // immediately so the next request lands on a healthy instance
1326
- // rather than coming back to a dying one. Wave 4.
1415
+ // rather than coming back to a dying one.
1327
1416
  connection: "close",
1328
1417
  },
1329
1418
  });
@@ -1343,8 +1432,9 @@ export class App {
1343
1432
  let activeSendHook = globalHooks.onSend;
1344
1433
  try {
1345
1434
  assertNoDuplicateSingletonHeaders(request.headers);
1435
+ assertNoReservedInternalHeaders(request.headers);
1346
1436
  this.assertTrustProxyConfigured(request);
1347
- this.assertWave3BootGuards();
1437
+ this.assertBootGuards();
1348
1438
  await globalHooks.onRequest?.(request);
1349
1439
  const url = new URL(request.url);
1350
1440
  const method = request.method;
@@ -1463,7 +1553,7 @@ export class App {
1463
1553
  onResponse: activeResponseHook,
1464
1554
  }, stripFingerprint);
1465
1555
  }
1466
- // Wave 4 leftover: when the client has already disconnected, classify
1556
+ // When the client has already disconnected, classify
1467
1557
  // the request at `disconnectStatusCode` (default 499) instead of
1468
1558
  // letting an AbortError bubble up as a generic 5xx. Logged at `info`
1469
1559
  // so disconnect storms do not look like service incidents.
@@ -1598,7 +1688,7 @@ export class App {
1598
1688
  }
1599
1689
  }
1600
1690
  }
1601
- // Wave 4: kill idle keep-alive connections immediately so they cannot
1691
+ // Kill idle keep-alive connections immediately so they cannot
1602
1692
  // be re-used for a new request that would race with the drain. Adapters
1603
1693
  // (Node) register a hook here. In-flight requests are unaffected.
1604
1694
  for (const hook of this.idleConnectionCloseHooks) {
@@ -1681,7 +1771,33 @@ function corsOriginAllowsFromHooks(layers) {
1681
1771
  return allows;
1682
1772
  }
1683
1773
  /**
1684
- * Topological sort of plugin extensions (Wave 6 item 10). Refuses-at-call
1774
+ * Detect header-mutating middleware on a WebSocket route's
1775
+ * effective hook stack. Returns a list of human-readable names for any
1776
+ * middleware that would otherwise lose its headers to the post-upgrade
1777
+ * RFC 6455 frame stream.
1778
+ * @internal
1779
+ */
1780
+ function detectHeaderMutatingMiddleware(layers) {
1781
+ const found = [];
1782
+ for (const hooks of layers) {
1783
+ const record = hooks;
1784
+ // Skip the framework-default auto-secureHeaders bundle — it is
1785
+ // installed by every App and only sets headers that are missing, so it
1786
+ // never conflicts with a WebSocket upgrade in practice.
1787
+ const isAuto = record[AUTO_SECURE_HEADERS_MARKER] === true;
1788
+ if (!isAuto && record[SECURE_HEADERS_MARKER] === true)
1789
+ found.push("secureHeaders()");
1790
+ if (record[CORS_HOOK_MARKER] === true)
1791
+ found.push("cors()");
1792
+ if (record[CSRF_HOOK_MARKER] === true)
1793
+ found.push("csrf()");
1794
+ if (record[COMPRESSION_HOOK_MARKER] === true)
1795
+ found.push("compression()");
1796
+ }
1797
+ return Array.from(new Set(found));
1798
+ }
1799
+ /**
1800
+ * Topological sort of plugin extensions. Refuses-at-call
1685
1801
  * on cyclic ordering with a structured error naming the cycle.
1686
1802
  * @internal
1687
1803
  */
@@ -1734,6 +1850,39 @@ export function topoSortExtensions(exts) {
1734
1850
  const remaining = Array.from(byName.keys()).filter((n) => !out.some((e) => e.name === n));
1735
1851
  throw new Error(`Plugin extension cycle detected among: ${remaining.map((n) => JSON.stringify(n)).join(", ")}.`);
1736
1852
  }
1853
+ // Refuse pairs of extensions that mutate the same response
1854
+ // header without declaring a before/after ordering relative to each
1855
+ // other. The resulting header value would otherwise depend on plugin
1856
+ // registration order.
1857
+ for (let i = 0; i < exts.length; i++) {
1858
+ const a = exts[i];
1859
+ const aHeaders = a.responseHeaders;
1860
+ if (!aHeaders || aHeaders.length === 0)
1861
+ continue;
1862
+ const aSet = new Set(aHeaders.map((h) => h.toLowerCase()));
1863
+ for (let j = i + 1; j < exts.length; j++) {
1864
+ const b = exts[j];
1865
+ const bHeaders = b.responseHeaders;
1866
+ if (!bHeaders || bHeaders.length === 0)
1867
+ continue;
1868
+ const overlap = bHeaders
1869
+ .map((h) => h.toLowerCase())
1870
+ .filter((h) => aSet.has(h));
1871
+ if (overlap.length === 0)
1872
+ continue;
1873
+ const declared = (a.before ?? []).includes(b.name) ||
1874
+ (a.after ?? []).includes(b.name) ||
1875
+ (b.before ?? []).includes(a.name) ||
1876
+ (b.after ?? []).includes(a.name);
1877
+ if (!declared) {
1878
+ throw new Error(`Plugin extension header conflict: ${JSON.stringify(a.name)} and ` +
1879
+ `${JSON.stringify(b.name)} both mutate response header(s) ` +
1880
+ `${overlap.map((h) => JSON.stringify(h)).join(", ")} but neither ` +
1881
+ "declares a `before` or `after` relationship to the other. Add the " +
1882
+ "missing ordering to make the merged header value deterministic.");
1883
+ }
1884
+ }
1885
+ }
1737
1886
  return out;
1738
1887
  }
1739
1888
  function securityMarkersFromHooks(layers) {
@@ -1925,6 +2074,12 @@ function headersToObject(h) {
1925
2074
  function queryToObject(s) {
1926
2075
  const o = {};
1927
2076
  for (const key of new Set(s.keys())) {
2077
+ // Spring4Shell-style defense: never bind attacker-controlled keys named
2078
+ // __proto__ / constructor / prototype as own properties on the parsed
2079
+ // query object — they would survive downstream `{...query}` spreads and
2080
+ // shallow merges. See isForbiddenObjectKey in src/security.ts.
2081
+ if (isForbiddenObjectKey(key))
2082
+ continue;
1928
2083
  const all = s.getAll(key);
1929
2084
  o[key] = all.length > 1 ? all : all[0];
1930
2085
  }
@@ -1947,7 +2102,16 @@ async function readBody(req, ct, limit, multipart) {
1947
2102
  }
1948
2103
  if (ct.includes("application/x-www-form-urlencoded")) {
1949
2104
  const bytes = await readBodyLimited(req, limit);
1950
- return Object.fromEntries(new URLSearchParams(new TextDecoder().decode(bytes)));
2105
+ const params = new URLSearchParams(new TextDecoder().decode(bytes));
2106
+ // Same Spring4Shell-class defense as queryToObject: Object.fromEntries
2107
+ // would set __proto__ / constructor / prototype as own properties.
2108
+ const out = {};
2109
+ for (const [k, v] of params) {
2110
+ if (isForbiddenObjectKey(k))
2111
+ continue;
2112
+ out[k] = v;
2113
+ }
2114
+ return out;
1951
2115
  }
1952
2116
  if (ct.includes("multipart/form-data")) {
1953
2117
  // Multipart: rely on platform parser, but enforce content-length first.
@@ -1972,6 +2136,12 @@ async function readBody(req, ct, limit, multipart) {
1972
2136
  throw new PayloadTooLargeError(multipart.maxFileBytes);
1973
2137
  }
1974
2138
  }
2139
+ // Spring4Shell-class defense (see isForbiddenObjectKey): drop fields
2140
+ // whose name would land on a prototype-pollution sink. They still count
2141
+ // toward the maxFields / maxFiles limits above so an attacker can't use
2142
+ // them to bypass DoS caps.
2143
+ if (isForbiddenObjectKey(k))
2144
+ return;
1975
2145
  out[k] = v;
1976
2146
  });
1977
2147
  if (multipart?.maxFields !== undefined && fields > multipart.maxFields) {