@daloyjs/core 0.32.0 → 0.34.1
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 +37 -2
- package/dist/adapters/bun.d.ts +4 -0
- package/dist/adapters/bun.d.ts.map +1 -1
- package/dist/adapters/bun.js +6 -1
- package/dist/adapters/bun.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +2 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -0
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/deno.d.ts +3 -0
- package/dist/adapters/deno.d.ts.map +1 -1
- package/dist/adapters/deno.js +1 -0
- package/dist/adapters/deno.js.map +1 -1
- package/dist/adapters/fastly.d.ts +2 -0
- package/dist/adapters/fastly.d.ts.map +1 -1
- package/dist/adapters/fastly.js +2 -0
- package/dist/adapters/fastly.js.map +1 -1
- package/dist/adapters/lambda.d.ts +7 -0
- package/dist/adapters/lambda.d.ts.map +1 -1
- package/dist/adapters/lambda.js +1 -0
- package/dist/adapters/lambda.js.map +1 -1
- package/dist/adapters/node.d.ts +3 -0
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +8 -2
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/vercel.d.ts +4 -0
- package/dist/adapters/vercel.d.ts.map +1 -1
- package/dist/adapters/vercel.js +1 -0
- package/dist/adapters/vercel.js.map +1 -1
- package/dist/app.d.ts +37 -37
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +103 -63
- 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 +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +38 -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 +2 -1
- package/dist/dependency.d.ts.map +1 -1
- package/dist/dependency.js +1 -1
- package/dist/dependency.js.map +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 +5 -5
- package/dist/errors.js +7 -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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- 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 +19 -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 +48 -15
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +26 -14
- package/dist/middleware.js.map +1 -1
- package/dist/multipart.d.ts +23 -0
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +70 -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 +7 -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 +2 -1
- package/dist/subdomains.d.ts.map +1 -1
- package/dist/subdomains.js +2 -1
- package/dist/subdomains.js.map +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 +3 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket.d.ts +71 -2
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +82 -0
- package/dist/websocket.js.map +1 -1
- package/package.json +13 -1
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
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
172
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
455
|
-
this.
|
|
456
|
-
this.
|
|
454
|
+
resetBootGuardCache() {
|
|
455
|
+
this.bootGuard.checked = false;
|
|
456
|
+
this.bootGuard.error = undefined;
|
|
457
457
|
}
|
|
458
458
|
/**
|
|
459
|
-
*
|
|
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
|
-
|
|
470
|
-
if (this.
|
|
471
|
-
if (this.
|
|
472
|
-
throw this.
|
|
469
|
+
assertBootGuards() {
|
|
470
|
+
if (this.bootGuard.checked) {
|
|
471
|
+
if (this.bootGuard.error)
|
|
472
|
+
throw this.bootGuard.error;
|
|
473
473
|
return;
|
|
474
474
|
}
|
|
475
|
-
this.
|
|
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.
|
|
492
|
+
this.bootGuard.error = err;
|
|
493
493
|
throw err;
|
|
494
494
|
}
|
|
495
495
|
/**
|
|
496
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
* (
|
|
1237
|
+
* (encapsulation default `local`).
|
|
1220
1238
|
*
|
|
1221
|
-
*
|
|
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
|
|
1243
|
+
* not been installed first.
|
|
1226
1244
|
* - `seed: string` \u2014 differentiator for parameterized instances of the
|
|
1227
|
-
* same plugin
|
|
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
|
|
1249
|
+
* loud.
|
|
1232
1250
|
* - `extensions: [{ event, handler, before?, after? }]` \u2014 declarative
|
|
1233
|
-
* lifecycle-hook ordering with topological-sort + cycle detection
|
|
1234
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|