@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.
- package/README.md +14 -1
- package/bin/daloy.mjs +0 -0
- package/dist/adapters/bun.d.ts.map +1 -1
- package/dist/adapters/bun.js +5 -1
- package/dist/adapters/bun.js.map +1 -1
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +7 -2
- package/dist/adapters/node.js.map +1 -1
- package/dist/app.d.ts +47 -36
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +229 -59
- package/dist/app.js.map +1 -1
- package/dist/banner.d.ts +2 -0
- package/dist/banner.d.ts.map +1 -1
- package/dist/banner.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +29 -31
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +10 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/compression.d.ts +2 -2
- package/dist/compression.js +2 -2
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/conn-info.d.ts +5 -5
- package/dist/conn-info.js +4 -4
- package/dist/contract.d.ts +19 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +10 -0
- package/dist/contract.js.map +1 -1
- package/dist/cookie.d.ts +15 -1
- package/dist/cookie.d.ts.map +1 -1
- package/dist/cookie.js +24 -4
- package/dist/cookie.js.map +1 -1
- package/dist/dependency.d.ts +1 -1
- package/dist/dependency.js +1 -1
- package/dist/discriminator.d.ts +9 -0
- package/dist/discriminator.d.ts.map +1 -1
- package/dist/discriminator.js.map +1 -1
- package/dist/docs.d.ts +49 -2
- package/dist/docs.d.ts.map +1 -1
- package/dist/docs.js +26 -2
- package/dist/docs.js.map +1 -1
- package/dist/errors.d.ts +89 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +148 -7
- package/dist/errors.js.map +1 -1
- package/dist/etag.d.ts +1 -1
- package/dist/etag.js +1 -1
- package/dist/fetch-guard.d.ts +163 -0
- package/dist/fetch-guard.d.ts.map +1 -0
- package/dist/fetch-guard.js +265 -0
- package/dist/fetch-guard.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/ip-restriction.d.ts +17 -0
- package/dist/ip-restriction.d.ts.map +1 -1
- package/dist/ip-restriction.js +6 -3
- package/dist/ip-restriction.js.map +1 -1
- package/dist/jwk.d.ts +1 -1
- package/dist/jwk.d.ts.map +1 -1
- package/dist/jwk.js +3 -3
- package/dist/jwt.d.ts +3 -3
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +19 -7
- package/dist/jwt.js.map +1 -1
- package/dist/load-shedding.d.ts +1 -1
- package/dist/load-shedding.js +1 -1
- package/dist/logger.d.ts +14 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +66 -4
- package/dist/logger.js.map +1 -1
- package/dist/middleware.d.ts +40 -15
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +18 -14
- package/dist/middleware.js.map +1 -1
- package/dist/multipart.d.ts +22 -0
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +69 -0
- package/dist/multipart.js.map +1 -1
- package/dist/openapi.d.ts +13 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +5 -0
- package/dist/openapi.js.map +1 -1
- package/dist/router.d.ts +9 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +6 -0
- package/dist/router.js.map +1 -1
- package/dist/sbom.cdx.json +60 -0
- package/dist/sbom.spdx.json +42 -0
- package/dist/security-schemes.d.ts +29 -0
- package/dist/security-schemes.d.ts.map +1 -1
- package/dist/security-schemes.js +11 -0
- package/dist/security-schemes.js.map +1 -1
- package/dist/security.d.ts +53 -2
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +67 -2
- package/dist/security.js.map +1 -1
- package/dist/session.d.ts +4 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -2
- package/dist/session.js.map +1 -1
- package/dist/streaming.d.ts +8 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js.map +1 -1
- package/dist/subdomains.d.ts +1 -1
- package/dist/subdomains.js +1 -1
- package/dist/time-claims.d.ts +1 -2
- package/dist/time-claims.d.ts.map +1 -1
- package/dist/time-claims.js +1 -2
- package/dist/time-claims.js.map +1 -1
- package/dist/tracing.d.ts +10 -0
- package/dist/tracing.d.ts.map +1 -1
- package/dist/tracing.js +5 -0
- package/dist/tracing.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/websocket.d.ts +87 -0
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +81 -0
- package/dist/websocket.js.map +1 -1
- 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
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
152
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
435
|
-
this.
|
|
436
|
-
this.
|
|
454
|
+
resetBootGuardCache() {
|
|
455
|
+
this.bootGuard.checked = false;
|
|
456
|
+
this.bootGuard.error = undefined;
|
|
437
457
|
}
|
|
438
458
|
/**
|
|
439
|
-
*
|
|
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
|
-
|
|
450
|
-
if (this.
|
|
451
|
-
if (this.
|
|
452
|
-
throw this.
|
|
469
|
+
assertBootGuards() {
|
|
470
|
+
if (this.bootGuard.checked) {
|
|
471
|
+
if (this.bootGuard.error)
|
|
472
|
+
throw this.bootGuard.error;
|
|
453
473
|
return;
|
|
454
474
|
}
|
|
455
|
-
this.
|
|
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.
|
|
492
|
+
this.bootGuard.error = err;
|
|
473
493
|
throw err;
|
|
474
494
|
}
|
|
475
495
|
/**
|
|
476
|
-
*
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
722
|
-
secureDefaults
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
* (
|
|
1237
|
+
* (encapsulation default `local`).
|
|
1149
1238
|
*
|
|
1150
|
-
*
|
|
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
|
|
1243
|
+
* not been installed first.
|
|
1155
1244
|
* - `seed: string` \u2014 differentiator for parameterized instances of the
|
|
1156
|
-
* same plugin
|
|
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
|
|
1249
|
+
* loud.
|
|
1161
1250
|
* - `extensions: [{ event, handler, before?, after? }]` \u2014 declarative
|
|
1162
|
-
* lifecycle-hook ordering with topological-sort + cycle detection
|
|
1163
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
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) {
|