@daloyjs/core 0.15.0 → 0.17.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 +1 -1
- package/dist/app.d.ts +163 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +302 -5
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +60 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +74 -3
- package/dist/middleware.js.map +1 -1
- package/dist/security.d.ts +32 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +79 -0
- package/dist/security.js.map +1 -1
- package/dist/session.d.ts +20 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +24 -1
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -380,7 +380,7 @@ What works today, at a glance:
|
|
|
380
380
|
|
|
381
381
|
- Contract-first routing, Standard Schema validation (Zod 4 / Valibot / ArkType / TypeBox), and OpenAPI 3.1 from a single source of truth.
|
|
382
382
|
- Adapters for Node (Heroku/Railway/Render/Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs.
|
|
383
|
-
- Built-in security primitives (body limits, prototype-pollution-safe JSON, path-traversal guard, request timeouts, header injection guards, **duplicate `Host` / `Content-Length` rejection**, **stripped `Server` / `X-Powered-By` headers by default**, **structured-log redaction defaults** for authorization / cookie / password / token / JWT-shaped values) plus first-party middleware (`secureHeaders` with CSP nonce + Trusted Types, `cors`, `rateLimit`, `requestId`, `bearerAuth`, `basicAuth`, `csrf` with **double-submit cookie** + **Fetch-Metadata** strategies, `session`, `timing` / `timingSafeEqual`) and **zero-knob crypto helpers** (`passwordHash` / `passwordVerify` at `@daloyjs/core/hashing`, `verifyWebhookSignature` / `signWebhookPayload`).
|
|
383
|
+
- Built-in security primitives (body limits, prototype-pollution-safe JSON, path-traversal guard, request timeouts, header injection guards, **duplicate `Host` / `Content-Length` rejection**, **stripped `Server` / `X-Powered-By` headers by default**, **structured-log redaction defaults** for authorization / cookie / password / token / JWT-shaped values, **`secureHeaders()` auto-applied since `0.16.0`** with user-installed overrides automatically replacing the auto instance, **cross-origin state-changing requests rejected with `403` since `0.16.0`** unless a route's `cors()` policy allows the request origin, **refuse-to-boot on weak session secrets, `cors({ origin: "*" })`, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded-*` in production since `0.17.0`** — all four checks opt-out via `app({ secureDefaults: false })`) plus first-party middleware (`secureHeaders` with CSP nonce + Trusted Types, `cors`, `rateLimit`, `requestId`, `bearerAuth`, `basicAuth`, `csrf` with **double-submit cookie** + **Fetch-Metadata** strategies, `session`, `timing` / `timingSafeEqual`) and **zero-knob crypto helpers** (`passwordHash` / `passwordVerify` at `@daloyjs/core/hashing`, `verifyWebhookSignature` / `signWebhookPayload`).
|
|
384
384
|
- Streaming helpers (SSE + NDJSON), multipart ergonomics, OpenTelemetry-compatible tracing, signed-cookie sessions with pluggable stores, and a Redis-backed rate-limit store at `@daloyjs/core/rate-limit-redis`.
|
|
385
385
|
- WebSocket primitives with the same Bun-style handler shape (`open`/`message`/`close`/`drain`/`error`) running on both Node and Bun adapters, plus typed `app.ws(path, handler)` registration and route-table awareness so the upgrade listener is only installed when WS routes exist.
|
|
386
386
|
- Pretty `printStartupBanner()` / `formatStartupBanner()` startup helpers at `@daloyjs/core/banner`, used by every starter template so `pnpm dev` greets you with a colorized boxed panel (TTY + `NO_COLOR` / `FORCE_COLOR` aware, with an ASCII fallback for dumb terminals).
|
package/dist/app.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type Logger } from "./logger.js";
|
|
|
3
3
|
import type { HttpMethod, Hooks, PathString, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
|
|
4
4
|
import { type OpenAPIInfo, type OpenAPIOptions } from "./openapi.js";
|
|
5
5
|
import { type DocsContentSecurityPolicyOptions, type ScalarReferenceConfiguration } from "./docs.js";
|
|
6
|
+
import { type SecureHeadersOptions } from "./middleware.js";
|
|
6
7
|
/**
|
|
7
8
|
* Configuration accepted by {@link App}'s constructor. Every field is
|
|
8
9
|
* optional; sensible production defaults are applied.
|
|
@@ -58,6 +59,79 @@ export interface AppOptions {
|
|
|
58
59
|
* @since 0.15.0
|
|
59
60
|
*/
|
|
60
61
|
stripServerHeaders?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Master switch for the secure-by-default Wave 2 surface (auto-applied
|
|
64
|
+
* {@link secureHeaders}, cross-origin guard for state-changing requests
|
|
65
|
+
* when no {@link cors} hook allows the request origin). Defaults to `true`
|
|
66
|
+
* in `@daloyjs/core@0.16.0` and later. Per-feature opt-outs
|
|
67
|
+
* (`secureHeaders: false`, `corsCrossOriginGuard: false`) remain available
|
|
68
|
+
* when this is left on. Pass `false` to restore the pre-0.16 behavior
|
|
69
|
+
* wholesale.
|
|
70
|
+
*
|
|
71
|
+
* @since 0.16.0
|
|
72
|
+
*/
|
|
73
|
+
secureDefaults?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Auto-install {@link secureHeaders} as a global hook so every response
|
|
76
|
+
* carries hardened defaults (HSTS, X-Frame-Options, nosniff, strict
|
|
77
|
+
* referrer, COOP/CORP, default CSP, etc.). Active when
|
|
78
|
+
* {@link AppOptions.secureDefaults} is not `false`. Pass `false` to skip
|
|
79
|
+
* the auto-install (you may still register your own `secureHeaders()` via
|
|
80
|
+
* `app.use(...)`). Pass an options object to override the defaults of the
|
|
81
|
+
* auto-installed instance.
|
|
82
|
+
*
|
|
83
|
+
* @since 0.16.0
|
|
84
|
+
*/
|
|
85
|
+
secureHeaders?: SecureHeadersOptions | false;
|
|
86
|
+
/**
|
|
87
|
+
* Reject state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) that
|
|
88
|
+
* carry a cross-origin `Origin` header when no {@link cors} hook in the
|
|
89
|
+
* matched route's hook chain allows that origin. Active when
|
|
90
|
+
* {@link AppOptions.secureDefaults} is not `false`. Returns
|
|
91
|
+
* `403 application/problem+json` so the rejection is loud at the network
|
|
92
|
+
* boundary instead of silently allowing a CSRF / SSRF surface. Set to
|
|
93
|
+
* `false` if you intentionally serve a cross-origin API without `cors()`
|
|
94
|
+
* (rare; almost always a misconfiguration).
|
|
95
|
+
*
|
|
96
|
+
* @since 0.16.0
|
|
97
|
+
*/
|
|
98
|
+
corsCrossOriginGuard?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Declare whether the application sits behind a trusted reverse proxy
|
|
101
|
+
* that populates `X-Forwarded-*` headers (load balancer, CDN, Vercel,
|
|
102
|
+
* Cloudflare, AWS ALB, etc.). The value is opt-in tri-state for the
|
|
103
|
+
* Wave 3 first-request guard:
|
|
104
|
+
*
|
|
105
|
+
* - `undefined` (default) — *unconfigured*. The framework returns
|
|
106
|
+
* `500 problem+json` on the first request that carries an
|
|
107
|
+
* `X-Forwarded-*` header so a misconfigured proxy chain cannot silently
|
|
108
|
+
* leak spoofed client IPs to the rate limiter, audit logs, or
|
|
109
|
+
* request-id propagation. Disabled when {@link AppOptions.secureDefaults}
|
|
110
|
+
* is `false`.
|
|
111
|
+
* - `true` — *trust*. The framework reads `X-Forwarded-*` without
|
|
112
|
+
* suspicion. Use only when every request passes through a proxy chain
|
|
113
|
+
* you control.
|
|
114
|
+
* - `false` — *explicitly do not trust*. The framework ignores
|
|
115
|
+
* `X-Forwarded-*` even when present and silences the unconfigured-proxy
|
|
116
|
+
* guard. Use when the application is exposed directly to the public
|
|
117
|
+
* internet on purpose.
|
|
118
|
+
*
|
|
119
|
+
* @since 0.17.0
|
|
120
|
+
*/
|
|
121
|
+
trustProxy?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Opt-out for the Wave 3 boot guard that refuses to start an App which
|
|
124
|
+
* registers {@link session} and any state-changing route without a
|
|
125
|
+
* matching {@link csrf} hook. Set to `"off"` to acknowledge that you
|
|
126
|
+
* intentionally accept cookie-authenticated POST / PUT / PATCH / DELETE
|
|
127
|
+
* requests without CSRF protection (SPA + bearer-token apps, internal
|
|
128
|
+
* services on a private network, etc.). Leave unset (the default) for
|
|
129
|
+
* any browser-facing API: the boot error includes one-line copy-paste
|
|
130
|
+
* remediation. Disabled when {@link AppOptions.secureDefaults} is `false`.
|
|
131
|
+
*
|
|
132
|
+
* @since 0.17.0
|
|
133
|
+
*/
|
|
134
|
+
csrf?: "off";
|
|
61
135
|
/** Pluggable logger. Default: structured JSON logger at "info" (or noop in test). */
|
|
62
136
|
logger?: Logger | {
|
|
63
137
|
level?: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
@@ -267,6 +341,8 @@ export declare class App {
|
|
|
267
341
|
private groupHooks;
|
|
268
342
|
private groupTags;
|
|
269
343
|
private groupAuth?;
|
|
344
|
+
/** Effective security markers for each registered route hook chain. */
|
|
345
|
+
private routeSecurityMarkers;
|
|
270
346
|
/** Decorator bag merged into ctx.state on every request. */
|
|
271
347
|
private decorations;
|
|
272
348
|
private installedPlugins;
|
|
@@ -279,7 +355,40 @@ export declare class App {
|
|
|
279
355
|
/** In-flight request count for graceful shutdown. */
|
|
280
356
|
private inflight;
|
|
281
357
|
private draining;
|
|
358
|
+
/**
|
|
359
|
+
* CORS origin allowlist predicates from the currently active group-level
|
|
360
|
+
* hooks. Used for unmatched routes; matched routes use the snapshot stored
|
|
361
|
+
* on their compiled route so later `app.use(cors(...))` calls do not
|
|
362
|
+
* retroactively loosen earlier routes.
|
|
363
|
+
*/
|
|
364
|
+
private corsOriginAllows;
|
|
365
|
+
/**
|
|
366
|
+
* Whether the Wave 3 once-only boot guard has run (session + CSRF +
|
|
367
|
+
* state-changing-route check). The check is deferred to first request
|
|
368
|
+
* because route registration and `app.use(csrf(...))` can happen in any
|
|
369
|
+
* order after construction; doing it on first `fetch()` is the latest
|
|
370
|
+
* point we still get a 500 before any handler ever runs.
|
|
371
|
+
*/
|
|
372
|
+
private wave3BootGuard;
|
|
373
|
+
/**
|
|
374
|
+
* Latched marker stamped after the framework has reported the first
|
|
375
|
+
* unconfigured-proxy request. Logged once at `warn` level so production
|
|
376
|
+
* dashboards see the misconfiguration without flooding on every retry.
|
|
377
|
+
*/
|
|
378
|
+
private trustProxyWarned;
|
|
282
379
|
constructor(options?: AppOptions);
|
|
380
|
+
/**
|
|
381
|
+
* Install the Wave 2 secure-by-default global hooks. Currently:
|
|
382
|
+
* - {@link secureHeaders} as a group-level hook so every response carries
|
|
383
|
+
* the hardened baseline (HSTS, X-Frame-Options, nosniff, default CSP).
|
|
384
|
+
*
|
|
385
|
+
* Called once during construction — the CORS cross-origin guard lives
|
|
386
|
+
* inside {@link App.fetch} because it needs the live request to decide.
|
|
387
|
+
* The auto-installed `secureHeaders` instance only sets headers when the
|
|
388
|
+
* response does not already carry them, so user-supplied
|
|
389
|
+
* `app.use(secureHeaders({...}))` still wins per-header.
|
|
390
|
+
*/
|
|
391
|
+
private installSecureDefaults;
|
|
283
392
|
/**
|
|
284
393
|
* Emit a one-time `warn` when the explicit {@link AppOptions.env} option
|
|
285
394
|
* disagrees with `process.env.NODE_ENV`. Silent when either signal is
|
|
@@ -293,6 +402,60 @@ export declare class App {
|
|
|
293
402
|
* auto-mount and error response detail stripping.
|
|
294
403
|
*/
|
|
295
404
|
private isProduction;
|
|
405
|
+
/**
|
|
406
|
+
* Wave 2 cross-origin guard. Rejects state-changing requests (`POST` /
|
|
407
|
+
* `PUT` / `PATCH` / `DELETE`) that carry an `Origin` header pointing at a
|
|
408
|
+
* different origin than the request URL when no {@link cors} hook is
|
|
409
|
+
* registered (neither at the app level nor on the matched route). Throws
|
|
410
|
+
* a {@link ForbiddenError} that surfaces as `403 application/problem+json`
|
|
411
|
+
* so the rejection is loud rather than silently allowing the unintended
|
|
412
|
+
* cross-origin write.
|
|
413
|
+
*
|
|
414
|
+
* Disabled when {@link AppOptions.secureDefaults} is `false` or
|
|
415
|
+
* {@link AppOptions.corsCrossOriginGuard} is `false`. Same-origin
|
|
416
|
+
* requests, GET / HEAD / OPTIONS, requests with no `Origin` header, and
|
|
417
|
+
* routes whose hook chain includes a `cors()` policy allowing the origin
|
|
418
|
+
* are unaffected.
|
|
419
|
+
*/
|
|
420
|
+
private assertCrossOriginAllowed;
|
|
421
|
+
/**
|
|
422
|
+
* Wave 3 sync boot guard. Inspects a hook layer being installed via
|
|
423
|
+
* {@link App.use} and refuses-to-boot when:
|
|
424
|
+
*
|
|
425
|
+
* - `cors({ origin: "*" })` is registered while resolved environment is
|
|
426
|
+
* `production`;
|
|
427
|
+
* - `session({ secret })` is registered while resolved environment is
|
|
428
|
+
* `production` and any secret fails {@link assertStrongSecret}.
|
|
429
|
+
*
|
|
430
|
+
* Disabled when `secureDefaults: false`. Thrown errors propagate out of
|
|
431
|
+
* `app.use(...)` so the process exits during startup rather than serving
|
|
432
|
+
* a misconfigured surface.
|
|
433
|
+
*/
|
|
434
|
+
private assertSecureHookConfig;
|
|
435
|
+
private resetWave3BootGuardCache;
|
|
436
|
+
/**
|
|
437
|
+
* Wave 3 first-request boot guard. Verifies that the assembled hook
|
|
438
|
+
* chain + route table is internally consistent before any user handler
|
|
439
|
+
* runs. Currently checks: when `session()` is installed and any route
|
|
440
|
+
* accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
|
|
441
|
+
* a `csrf()` hook (or third-party equivalent stamped with
|
|
442
|
+
* {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
|
|
443
|
+
* hook chain. Opt out with `app({ csrf: "off" })` or
|
|
444
|
+
* `app({ secureDefaults: false })`. Runs once per App between registration
|
|
445
|
+
* changes; the result is cached so the fast path is a single boolean check.
|
|
446
|
+
*/
|
|
447
|
+
private assertWave3BootGuards;
|
|
448
|
+
/**
|
|
449
|
+
* Wave 3 per-request guard for spoofed proxy headers. When the App was
|
|
450
|
+
* constructed without an explicit {@link AppOptions.trustProxy} value
|
|
451
|
+
* and a request arrives carrying an `X-Forwarded-*` header, refuse to
|
|
452
|
+
* dispatch it: the rate limiter, audit log, and request-id propagation
|
|
453
|
+
* would otherwise honour the attacker-supplied IP. Returns a structured
|
|
454
|
+
* `500 problem+json` so the failure is loud at the network boundary.
|
|
455
|
+
* Disabled when `secureDefaults: false` or when `trustProxy` is set to
|
|
456
|
+
* `true` or `false` explicitly.
|
|
457
|
+
*/
|
|
458
|
+
private assertTrustProxyConfigured;
|
|
296
459
|
/**
|
|
297
460
|
* Resolve the {@link AppOptions.docs} option and, when enabled, register
|
|
298
461
|
* the `/openapi.json` + `/docs` routes. Called once during construction so
|
package/dist/app.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
package/dist/app.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Router } from "./router.js";
|
|
2
2
|
import { WebSocketRegistry } from "./websocket.js";
|
|
3
|
-
import { BadRequestError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
3
|
+
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
4
4
|
import { validate } from "./schema.js";
|
|
5
|
-
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders } from "./security.js";
|
|
5
|
+
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertStrongSecret } 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
|
+
import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
|
|
10
|
+
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
|
|
11
|
+
const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
|
|
9
12
|
const DEFAULTS = {
|
|
10
13
|
bodyLimitBytes: 1024 * 1024,
|
|
11
14
|
requestTimeoutMs: 30_000,
|
|
@@ -72,6 +75,8 @@ export class App {
|
|
|
72
75
|
groupHooks = [];
|
|
73
76
|
groupTags = [];
|
|
74
77
|
groupAuth;
|
|
78
|
+
/** Effective security markers for each registered route hook chain. */
|
|
79
|
+
routeSecurityMarkers = [];
|
|
75
80
|
/** Decorator bag merged into ctx.state on every request. */
|
|
76
81
|
decorations = {};
|
|
77
82
|
installedPlugins = new Set();
|
|
@@ -84,6 +89,27 @@ export class App {
|
|
|
84
89
|
/** In-flight request count for graceful shutdown. */
|
|
85
90
|
inflight = 0;
|
|
86
91
|
draining = false;
|
|
92
|
+
/**
|
|
93
|
+
* CORS origin allowlist predicates from the currently active group-level
|
|
94
|
+
* hooks. Used for unmatched routes; matched routes use the snapshot stored
|
|
95
|
+
* on their compiled route so later `app.use(cors(...))` calls do not
|
|
96
|
+
* retroactively loosen earlier routes.
|
|
97
|
+
*/
|
|
98
|
+
corsOriginAllows = [];
|
|
99
|
+
/**
|
|
100
|
+
* Whether the Wave 3 once-only boot guard has run (session + CSRF +
|
|
101
|
+
* state-changing-route check). The check is deferred to first request
|
|
102
|
+
* because route registration and `app.use(csrf(...))` can happen in any
|
|
103
|
+
* order after construction; doing it on first `fetch()` is the latest
|
|
104
|
+
* point we still get a 500 before any handler ever runs.
|
|
105
|
+
*/
|
|
106
|
+
wave3BootGuard = { checked: false };
|
|
107
|
+
/**
|
|
108
|
+
* Latched marker stamped after the framework has reported the first
|
|
109
|
+
* unconfigured-proxy request. Logged once at `warn` level so production
|
|
110
|
+
* dashboards see the misconfiguration without flooding on every retry.
|
|
111
|
+
*/
|
|
112
|
+
trustProxyWarned = false;
|
|
87
113
|
constructor(options = {}) {
|
|
88
114
|
this.options = {
|
|
89
115
|
validateResponses: options.validateResponses ?? DEFAULTS.validateResponses,
|
|
@@ -99,8 +125,35 @@ export class App {
|
|
|
99
125
|
? options.logger
|
|
100
126
|
: createLogger({ level: options.logger?.level ?? "info" });
|
|
101
127
|
this.warnOnEnvMismatch();
|
|
128
|
+
if (this.options.hooks)
|
|
129
|
+
this.assertSecureHookConfig(this.options.hooks);
|
|
130
|
+
this.installSecureDefaults();
|
|
102
131
|
this.maybeMountDocs();
|
|
103
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Install the Wave 2 secure-by-default global hooks. Currently:
|
|
135
|
+
* - {@link secureHeaders} as a group-level hook so every response carries
|
|
136
|
+
* the hardened baseline (HSTS, X-Frame-Options, nosniff, default CSP).
|
|
137
|
+
*
|
|
138
|
+
* Called once during construction — the CORS cross-origin guard lives
|
|
139
|
+
* inside {@link App.fetch} because it needs the live request to decide.
|
|
140
|
+
* The auto-installed `secureHeaders` instance only sets headers when the
|
|
141
|
+
* response does not already carry them, so user-supplied
|
|
142
|
+
* `app.use(secureHeaders({...}))` still wins per-header.
|
|
143
|
+
*/
|
|
144
|
+
installSecureDefaults() {
|
|
145
|
+
if (this.options.secureDefaults === false)
|
|
146
|
+
return;
|
|
147
|
+
if (this.options.secureHeaders !== false) {
|
|
148
|
+
const opts = this.options.secureHeaders &&
|
|
149
|
+
typeof this.options.secureHeaders === "object"
|
|
150
|
+
? this.options.secureHeaders
|
|
151
|
+
: {};
|
|
152
|
+
const auto = secureHeadersMiddleware(opts);
|
|
153
|
+
auto[AUTO_SECURE_HEADERS_MARKER] = true;
|
|
154
|
+
this.groupHooks.push(auto);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
104
157
|
/**
|
|
105
158
|
* Emit a one-time `warn` when the explicit {@link AppOptions.env} option
|
|
106
159
|
* disagrees with `process.env.NODE_ENV`. Silent when either signal is
|
|
@@ -132,6 +185,170 @@ export class App {
|
|
|
132
185
|
typeof process.env !== "undefined" &&
|
|
133
186
|
process.env.NODE_ENV === "production");
|
|
134
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Wave 2 cross-origin guard. Rejects state-changing requests (`POST` /
|
|
190
|
+
* `PUT` / `PATCH` / `DELETE`) that carry an `Origin` header pointing at a
|
|
191
|
+
* different origin than the request URL when no {@link cors} hook is
|
|
192
|
+
* registered (neither at the app level nor on the matched route). Throws
|
|
193
|
+
* a {@link ForbiddenError} that surfaces as `403 application/problem+json`
|
|
194
|
+
* so the rejection is loud rather than silently allowing the unintended
|
|
195
|
+
* cross-origin write.
|
|
196
|
+
*
|
|
197
|
+
* Disabled when {@link AppOptions.secureDefaults} is `false` or
|
|
198
|
+
* {@link AppOptions.corsCrossOriginGuard} is `false`. Same-origin
|
|
199
|
+
* requests, GET / HEAD / OPTIONS, requests with no `Origin` header, and
|
|
200
|
+
* routes whose hook chain includes a `cors()` policy allowing the origin
|
|
201
|
+
* are unaffected.
|
|
202
|
+
*/
|
|
203
|
+
assertCrossOriginAllowed(request, url, method, corsOriginAllows) {
|
|
204
|
+
if (this.options.secureDefaults === false)
|
|
205
|
+
return;
|
|
206
|
+
if (this.options.corsCrossOriginGuard === false)
|
|
207
|
+
return;
|
|
208
|
+
if (method !== "POST" &&
|
|
209
|
+
method !== "PUT" &&
|
|
210
|
+
method !== "PATCH" &&
|
|
211
|
+
method !== "DELETE") {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const origin = request.headers.get("origin");
|
|
215
|
+
if (!origin || origin === "null")
|
|
216
|
+
return;
|
|
217
|
+
let originUrl;
|
|
218
|
+
try {
|
|
219
|
+
originUrl = new URL(origin);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Malformed Origin header — refuse loudly.
|
|
223
|
+
throw new ForbiddenError("Cross-origin state-changing request rejected: malformed Origin header.");
|
|
224
|
+
}
|
|
225
|
+
if (originUrl.origin === url.origin)
|
|
226
|
+
return;
|
|
227
|
+
if (corsOriginAllows.some((allows) => allows(origin)))
|
|
228
|
+
return;
|
|
229
|
+
throw new ForbiddenError(`Cross-origin ${method} from "${originUrl.origin}" rejected: no registered cors() policy allows that origin. ` +
|
|
230
|
+
`Register cors({ origin: [...] }) via app.use(...) to allow it, or pass ` +
|
|
231
|
+
`app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Wave 3 sync boot guard. Inspects a hook layer being installed via
|
|
235
|
+
* {@link App.use} and refuses-to-boot when:
|
|
236
|
+
*
|
|
237
|
+
* - `cors({ origin: "*" })` is registered while resolved environment is
|
|
238
|
+
* `production`;
|
|
239
|
+
* - `session({ secret })` is registered while resolved environment is
|
|
240
|
+
* `production` and any secret fails {@link assertStrongSecret}.
|
|
241
|
+
*
|
|
242
|
+
* Disabled when `secureDefaults: false`. Thrown errors propagate out of
|
|
243
|
+
* `app.use(...)` so the process exits during startup rather than serving
|
|
244
|
+
* a misconfigured surface.
|
|
245
|
+
*/
|
|
246
|
+
assertSecureHookConfig(hooks) {
|
|
247
|
+
if (this.options.secureDefaults === false)
|
|
248
|
+
return;
|
|
249
|
+
if (!this.isProduction())
|
|
250
|
+
return;
|
|
251
|
+
const record = hooks;
|
|
252
|
+
if (record[CORS_WILDCARD_ORIGIN_MARKER] === true) {
|
|
253
|
+
throw new Error('cors({ origin: "*" }) refused in production: a wildcard CORS origin exposes every state-changing route cross-origin. ' +
|
|
254
|
+
"Replace the wildcard with an explicit allowlist (string[] or predicate), or pass " +
|
|
255
|
+
"app({ secureDefaults: false }) to disable this guard.");
|
|
256
|
+
}
|
|
257
|
+
if (record[SESSION_HOOK_MARKER] === true) {
|
|
258
|
+
const secrets = record[SESSION_SECRETS_MARKER];
|
|
259
|
+
if (Array.isArray(secrets)) {
|
|
260
|
+
for (const s of secrets) {
|
|
261
|
+
assertStrongSecret(s, "session");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
resetWave3BootGuardCache() {
|
|
267
|
+
this.wave3BootGuard.checked = false;
|
|
268
|
+
this.wave3BootGuard.error = undefined;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Wave 3 first-request boot guard. Verifies that the assembled hook
|
|
272
|
+
* chain + route table is internally consistent before any user handler
|
|
273
|
+
* runs. Currently checks: when `session()` is installed and any route
|
|
274
|
+
* accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
|
|
275
|
+
* a `csrf()` hook (or third-party equivalent stamped with
|
|
276
|
+
* {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
|
|
277
|
+
* hook chain. Opt out with `app({ csrf: "off" })` or
|
|
278
|
+
* `app({ secureDefaults: false })`. Runs once per App between registration
|
|
279
|
+
* changes; the result is cached so the fast path is a single boolean check.
|
|
280
|
+
*/
|
|
281
|
+
assertWave3BootGuards() {
|
|
282
|
+
if (this.wave3BootGuard.checked) {
|
|
283
|
+
if (this.wave3BootGuard.error)
|
|
284
|
+
throw this.wave3BootGuard.error;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.wave3BootGuard.checked = true;
|
|
288
|
+
if (this.options.secureDefaults === false)
|
|
289
|
+
return;
|
|
290
|
+
if (this.options.csrf === "off")
|
|
291
|
+
return;
|
|
292
|
+
// Per the risk register: boot guards only fire in production so CI /
|
|
293
|
+
// staging surfaces that ship sample secrets / no CSRF token while
|
|
294
|
+
// iterating do not pay the refuse-to-boot cost.
|
|
295
|
+
if (!this.isProduction())
|
|
296
|
+
return;
|
|
297
|
+
const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
|
|
298
|
+
if (!stateChanging)
|
|
299
|
+
return;
|
|
300
|
+
const err = new Error(`session() is registered in the hook chain for a state-changing route ` +
|
|
301
|
+
`(${stateChanging.method} ${stateChanging.path}) but no csrf() hook is installed. ` +
|
|
302
|
+
`Register csrf() via app.use(csrf({ strategy: "fetch-metadata", allowedOrigins: [...] })), ` +
|
|
303
|
+
`or pass app({ csrf: "off" }) to acknowledge that this app is not browser-facing.`);
|
|
304
|
+
this.wave3BootGuard.error = err;
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Wave 3 per-request guard for spoofed proxy headers. When the App was
|
|
309
|
+
* constructed without an explicit {@link AppOptions.trustProxy} value
|
|
310
|
+
* and a request arrives carrying an `X-Forwarded-*` header, refuse to
|
|
311
|
+
* dispatch it: the rate limiter, audit log, and request-id propagation
|
|
312
|
+
* would otherwise honour the attacker-supplied IP. Returns a structured
|
|
313
|
+
* `500 problem+json` so the failure is loud at the network boundary.
|
|
314
|
+
* Disabled when `secureDefaults: false` or when `trustProxy` is set to
|
|
315
|
+
* `true` or `false` explicitly.
|
|
316
|
+
*/
|
|
317
|
+
assertTrustProxyConfigured(request) {
|
|
318
|
+
if (this.options.secureDefaults === false)
|
|
319
|
+
return;
|
|
320
|
+
if (this.options.trustProxy !== undefined)
|
|
321
|
+
return;
|
|
322
|
+
// Same risk-register clause as the session/CSRF guard: only enforce
|
|
323
|
+
// in production. Dev/CI surfaces routinely test forwarded headers
|
|
324
|
+
// without configuring a reverse-proxy posture.
|
|
325
|
+
if (!this.isProduction())
|
|
326
|
+
return;
|
|
327
|
+
const headers = request.headers;
|
|
328
|
+
let found;
|
|
329
|
+
for (const name of [
|
|
330
|
+
"x-forwarded-for",
|
|
331
|
+
"x-forwarded-host",
|
|
332
|
+
"x-forwarded-proto",
|
|
333
|
+
"x-forwarded-port",
|
|
334
|
+
"x-real-ip",
|
|
335
|
+
]) {
|
|
336
|
+
if (headers.has(name)) {
|
|
337
|
+
found = name;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!found)
|
|
342
|
+
return;
|
|
343
|
+
if (!this.trustProxyWarned) {
|
|
344
|
+
this.trustProxyWarned = true;
|
|
345
|
+
this.log.warn({ event: "trust-proxy.unconfigured", header: found }, `Request carried ${found} but app({ trustProxy }) is unset; refusing to honour spoofable proxy headers.`);
|
|
346
|
+
}
|
|
347
|
+
throw new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
|
|
348
|
+
`Pass app({ trustProxy: true }) when running behind a trusted reverse proxy, ` +
|
|
349
|
+
`or app({ trustProxy: false }) to ignore forwarded headers, ` +
|
|
350
|
+
`or app({ secureDefaults: false }) to disable this guard.`);
|
|
351
|
+
}
|
|
135
352
|
/**
|
|
136
353
|
* Resolve the {@link AppOptions.docs} option and, when enabled, register
|
|
137
354
|
* the `/openapi.json` + `/docs` routes. Called once during construction so
|
|
@@ -291,6 +508,8 @@ export class App {
|
|
|
291
508
|
* @returns This `App` instance for chaining.
|
|
292
509
|
*/
|
|
293
510
|
route(def) {
|
|
511
|
+
if (def.hooks)
|
|
512
|
+
this.assertSecureHookConfig(def.hooks);
|
|
294
513
|
const fullPath = joinPath(this.prefix, def.path);
|
|
295
514
|
const merged = {
|
|
296
515
|
...def,
|
|
@@ -298,9 +517,21 @@ export class App {
|
|
|
298
517
|
tags: [...(this.groupTags ?? []), ...(def.tags ?? [])],
|
|
299
518
|
auth: def.auth ?? this.groupAuth,
|
|
300
519
|
};
|
|
301
|
-
const
|
|
302
|
-
|
|
520
|
+
const sources = [...this.groupHooks, def.hooks ?? {}];
|
|
521
|
+
const hooks = mergeHooks(sources);
|
|
522
|
+
const corsOriginAllows = corsOriginAllowsFromHooks(sources);
|
|
523
|
+
const securityMarkers = securityMarkersFromHooks([
|
|
524
|
+
this.options.hooks ?? {},
|
|
525
|
+
...sources,
|
|
526
|
+
]);
|
|
527
|
+
this.router.add(def.method, fullPath, { def: merged, hooks, corsOriginAllows }, def.operationId);
|
|
303
528
|
this.routes.push(merged);
|
|
529
|
+
this.routeSecurityMarkers.push({
|
|
530
|
+
method: merged.method,
|
|
531
|
+
path: merged.path,
|
|
532
|
+
...securityMarkers,
|
|
533
|
+
});
|
|
534
|
+
this.resetWave3BootGuardCache();
|
|
304
535
|
return this;
|
|
305
536
|
}
|
|
306
537
|
/**
|
|
@@ -340,6 +571,8 @@ export class App {
|
|
|
340
571
|
* @returns This `App` instance for chaining.
|
|
341
572
|
*/
|
|
342
573
|
group(prefix, config, register) {
|
|
574
|
+
if (config.hooks)
|
|
575
|
+
this.assertSecureHookConfig(config.hooks);
|
|
343
576
|
// Child apps share the parent's router/routes/etc. Disable docs auto-mount
|
|
344
577
|
// on the child so it does not re-register the parent's `/openapi.json` and
|
|
345
578
|
// `/docs` routes (which would throw "Duplicate route").
|
|
@@ -347,12 +580,15 @@ export class App {
|
|
|
347
580
|
child.router = this.router;
|
|
348
581
|
child.routes = this.routes;
|
|
349
582
|
child.webSocketRoutes = this.webSocketRoutes;
|
|
583
|
+
child.routeSecurityMarkers = this.routeSecurityMarkers;
|
|
584
|
+
child.wave3BootGuard = this.wave3BootGuard;
|
|
350
585
|
child.log = this.log;
|
|
351
586
|
child.prefix = joinPath(this.prefix, prefix);
|
|
352
587
|
child.groupHooks = [
|
|
353
588
|
...this.groupHooks,
|
|
354
589
|
...(config.hooks ? [config.hooks] : []),
|
|
355
590
|
];
|
|
591
|
+
child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
|
|
356
592
|
child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
|
|
357
593
|
child.groupAuth = config.auth ?? this.groupAuth;
|
|
358
594
|
child.decorations = this.decorations;
|
|
@@ -382,7 +618,27 @@ export class App {
|
|
|
382
618
|
* @returns This `App` instance for chaining.
|
|
383
619
|
*/
|
|
384
620
|
use(hooks) {
|
|
621
|
+
// Wave 3 boot guards: refuse to start when the new hook layer is a
|
|
622
|
+
// known-misconfigured security primitive in production. These checks
|
|
623
|
+
// run synchronously at registration time so the developer sees the
|
|
624
|
+
// failure during boot, not on first request.
|
|
625
|
+
this.assertSecureHookConfig(hooks);
|
|
626
|
+
// If the developer installs their own secureHeaders(), drop the
|
|
627
|
+
// Wave 2 auto-installed instance so the user's overrides win instead of
|
|
628
|
+
// being shadowed (the auto one runs first and the per-header
|
|
629
|
+
// "set only if absent" semantics mean the second installation would be
|
|
630
|
+
// a silent no-op).
|
|
631
|
+
if (hooks[SECURE_HEADERS_MARKER] === true) {
|
|
632
|
+
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
|
|
633
|
+
true);
|
|
634
|
+
if (autoIdx >= 0)
|
|
635
|
+
this.groupHooks.splice(autoIdx, 1);
|
|
636
|
+
}
|
|
385
637
|
this.groupHooks.push(hooks);
|
|
638
|
+
if (hooks[CORS_HOOK_MARKER] === true) {
|
|
639
|
+
this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
|
|
640
|
+
}
|
|
641
|
+
this.resetWave3BootGuardCache();
|
|
386
642
|
return this;
|
|
387
643
|
}
|
|
388
644
|
/**
|
|
@@ -574,12 +830,18 @@ export class App {
|
|
|
574
830
|
let activeSendHook = globalHooks.onSend;
|
|
575
831
|
try {
|
|
576
832
|
assertNoDuplicateSingletonHeaders(request.headers);
|
|
833
|
+
this.assertTrustProxyConfigured(request);
|
|
834
|
+
this.assertWave3BootGuards();
|
|
577
835
|
await globalHooks.onRequest?.(request);
|
|
578
836
|
const url = new URL(request.url);
|
|
579
837
|
const method = request.method;
|
|
580
838
|
const headFallback = method === "HEAD";
|
|
581
839
|
const match = this.router.find(method, url.pathname) ??
|
|
582
840
|
(headFallback ? this.router.find("GET", url.pathname) : undefined);
|
|
841
|
+
this.assertCrossOriginAllowed(request, url, method, [
|
|
842
|
+
...corsOriginAllowsFromHooks([this.options.hooks ?? {}]),
|
|
843
|
+
...(match ? match.handler.corsOriginAllows : this.corsOriginAllows),
|
|
844
|
+
]);
|
|
583
845
|
if (!match) {
|
|
584
846
|
const allowed = this.router.allowedMethods(url.pathname);
|
|
585
847
|
ctx = {
|
|
@@ -800,6 +1062,41 @@ function joinPath(a, b) {
|
|
|
800
1062
|
const joined = `${left}${right}`;
|
|
801
1063
|
return joined === "" ? "/" : joined;
|
|
802
1064
|
}
|
|
1065
|
+
function corsOriginAllowsFromHooks(layers) {
|
|
1066
|
+
const allows = [];
|
|
1067
|
+
for (const hooks of layers) {
|
|
1068
|
+
const record = hooks;
|
|
1069
|
+
const allow = record[CORS_ORIGIN_ALLOW_MARKER];
|
|
1070
|
+
if (typeof allow === "function") {
|
|
1071
|
+
allows.push(allow);
|
|
1072
|
+
}
|
|
1073
|
+
else if (record[CORS_HOOK_MARKER] === true) {
|
|
1074
|
+
// Third-party CORS helpers can still opt out of the guard with the
|
|
1075
|
+
// original marker. The first-party cors() stamps a stricter predicate
|
|
1076
|
+
// above, so disallowed origins are rejected before the handler runs.
|
|
1077
|
+
allows.push(() => true);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return allows;
|
|
1081
|
+
}
|
|
1082
|
+
function securityMarkersFromHooks(layers) {
|
|
1083
|
+
let hasSession = false;
|
|
1084
|
+
let hasCsrf = false;
|
|
1085
|
+
for (const hooks of layers) {
|
|
1086
|
+
const record = hooks;
|
|
1087
|
+
if (record[SESSION_HOOK_MARKER] === true)
|
|
1088
|
+
hasSession = true;
|
|
1089
|
+
if (record[CSRF_HOOK_MARKER] === true)
|
|
1090
|
+
hasCsrf = true;
|
|
1091
|
+
}
|
|
1092
|
+
return { hasSession, hasCsrf };
|
|
1093
|
+
}
|
|
1094
|
+
function isStateChangingMethod(method) {
|
|
1095
|
+
return (method === "POST" ||
|
|
1096
|
+
method === "PUT" ||
|
|
1097
|
+
method === "PATCH" ||
|
|
1098
|
+
method === "DELETE");
|
|
1099
|
+
}
|
|
803
1100
|
function mergeHooks(layers) {
|
|
804
1101
|
const pick = (key) => layers
|
|
805
1102
|
.map((h) => h[key])
|
|
@@ -906,7 +1203,7 @@ async function buildContext(request, url, rawParams, def, opts) {
|
|
|
906
1203
|
}
|
|
907
1204
|
if (def.request?.body) {
|
|
908
1205
|
const ct = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
909
|
-
const allowed = opts.allowedContentTypes ?? [
|
|
1206
|
+
const allowed = def.accepts ?? opts.allowedContentTypes ?? [
|
|
910
1207
|
"application/json",
|
|
911
1208
|
"application/x-www-form-urlencoded",
|
|
912
1209
|
"multipart/form-data",
|