@daloyjs/core 0.32.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 (125) hide show
  1. package/README.md +13 -2
  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 +37 -37
  10. package/dist/app.d.ts.map +1 -1
  11. package/dist/app.js +103 -63
  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 +5 -5
  48. package/dist/errors.js +7 -7
  49. package/dist/errors.js.map +1 -1
  50. package/dist/etag.d.ts +1 -1
  51. package/dist/etag.js +1 -1
  52. package/dist/fetch-guard.d.ts +163 -0
  53. package/dist/fetch-guard.d.ts.map +1 -0
  54. package/dist/fetch-guard.js +265 -0
  55. package/dist/fetch-guard.js.map +1 -0
  56. package/dist/index.d.ts +4 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -2
  59. package/dist/index.js.map +1 -1
  60. package/dist/ip-restriction.d.ts +17 -0
  61. package/dist/ip-restriction.d.ts.map +1 -1
  62. package/dist/ip-restriction.js +6 -3
  63. package/dist/ip-restriction.js.map +1 -1
  64. package/dist/jwk.d.ts +1 -1
  65. package/dist/jwk.d.ts.map +1 -1
  66. package/dist/jwk.js +3 -3
  67. package/dist/jwt.d.ts +3 -3
  68. package/dist/jwt.d.ts.map +1 -1
  69. package/dist/jwt.js +19 -7
  70. package/dist/jwt.js.map +1 -1
  71. package/dist/load-shedding.d.ts +1 -1
  72. package/dist/load-shedding.js +1 -1
  73. package/dist/logger.d.ts +14 -0
  74. package/dist/logger.d.ts.map +1 -1
  75. package/dist/logger.js +66 -4
  76. package/dist/logger.js.map +1 -1
  77. package/dist/middleware.d.ts +40 -15
  78. package/dist/middleware.d.ts.map +1 -1
  79. package/dist/middleware.js +18 -14
  80. package/dist/middleware.js.map +1 -1
  81. package/dist/multipart.d.ts +22 -0
  82. package/dist/multipart.d.ts.map +1 -1
  83. package/dist/multipart.js +69 -0
  84. package/dist/multipart.js.map +1 -1
  85. package/dist/openapi.d.ts +13 -0
  86. package/dist/openapi.d.ts.map +1 -1
  87. package/dist/openapi.js +5 -0
  88. package/dist/openapi.js.map +1 -1
  89. package/dist/router.d.ts +9 -0
  90. package/dist/router.d.ts.map +1 -1
  91. package/dist/router.js +6 -0
  92. package/dist/router.js.map +1 -1
  93. package/dist/sbom.cdx.json +60 -0
  94. package/dist/sbom.spdx.json +42 -0
  95. package/dist/security-schemes.d.ts +29 -0
  96. package/dist/security-schemes.d.ts.map +1 -1
  97. package/dist/security-schemes.js +11 -0
  98. package/dist/security-schemes.js.map +1 -1
  99. package/dist/security.d.ts +53 -2
  100. package/dist/security.d.ts.map +1 -1
  101. package/dist/security.js +67 -2
  102. package/dist/security.js.map +1 -1
  103. package/dist/session.d.ts +4 -2
  104. package/dist/session.d.ts.map +1 -1
  105. package/dist/session.js +2 -2
  106. package/dist/session.js.map +1 -1
  107. package/dist/streaming.d.ts +8 -0
  108. package/dist/streaming.d.ts.map +1 -1
  109. package/dist/streaming.js.map +1 -1
  110. package/dist/subdomains.d.ts +1 -1
  111. package/dist/subdomains.js +1 -1
  112. package/dist/time-claims.d.ts +1 -2
  113. package/dist/time-claims.d.ts.map +1 -1
  114. package/dist/time-claims.js +1 -2
  115. package/dist/time-claims.js.map +1 -1
  116. package/dist/tracing.d.ts +10 -0
  117. package/dist/tracing.d.ts.map +1 -1
  118. package/dist/tracing.js +5 -0
  119. package/dist/tracing.js.map +1 -1
  120. package/dist/types.d.ts +1 -1
  121. package/dist/websocket.d.ts +67 -2
  122. package/dist/websocket.d.ts.map +1 -1
  123. package/dist/websocket.js +81 -0
  124. package/dist/websocket.js.map +1 -1
  125. package/package.json +30 -10
package/dist/app.js CHANGED
@@ -2,7 +2,7 @@ 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";
@@ -30,8 +30,8 @@ export function _resetCrashHandlersForTests() {
30
30
  activeCrashLogger = undefined;
31
31
  }
32
32
  /**
33
- * Wave 8 — once-per-process latch for the `secureDefaults: false` warning
34
- * 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
35
35
  * process share one log entry instead of flooding the access log with
36
36
  * duplicate warnings on every construction.
37
37
  */
@@ -41,9 +41,9 @@ export function _resetInsecureDefaultsLogForTests() {
41
41
  insecureDefaultsLoggedThisProcess = false;
42
42
  }
43
43
  /**
44
- * Wave 8 — list of secure-by-default surfaces disabled when
45
- * `secureDefaults: false` is set. Surfaced through the once-per-process
46
- * `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.
47
47
  */
48
48
  const DISABLED_BY_INSECURE_DEFAULTS = Object.freeze([
49
49
  "secureHeaders auto-install",
@@ -56,7 +56,7 @@ const DISABLED_BY_INSECURE_DEFAULTS = Object.freeze([
56
56
  "anonymous stateful plugin refuse-to-boot",
57
57
  ]);
58
58
  /**
59
- * Wave 12 item 11: the exact RFC 7231 + RFC 5789 HTTP-method allowlist.
59
+ * The exact RFC 7231 + RFC 5789 HTTP-method allowlist.
60
60
  * The framework refuses any other method at route-registration time so
61
61
  * `TRACE`, `CONNECT`, and WebDAV verbs (`MKCOL`, `COPY`, `PROPFIND`,
62
62
  * `MOVE`, etc.) cannot bypass the strict Content-Type / body-on-GET /
@@ -147,7 +147,7 @@ export class App {
147
147
  installedPlugins = new Set();
148
148
  closeHooks = [];
149
149
  closeHooksRun = false;
150
- /** Wave 4 idle-connection close hooks (adapter-registered, sync). */
150
+ /** Idle-connection close hooks (adapter-registered, sync). */
151
151
  idleConnectionCloseHooks = [];
152
152
  pluginInstalledListeners = [];
153
153
  shutdownListeners = [];
@@ -168,13 +168,13 @@ export class App {
168
168
  */
169
169
  corsOriginAllows = [];
170
170
  /**
171
- * Whether the Wave 3 once-only boot guard has run (session + CSRF +
172
- * 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
173
173
  * because route registration and `app.use(csrf(...))` can happen in any
174
174
  * order after construction; doing it on first `fetch()` is the latest
175
175
  * point we still get a 500 before any handler ever runs.
176
176
  */
177
- wave3BootGuard = { checked: false };
177
+ bootGuard = { checked: false };
178
178
  /**
179
179
  * Latched marker stamped after the framework has reported the first
180
180
  * unconfigured-proxy request. Logged once at `warn` level so production
@@ -206,7 +206,7 @@ export class App {
206
206
  this.maybeMountDocs();
207
207
  }
208
208
  /**
209
- * Wave 4 leftover: validate {@link AppOptions.disconnectStatusCode}.
209
+ * Validate {@link AppOptions.disconnectStatusCode}.
210
210
  * Refuses anything outside `[400, 499]` (except `0`, which disables the
211
211
  * rewrite). Throws at construction time so the misconfiguration cannot
212
212
  * survive past boot.
@@ -220,7 +220,7 @@ export class App {
220
220
  }
221
221
  }
222
222
  /**
223
- * Wave 8 — master-flag escape-hatch enforcement. When
223
+ * Master-flag escape-hatch enforcement. When
224
224
  * `secureDefaults: false` is set, the framework:
225
225
  * - refuses-to-construct in production unless
226
226
  * `acknowledgeInsecureDefaults: true` is also set; closes the
@@ -236,7 +236,7 @@ export class App {
236
236
  const inProduction = this.isProduction();
237
237
  if (inProduction && this.options.acknowledgeInsecureDefaults !== true) {
238
238
  throw new Error("app({ secureDefaults: false }) is refused in production. " +
239
- "secureDefaults turns off the entire Wave 1–7 secure-by-default " +
239
+ "secureDefaults turns off the entire secure-by-default " +
240
240
  "surface (auto secureHeaders, cross-origin guard, crash-on-unhandled-rejection, " +
241
241
  "trustProxy guard, csrf/session boot guard, weak-secret refuse-to-boot, " +
242
242
  "cors({ origin: '*' }) refuse-to-boot, anonymous-stateful-plugin refuse-to-boot). " +
@@ -256,7 +256,7 @@ export class App {
256
256
  }
257
257
  }
258
258
  /**
259
- * Install the Wave 2 secure-by-default global hooks. Currently:
259
+ * Install the secure-by-default global hooks. Currently:
260
260
  * - {@link secureHeaders} as a group-level hook so every response carries
261
261
  * the hardened baseline (HSTS, X-Frame-Options, nosniff, default CSP).
262
262
  *
@@ -278,7 +278,7 @@ export class App {
278
278
  auto[AUTO_SECURE_HEADERS_MARKER] = true;
279
279
  this.groupHooks.push(auto);
280
280
  }
281
- // Wave 4 leftover: opt-in load-shedding pressure monitor.
281
+ // Opt-in load-shedding pressure monitor.
282
282
  if (this.options.loadShedding) {
283
283
  const lsOpts = typeof this.options.loadShedding === "object"
284
284
  ? this.options.loadShedding
@@ -287,7 +287,7 @@ export class App {
287
287
  }
288
288
  }
289
289
  /**
290
- * Wave 4 crash-on-unrecoverable-error guard. Installs Node-process-level
290
+ * Crash-on-unrecoverable-error guard. Installs Node-process-level
291
291
  * listeners for `unhandledRejection` and `uncaughtException` that log
292
292
  * through the pluggable logger and call `process.exit(1)`. Idempotent via
293
293
  * a module-level latch so multiple `new App()` instantiations in the same
@@ -363,7 +363,7 @@ export class App {
363
363
  process.env.NODE_ENV === "production");
364
364
  }
365
365
  /**
366
- * Wave 2 cross-origin guard. Rejects state-changing requests (`POST` /
366
+ * Cross-origin guard. Rejects state-changing requests (`POST` /
367
367
  * `PUT` / `PATCH` / `DELETE`) that carry an `Origin` header pointing at a
368
368
  * different origin than the request URL when no {@link cors} hook is
369
369
  * registered (neither at the app level nor on the matched route). Throws
@@ -408,7 +408,7 @@ export class App {
408
408
  `app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
409
409
  }
410
410
  /**
411
- * Wave 3 sync boot guard. Inspects a hook layer being installed via
411
+ * Sync boot guard. Inspects a hook layer being installed via
412
412
  * {@link App.use} and refuses-to-boot when:
413
413
  *
414
414
  * - `cors({ origin: "*" })` is registered while resolved environment is
@@ -451,12 +451,12 @@ export class App {
451
451
  `but security scheme "${auth.scheme}" requires payload authentication. ` +
452
452
  `Remove the route-level opt-out or use a scheme without requirePayloadAuth: true.`);
453
453
  }
454
- resetWave3BootGuardCache() {
455
- this.wave3BootGuard.checked = false;
456
- this.wave3BootGuard.error = undefined;
454
+ resetBootGuardCache() {
455
+ this.bootGuard.checked = false;
456
+ this.bootGuard.error = undefined;
457
457
  }
458
458
  /**
459
- * Wave 3 first-request boot guard. Verifies that the assembled hook
459
+ * First-request boot guard. Verifies that the assembled hook
460
460
  * chain + route table is internally consistent before any user handler
461
461
  * runs. Currently checks: when `session()` is installed and any route
462
462
  * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
@@ -466,13 +466,13 @@ export class App {
466
466
  * `app({ secureDefaults: false })`. Runs once per App between registration
467
467
  * changes; the result is cached so the fast path is a single boolean check.
468
468
  */
469
- assertWave3BootGuards() {
470
- if (this.wave3BootGuard.checked) {
471
- if (this.wave3BootGuard.error)
472
- throw this.wave3BootGuard.error;
469
+ assertBootGuards() {
470
+ if (this.bootGuard.checked) {
471
+ if (this.bootGuard.error)
472
+ throw this.bootGuard.error;
473
473
  return;
474
474
  }
475
- this.wave3BootGuard.checked = true;
475
+ this.bootGuard.checked = true;
476
476
  if (this.options.secureDefaults === false)
477
477
  return;
478
478
  if (this.options.csrf === "off")
@@ -489,11 +489,11 @@ export class App {
489
489
  `(${stateChanging.method} ${stateChanging.path}) but no csrf() hook is installed. ` +
490
490
  `Register csrf() via app.use(csrf({ strategy: "fetch-metadata", allowedOrigins: [...] })), ` +
491
491
  `or pass app({ csrf: "off" }) to acknowledge that this app is not browser-facing.`);
492
- this.wave3BootGuard.error = err;
492
+ this.bootGuard.error = err;
493
493
  throw err;
494
494
  }
495
495
  /**
496
- * 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
497
497
  * constructed without an explicit {@link AppOptions.trustProxy} value
498
498
  * and a request arrives carrying an `X-Forwarded-*` header, refuse to
499
499
  * dispatch it: the rate limiter, audit log, and request-id propagation
@@ -507,7 +507,7 @@ export class App {
507
507
  return;
508
508
  if (this.options.trustProxy !== undefined)
509
509
  return;
510
- // Wave 6: `behindProxy` is the declarative successor — when supplied,
510
+ // `behindProxy` is the declarative successor — when supplied,
511
511
  // the framework already knows how to interpret forwarded headers and
512
512
  // the legacy unconfigured-proxy guard is satisfied.
513
513
  if (this.options.behindProxy !== undefined)
@@ -701,7 +701,7 @@ export class App {
701
701
  * @returns This `App` instance for chaining.
702
702
  */
703
703
  route(def) {
704
- // Wave 12 item 11: refuse non-canonical HTTP methods at runtime.
704
+ // Refuse non-canonical HTTP methods at runtime.
705
705
  // The TypeScript `HttpMethod` union already constrains the public
706
706
  // surface, but an unsafe cast (or a runtime caller in plain JS)
707
707
  // could bypass it. Closes the "framework silently routes WebDAV /
@@ -739,7 +739,7 @@ export class App {
739
739
  path: merged.path,
740
740
  ...securityMarkers,
741
741
  });
742
- this.resetWave3BootGuardCache();
742
+ this.resetBootGuardCache();
743
743
  return this;
744
744
  }
745
745
  /**
@@ -757,7 +757,7 @@ export class App {
757
757
  production,
758
758
  secureDefaults,
759
759
  });
760
- // Wave 11 — pre-upgrade authentication boundary. In production under
760
+ // Pre-upgrade authentication boundary. In production under
761
761
  // secureDefaults, a WebSocket route must either make an explicit
762
762
  // pre-upgrade decision (`beforeUpgrade`) or acknowledge that the route is
763
763
  // intentionally public. This prevents accidental auth in `open()` after
@@ -771,7 +771,25 @@ export class App {
771
771
  "beforeUpgrade hook for authenticated routes, or pass " +
772
772
  "{ acknowledgeUnauthenticated: true } for an intentionally public route.");
773
773
  }
774
- // Wave 11 — WebSocket post-upgrade header immutability. Once the RFC
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
775
793
  // 6455 101 handshake has been sent, no further response headers can be
776
794
  // added by middleware; mounting header-mutating middleware on a path
777
795
  // that also matches a WS route is a documented misconfiguration in
@@ -801,7 +819,7 @@ export class App {
801
819
  * container orchestrator `livenessProbe` configuration — a failing
802
820
  * liveness probe restarts the container.
803
821
  *
804
- * Defaults (Wave 4 secure-by-default):
822
+ * Defaults (secure-by-default):
805
823
  * - path: `/healthz`
806
824
  * - rate-limit: 60 req/min per remote IP, in-memory (per-process)
807
825
  * - auth: opt-in via `token`. In production with `secureDefaults: true`,
@@ -860,7 +878,7 @@ export class App {
860
878
  ? null
861
879
  : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
862
880
  const token = opts.token;
863
- // Wave 4 refuse-to-boot: unauthenticated health/ready probes in
881
+ // Refuse-to-boot: unauthenticated health/ready probes in
864
882
  // production are a documented info-disclosure surface (process uptime,
865
883
  // plugin-ready transitions, internal hostnames in some shops). Force
866
884
  // an explicit acknowledgement.
@@ -923,7 +941,7 @@ export class App {
923
941
  });
924
942
  }
925
943
  /**
926
- * Wave 4 leftover: register a built-in receiver for CSP / Reporting API
944
+ * Register a built-in receiver for CSP / Reporting API
927
945
  * violation reports. Accepts `application/csp-report` and
928
946
  * `application/reports+json` payloads, rate-limits per IP (defaults: 60
929
947
  * req/min), caps body size (default 8 KiB), and forwards parsed reports
@@ -939,7 +957,7 @@ export class App {
939
957
  cspReportRoute(opts = {}) {
940
958
  const path = (opts.path ?? "/__csp-report");
941
959
  const maxBytes = opts.maxBodyBytes ?? 8192;
942
- // Wave 11 — refuse a configured cap above 64 KiB so a misconfigured
960
+ // Refuse a configured cap above 64 KiB so a misconfigured
943
961
  // policy cannot turn the receiver into a DoS-via-report-flood amplifier.
944
962
  const HARD_MAX = 65536;
945
963
  if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
@@ -952,7 +970,7 @@ export class App {
952
970
  ? new Map()
953
971
  : null;
954
972
  const log = this.log;
955
- // Wave 11 — only log report bodies when explicitly enabled. In
973
+ // Only log report bodies when explicitly enabled. In
956
974
  // production this is opt-in; in development the body is included by
957
975
  // default so violations are debuggable.
958
976
  const includeReportBody = opts.logCspReportBodies ?? !this.isProduction();
@@ -1064,7 +1082,7 @@ export class App {
1064
1082
  child.routes = this.routes;
1065
1083
  child.webSocketRoutes = this.webSocketRoutes;
1066
1084
  child.routeSecurityMarkers = this.routeSecurityMarkers;
1067
- child.wave3BootGuard = this.wave3BootGuard;
1085
+ child.bootGuard = this.bootGuard;
1068
1086
  child.log = this.log;
1069
1087
  child.prefix = joinPath(this.prefix, prefix);
1070
1088
  child.groupHooks = [
@@ -1103,13 +1121,13 @@ export class App {
1103
1121
  * @returns This `App` instance for chaining.
1104
1122
  */
1105
1123
  use(hooks) {
1106
- // 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
1107
1125
  // known-misconfigured security primitive in production. These checks
1108
1126
  // run synchronously at registration time so the developer sees the
1109
1127
  // failure during boot, not on first request.
1110
1128
  this.assertSecureHookConfig(hooks);
1111
1129
  // If the developer installs their own secureHeaders(), drop the
1112
- // 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
1113
1131
  // being shadowed (the auto one runs first and the per-header
1114
1132
  // "set only if absent" semantics mean the second installation would be
1115
1133
  // a silent no-op).
@@ -1123,7 +1141,7 @@ export class App {
1123
1141
  if (hooks[CORS_HOOK_MARKER] === true) {
1124
1142
  this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
1125
1143
  }
1126
- this.resetWave3BootGuardCache();
1144
+ this.resetBootGuardCache();
1127
1145
  return this;
1128
1146
  }
1129
1147
  /**
@@ -1151,7 +1169,7 @@ export class App {
1151
1169
  * @returns This `App` instance for chaining.
1152
1170
  */
1153
1171
  /**
1154
- * Apply an ordered list of plugin extensions (Wave 6 item 10) to the
1172
+ * Apply an ordered list of plugin extensions to the
1155
1173
  * group-level hook chain. Each extension's `handler` is wrapped into a
1156
1174
  * single-event {@link Hooks} bundle so subsequent route registrations
1157
1175
  * pick it up via the normal hook composition path.
@@ -1166,7 +1184,7 @@ export class App {
1166
1184
  decorate(key, value, opts = {}) {
1167
1185
  if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
1168
1186
  opts.override !== true) {
1169
- // Wave 6 item 9: namespace-protected decorators. Refuse to silently
1187
+ // Namespace-protected decorators. Refuse to silently
1170
1188
  // shadow an existing decoration; emit a once-per-process warn naming
1171
1189
  // both decorators on the explicit-override path.
1172
1190
  throw new Error(`decorate(): key "${key}" is already decorated. ` +
@@ -1216,22 +1234,22 @@ export class App {
1216
1234
  /**
1217
1235
  * Encapsulated plugin registration (Fastify-style). Receives a child App;
1218
1236
  * routes/hooks declared on the child are scoped to the plugin by default
1219
- * (Wave 6 item 15 \u2014 encapsulation default `local`).
1237
+ * (encapsulation default `local`).
1220
1238
  *
1221
- * Wave 6 (`0.24.0`) additions on the plugin descriptor object:
1239
+ * Plugin descriptor object fields (since `0.24.0`):
1222
1240
  *
1223
1241
  * - `dependencies: string[]` \u2014 prerequisite plugin names; the framework
1224
1242
  * refuses-to-boot at registration time when any declared dependency has
1225
- * not been installed first (item 8).
1243
+ * not been installed first.
1226
1244
  * - `seed: string` \u2014 differentiator for parameterized instances of the
1227
- * same plugin (item 16). Dedup key becomes `${name}#${seed}`.
1245
+ * same plugin. Dedup key becomes `${name}#${seed}`.
1228
1246
  * - `stateful: boolean` \u2014 when `true` AND `name` is absent AND the app is
1229
1247
  * in production with `secureDefaults` on, registration refuses-to-boot
1230
1248
  * so silent double-installs of global-state-mutating plugins are caught
1231
- * loud (item 16).
1249
+ * loud.
1232
1250
  * - `extensions: [{ event, handler, before?, after? }]` \u2014 declarative
1233
- * lifecycle-hook ordering with topological-sort + cycle detection
1234
- * (item 10). Refuses-at-registration on cycles.
1251
+ * lifecycle-hook ordering with topological-sort + cycle detection.
1252
+ * Refuses-at-registration on cycles.
1235
1253
  */
1236
1254
  register(plugin, config = {}) {
1237
1255
  const fn = typeof plugin === "function" ? plugin : (plugin.register ?? (() => { }));
@@ -1258,7 +1276,7 @@ export class App {
1258
1276
  this.installedPlugins.add(dedupKey);
1259
1277
  }
1260
1278
  if (descriptor?.extensions && descriptor.extensions.length > 0) {
1261
- // Wave 6 item 10: topological sort with cycle detection.
1279
+ // Topological sort with cycle detection.
1262
1280
  const ordered = topoSortExtensions(descriptor.extensions);
1263
1281
  this.applyExtensions(ordered);
1264
1282
  }
@@ -1363,7 +1381,7 @@ export class App {
1363
1381
  const response = await this.dispatch(request);
1364
1382
  // In-flight responses that finish during draining advertise
1365
1383
  // `Connection: close` so HTTP/1.1 load balancers stop re-using the
1366
- // socket for new requests. Wave 4 connection-draining.
1384
+ // socket for new requests (connection-draining).
1367
1385
  if (this.draining && !response.headers.has("connection")) {
1368
1386
  response.headers.set("connection", "close");
1369
1387
  }
@@ -1394,7 +1412,7 @@ export class App {
1394
1412
  "retry-after": "5",
1395
1413
  // Tell HTTP/1.1 load balancers to close the keep-alive socket
1396
1414
  // immediately so the next request lands on a healthy instance
1397
- // rather than coming back to a dying one. Wave 4.
1415
+ // rather than coming back to a dying one.
1398
1416
  connection: "close",
1399
1417
  },
1400
1418
  });
@@ -1414,8 +1432,9 @@ export class App {
1414
1432
  let activeSendHook = globalHooks.onSend;
1415
1433
  try {
1416
1434
  assertNoDuplicateSingletonHeaders(request.headers);
1435
+ assertNoReservedInternalHeaders(request.headers);
1417
1436
  this.assertTrustProxyConfigured(request);
1418
- this.assertWave3BootGuards();
1437
+ this.assertBootGuards();
1419
1438
  await globalHooks.onRequest?.(request);
1420
1439
  const url = new URL(request.url);
1421
1440
  const method = request.method;
@@ -1534,7 +1553,7 @@ export class App {
1534
1553
  onResponse: activeResponseHook,
1535
1554
  }, stripFingerprint);
1536
1555
  }
1537
- // Wave 4 leftover: when the client has already disconnected, classify
1556
+ // When the client has already disconnected, classify
1538
1557
  // the request at `disconnectStatusCode` (default 499) instead of
1539
1558
  // letting an AbortError bubble up as a generic 5xx. Logged at `info`
1540
1559
  // so disconnect storms do not look like service incidents.
@@ -1669,7 +1688,7 @@ export class App {
1669
1688
  }
1670
1689
  }
1671
1690
  }
1672
- // Wave 4: kill idle keep-alive connections immediately so they cannot
1691
+ // Kill idle keep-alive connections immediately so they cannot
1673
1692
  // be re-used for a new request that would race with the drain. Adapters
1674
1693
  // (Node) register a hook here. In-flight requests are unaffected.
1675
1694
  for (const hook of this.idleConnectionCloseHooks) {
@@ -1752,7 +1771,7 @@ function corsOriginAllowsFromHooks(layers) {
1752
1771
  return allows;
1753
1772
  }
1754
1773
  /**
1755
- * Wave 11 — detect header-mutating middleware on a WebSocket route's
1774
+ * Detect header-mutating middleware on a WebSocket route's
1756
1775
  * effective hook stack. Returns a list of human-readable names for any
1757
1776
  * middleware that would otherwise lose its headers to the post-upgrade
1758
1777
  * RFC 6455 frame stream.
@@ -1778,7 +1797,7 @@ function detectHeaderMutatingMiddleware(layers) {
1778
1797
  return Array.from(new Set(found));
1779
1798
  }
1780
1799
  /**
1781
- * Topological sort of plugin extensions (Wave 6 item 10). Refuses-at-call
1800
+ * Topological sort of plugin extensions. Refuses-at-call
1782
1801
  * on cyclic ordering with a structured error naming the cycle.
1783
1802
  * @internal
1784
1803
  */
@@ -1831,7 +1850,7 @@ export function topoSortExtensions(exts) {
1831
1850
  const remaining = Array.from(byName.keys()).filter((n) => !out.some((e) => e.name === n));
1832
1851
  throw new Error(`Plugin extension cycle detected among: ${remaining.map((n) => JSON.stringify(n)).join(", ")}.`);
1833
1852
  }
1834
- // Wave 11 — refuse pairs of extensions that mutate the same response
1853
+ // Refuse pairs of extensions that mutate the same response
1835
1854
  // header without declaring a before/after ordering relative to each
1836
1855
  // other. The resulting header value would otherwise depend on plugin
1837
1856
  // registration order.
@@ -2055,6 +2074,12 @@ function headersToObject(h) {
2055
2074
  function queryToObject(s) {
2056
2075
  const o = {};
2057
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;
2058
2083
  const all = s.getAll(key);
2059
2084
  o[key] = all.length > 1 ? all : all[0];
2060
2085
  }
@@ -2077,7 +2102,16 @@ async function readBody(req, ct, limit, multipart) {
2077
2102
  }
2078
2103
  if (ct.includes("application/x-www-form-urlencoded")) {
2079
2104
  const bytes = await readBodyLimited(req, limit);
2080
- 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;
2081
2115
  }
2082
2116
  if (ct.includes("multipart/form-data")) {
2083
2117
  // Multipart: rely on platform parser, but enforce content-length first.
@@ -2102,6 +2136,12 @@ async function readBody(req, ct, limit, multipart) {
2102
2136
  throw new PayloadTooLargeError(multipart.maxFileBytes);
2103
2137
  }
2104
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;
2105
2145
  out[k] = v;
2106
2146
  });
2107
2147
  if (multipart?.maxFields !== undefined && fields > multipart.maxFields) {