@daloyjs/core 0.35.1 → 0.36.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 CHANGED
@@ -466,6 +466,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
466
466
 
467
467
  - Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded-*` in production.
468
468
  - `secureDefaults: false` in production unless `acknowledgeInsecureDefaults: true` is set, plus a once-per-process `error` log naming every disabled default.
469
+ - `preset: "internal-service"` topology preset for service-to-service deployments behind a mesh / sidecar / private network: turns OFF the browser-only guards (auto `secureHeaders`, `corsCrossOriginGuard`, `csrf` boot guard, unconfigured `X-Forwarded-*` guard) while keeping every input, parser, credential, SSRF, weak-secret, and refuse-to-boot guard ON. Per-knob options still win, the choice is logged at boot under `event: "security.preset.applied"`, and the live posture is auditable via `app.getSecurityPosture()`.
469
470
  - `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, and refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2).
470
471
  - `secureHeaders()` refuses to construct with `frameOptions: false` AND no CSP `frame-ancestors` directive (no clickjacking defense).
471
472
  - `cors()` refuses `methods: ['*']` at construction; default `allowMethods` narrowed to `[GET, HEAD, POST]` so `PUT` / `PATCH` / `DELETE` become explicit opt-ins.
@@ -23,6 +23,17 @@ export interface NodeServerOptions {
23
23
  * clients can spoof the scheme/host. Default: false.
24
24
  */
25
25
  trustProxy?: boolean;
26
+ /**
27
+ * Maximum declared `Content-Length` (in bytes) for which the Node adapter
28
+ * pre-buffers the request body into a `Uint8Array` before constructing the
29
+ * `Request`. Bodies above this threshold fall back to the streaming
30
+ * `Readable.toWeb(req)` path so the adapter never holds an unbounded buffer
31
+ * per in-flight request — important under high concurrency where N
32
+ * simultaneous large uploads would otherwise pin N × threshold bytes of
33
+ * memory. The threshold is independently capped by `App.bodyLimitBytes`,
34
+ * which is the actual security limit. Default: 256 KiB.
35
+ */
36
+ bufferedBodyMaxBytes?: number;
26
37
  }
27
38
  /** Handle returned by {@link serve} exposing the underlying Node `Server` plus a `close()` for graceful shutdown. */
28
39
  export interface NodeServerHandle {
@@ -4,11 +4,14 @@
4
4
  */
5
5
  import { createServer, } from "node:http";
6
6
  import { Readable } from "node:stream";
7
- import { DALOY_RAW_BODY } from "../app.js";
7
+ import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY } from "../app.js";
8
8
  import { FrameSink, encodeFrame, encodeClosePayload, encodeSendPayload, validateUpgrade, validateSelectedSubprotocol, checkWebSocketOrigin, WS_OPCODE, WS_CLOSE_CODE, WS_READY_STATE, WS_MAX_CONTROL_PAYLOAD, WebSocketProtocolError, WebSocketPayloadTooLargeError, } from "../websocket.js";
9
9
  /** Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}. */
10
10
  export function serve(app, opts = {}) {
11
11
  const trustProxy = opts.trustProxy === true;
12
+ const bufferedBodyMaxBytes = typeof opts.bufferedBodyMaxBytes === "number" && opts.bufferedBodyMaxBytes >= 0
13
+ ? opts.bufferedBodyMaxBytes
14
+ : DEFAULT_BUFFERED_BODY_MAX_BYTES;
12
15
  const server = createServer({ maxHeaderSize: opts.maxHeaderBytes ?? 16 * 1024 }, (req, res) => {
13
16
  // GET/HEAD: no body work, dispatch directly. Keep this first so the GET
14
17
  // hot path doesn't pay for any of the buffering bookkeeping below.
@@ -23,7 +26,7 @@ export function serve(app, opts = {}) {
23
26
  // WHATWG-stream adapter that dominates POST throughput on Node.
24
27
  const cl = req.headers["content-length"];
25
28
  const n = cl ? Number(cl) : NaN;
26
- if (Number.isFinite(n) && n >= 0 && n <= BUFFERED_BODY_MAX_BYTES) {
29
+ if (Number.isFinite(n) && n >= 0 && n <= bufferedBodyMaxBytes) {
27
30
  bufferRequestBody(req, n).then((bytes) => dispatchToApp(app, req, res, trustProxy, bytes), (e) => writeAdapterError(res, e));
28
31
  return;
29
32
  }
@@ -70,12 +73,14 @@ export function serve(app, opts = {}) {
70
73
  return { server, port, close };
71
74
  }
72
75
  /**
73
- * Maximum content-length (in bytes) that the Node adapter will pre-buffer
74
- * before constructing the `Request`. Bodies above this fall back to the
75
- * streaming `Readable.toWeb` path so unbounded uploads can't exhaust
76
- * adapter memory. 1 MiB matches the default `App.bodyLimitBytes`.
76
+ * Default pre-buffer ceiling for the Node adapter. 256 KiB is a compromise:
77
+ * large enough that the vast majority of JSON / form requests stay on the
78
+ * fast (Uint8Array) path, small enough that N concurrent in-flight bodies
79
+ * don't pin huge amounts of memory. Override via
80
+ * {@link NodeServerOptions.bufferedBodyMaxBytes}. The actual security cap
81
+ * on body size remains `App.bodyLimitBytes`.
77
82
  */
78
- const BUFFERED_BODY_MAX_BYTES = 1024 * 1024;
83
+ const DEFAULT_BUFFERED_BODY_MAX_BYTES = 256 * 1024;
79
84
  function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
80
85
  let request;
81
86
  try {
@@ -111,21 +116,31 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
111
116
  }
112
117
  function bufferRequestBody(req, expected) {
113
118
  return new Promise((resolve, reject) => {
114
- const chunks = [];
119
+ // Pre-allocate to the declared Content-Length. The caller has already
120
+ // checked `expected <= BUFFERED_BODY_MAX_BYTES` (1 MiB) so this
121
+ // allocation is bounded and DoS-safe. Skipping the intermediate
122
+ // `chunks: Buffer[]` array + `Buffer.concat` avoids one full-body
123
+ // copy per request — significant at 1 MiB bodies under load.
124
+ // Use `Buffer.alloc` (zero-filled) rather than `Buffer.allocUnsafe`:
125
+ // the unsafe variant returns uninitialized memory and is forbidden by
126
+ // `verify:no-unsafe-buffer`. Any unwritten tail is sliced off below.
127
+ const out = expected > 0 ? Buffer.alloc(expected) : null;
115
128
  let received = 0;
116
129
  let settled = false;
117
130
  const onData = (chunk) => {
118
131
  if (settled)
119
132
  return;
120
- received += chunk.length;
121
- if (received > expected) {
133
+ const next = received + chunk.length;
134
+ if (next > expected) {
122
135
  settled = true;
123
136
  cleanup();
124
137
  req.destroy();
125
138
  reject(new Error("Request body exceeded declared Content-Length"));
126
139
  return;
127
140
  }
128
- chunks.push(chunk);
141
+ // out is non-null here because next > 0 implies expected > 0.
142
+ chunk.copy(out, received);
143
+ received = next;
129
144
  };
130
145
  const onEnd = () => {
131
146
  if (settled)
@@ -136,7 +151,9 @@ function bufferRequestBody(req, expected) {
136
151
  resolve(new Uint8Array(0));
137
152
  return;
138
153
  }
139
- const buf = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, received);
154
+ // Trust Content-Length: if the client under-delivered we still
155
+ // resolve the prefix actually received (matches prior behavior).
156
+ const buf = received === expected ? out : out.subarray(0, received);
140
157
  resolve(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
141
158
  };
142
159
  const onErr = (err) => {
@@ -184,23 +201,40 @@ function toWebRequest(req, trustProxy, bufferedBody) {
184
201
  const proto = forwardedProto ??
185
202
  (req.socket.encrypted ? "https" : "http");
186
203
  const url = `${proto}://${host}${req.url ?? "/"}`;
187
- const headers = new Headers();
188
- for (const k in reqHeaders) {
189
- const v = reqHeaders[k];
190
- if (v === undefined)
204
+ // Build headers from `rawHeaders` (a flat [k0,v0,k1,v1,...] array) instead
205
+ // of the parsed `req.headers` object. This matches @hono/node-server's
206
+ // `newHeadersFromIncoming`: one `new Headers([[k,v],...])` constructor
207
+ // call rather than N `headers.set()` calls. It is also stricter for
208
+ // duplicate-Host smuggling — Node coalesces some singleton headers down
209
+ // to the first value on `req.headers`, but `rawHeaders` preserves every
210
+ // occurrence so `assertNoDuplicateSingletonHeaders` actually sees them.
211
+ // Skip HTTP/2 pseudo-headers (leading ':') defensively, even though
212
+ // node:http's createServer is HTTP/1.1 only today.
213
+ const rawHeaders = req.rawHeaders;
214
+ const headerPairs = [];
215
+ for (let i = 0; i < rawHeaders.length; i += 2) {
216
+ const k = rawHeaders[i];
217
+ if (k.charCodeAt(0) === 58 /* ':' */)
191
218
  continue;
192
- headers.set(k, Array.isArray(v) ? v.join(", ") : v);
219
+ headerPairs.push([k, rawHeaders[i + 1]]);
193
220
  }
221
+ const headers = new Headers(headerPairs);
194
222
  const method = req.method ?? "GET";
195
223
  if (method === "GET" || method === "HEAD") {
196
224
  return new Request(url, { method, headers });
197
225
  }
198
226
  if (bufferedBody !== undefined) {
199
- return new Request(url, {
227
+ const req2 = new Request(url, {
200
228
  method,
201
229
  headers,
202
230
  body: bufferedBody,
203
231
  });
232
+ // Stash the validated bytes so readBodyLimited (and any other internal
233
+ // body reader) can skip the WHATWG ReadableStream reader loop. The
234
+ // adapter has already enforced BUFFERED_BODY_MAX_BYTES + Content-Length
235
+ // here; readBodyLimited re-checks against the caller's limit.
236
+ req2[DALOY_REQUEST_RAW_BODY] = bufferedBody;
237
+ return req2;
204
238
  }
205
239
  return new Request(url, {
206
240
  method,
@@ -234,6 +268,30 @@ function sendWebResponse(res, out) {
234
268
  }
235
269
  return;
236
270
  }
271
+ // Fast-path: handler returned a raw stream. Check before `!res.body` —
272
+ // a Node Readable is stashed alongside a null Response body, and the
273
+ // `new Response(null)` constructor also auto-sets `content-length: 0`
274
+ // which we must strip so Node falls back to chunked transfer-encoding.
275
+ const rawStream = res[DALOY_RAW_STREAM];
276
+ if (rawStream !== undefined) {
277
+ if (typeof rawStream.pipe === "function" && !(rawStream instanceof ReadableStream)) {
278
+ // Node `Readable` from the handler: skip the Web-stream bridge entirely
279
+ // and `.pipe(out)` like Fastify/Koa/Express do.
280
+ out.removeHeader("content-length");
281
+ return new Promise((resolve, reject) => {
282
+ const r = rawStream;
283
+ const onError = (err) => {
284
+ r.destroy();
285
+ reject(err);
286
+ };
287
+ r.once("error", onError);
288
+ out.once("error", onError);
289
+ out.once("finish", () => resolve());
290
+ r.pipe(out);
291
+ });
292
+ }
293
+ return pumpBody(rawStream, out);
294
+ }
237
295
  if (!res.body) {
238
296
  out.end();
239
297
  return;
@@ -253,16 +311,20 @@ function sendWebResponse(res, out) {
253
311
  }
254
312
  return pumpBody(res.body, out);
255
313
  }
256
- async function pumpBody(body, out) {
257
- const reader = body.getReader();
258
- while (true) {
259
- const { done, value } = await reader.read();
260
- if (done)
261
- break;
262
- if (value)
263
- out.write(value);
264
- }
265
- out.end();
314
+ function pumpBody(body, out) {
315
+ // Delegate to Node's native pipe: it honors backpressure and avoids the
316
+ // per-chunk microtask overhead of an explicit `await reader.read()` loop.
317
+ return new Promise((resolve, reject) => {
318
+ const readable = Readable.fromWeb(body);
319
+ const onError = (err) => {
320
+ readable.destroy();
321
+ reject(err);
322
+ };
323
+ readable.once("error", onError);
324
+ out.once("error", onError);
325
+ out.once("finish", () => resolve());
326
+ readable.pipe(out);
327
+ });
266
328
  }
267
329
  // ---------- WebSocket upgrade ----------
268
330
  async function handleUpgrade(app, req, socket, head, trustProxy) {
package/dist/app.d.ts CHANGED
@@ -10,6 +10,33 @@ import { type BehindProxyConfig } from "./conn-info.js";
10
10
  export declare function _resetCrashHandlersForTests(): void;
11
11
  /** @internal Test-only helper to reset the latch between tests. */
12
12
  export declare function _resetInsecureDefaultsLogForTests(): void;
13
+ /**
14
+ * Named security posture preset. Currently only one value is supported:
15
+ *
16
+ * - `"internal-service"` — relaxes the *topology-dependent* defaults that
17
+ * only make sense when an HTTP boundary faces a browser or the public
18
+ * internet (auto `secureHeaders`, cross-origin write guard, the
19
+ * session+state-changing-route CSRF boot guard, and the unconfigured
20
+ * `X-Forwarded-*` guard). Everything that protects the service from
21
+ * malformed input, confused dependencies, or compromised callers —
22
+ * body limits, request timeouts, JWT algorithm allowlists, weak-secret
23
+ * refuse-to-boot, `cors({ origin: '*' })` refuse-to-boot, anonymous
24
+ * stateful plugin refuse-to-boot, `crashOnUnhandledRejection`, schema
25
+ * strictness, prototype-pollution-safe parsers, SSRF-safe `fetchGuard`
26
+ * defaults, RFC 9457 problem+json redaction — stays on. Per-knob
27
+ * options still win (`secureHeaders: { ... }` re-enables it on top of
28
+ * the preset). The preset choice is logged once at boot under the
29
+ * `security.preset.applied` event so operators can audit the posture
30
+ * without reading code.
31
+ *
32
+ * Topology presets are intentionally a small, curated set — they are NOT
33
+ * a master "disable everything" knob. If you really need to disable the
34
+ * entire secure-by-default surface, use the explicit
35
+ * {@link AppOptions.secureDefaults} `false` escape hatch.
36
+ *
37
+ * @since 0.34.0
38
+ */
39
+ export type SecurityPreset = "internal-service";
13
40
  /**
14
41
  * Configuration accepted by {@link App}'s constructor. Every field is
15
42
  * optional; sensible production defaults are applied.
@@ -21,6 +48,23 @@ export interface AppOptions {
21
48
  title?: string;
22
49
  version?: string;
23
50
  description?: string;
51
+ /**
52
+ * Topology-aware security posture preset. See {@link SecurityPreset}.
53
+ *
54
+ * - `"internal-service"` — for service-to-service deployments behind a
55
+ * service mesh, sidecar, or private network. Turns off the
56
+ * browser-/edge-only guards (auto `secureHeaders`, cross-origin write
57
+ * guard, session+state-changing CSRF boot guard, unconfigured
58
+ * `X-Forwarded-*` guard) while keeping every input-, parser-,
59
+ * credential-, and SSRF-level guard on. The choice is logged once at
60
+ * boot under the `security.preset.applied` event. Per-knob options
61
+ * you pass alongside the preset still win.
62
+ *
63
+ * Omit (default) for browser-facing / public APIs.
64
+ *
65
+ * @since 0.34.0
66
+ */
67
+ preset?: SecurityPreset;
24
68
  /** Validate handler responses against declared response schemas. Default: true. */
25
69
  validateResponses?: boolean;
26
70
  /** Hard cap on request body size in bytes. Default: 1 MiB. */
@@ -480,6 +524,25 @@ export interface IntrospectedRoute {
480
524
  * implementation detail — userland code should never depend on it.
481
525
  */
482
526
  export declare const DALOY_RAW_BODY: unique symbol;
527
+ /**
528
+ * Internal Symbol used by adapters to stash a pre-buffered request body on
529
+ * the `Request` instance. When set, {@link readBodyLimited} skips the
530
+ * `ReadableStream` reader loop and returns the cached bytes directly after
531
+ * re-checking them against the caller-supplied limit. Adapters MUST only
532
+ * attach bytes they have already validated against the configured
533
+ * {@link AppOptions.bodyLimitBytes}; the limit re-check in
534
+ * `readBodyLimited` is defense-in-depth, not the primary cap. Module-public
535
+ * so first-party adapters can opt in; not part of the userland API surface.
536
+ */
537
+ export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
538
+ /**
539
+ * Internal Symbol set by handlers/serializers to attach a raw stream
540
+ * (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
541
+ * adapter pipes the stream straight to the socket, skipping the
542
+ * Web-stream reader bridge. Module-public so first-party adapters can
543
+ * opt in; userland code should not depend on it.
544
+ */
545
+ export declare const DALOY_RAW_STREAM: unique symbol;
483
546
  /**
484
547
  * Contract-first HTTP application.
485
548
  *
@@ -535,6 +598,14 @@ export declare class App {
535
598
  /** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
536
599
  readonly routes: RouteDefinition<any, any, any, any>[];
537
600
  private router;
601
+ /**
602
+ * Memoized result of `isProduction()`. The inputs (`options.env`,
603
+ * `options.production`, `process.env.NODE_ENV`) cannot change between
604
+ * the moment a route is dispatched and the moment its error is rendered,
605
+ * so reading `process.env.NODE_ENV` on every error response is wasted
606
+ * work in the hot path. Computed lazily on first read.
607
+ */
608
+ private _productionCache;
538
609
  /** WebSocket route registry. Adapters look up handlers via `app.webSocketRoutes.find()`. */
539
610
  readonly webSocketRoutes: WebSocketRegistry;
540
611
  private prefix;
@@ -545,6 +616,13 @@ export declare class App {
545
616
  private routeSecurityMarkers;
546
617
  /** Decorator bag merged into ctx.state on every request. */
547
618
  private decorations;
619
+ /**
620
+ * Count of own keys on {@link decorations}. Tracked alongside the bag so the
621
+ * dispatch hot path can take a `count === 0` fast path and skip the
622
+ * `Object.assign` spread on the common case (no `app.decorate()` calls).
623
+ * Updated only when {@link decorate} mutates the bag.
624
+ */
625
+ private decorationsCount;
548
626
  private installedPlugins;
549
627
  private closeHooks;
550
628
  private closeHooksRun;
@@ -608,6 +686,41 @@ export declare class App {
608
686
  * set deep in shared configuration.
609
687
  */
610
688
  private assertInsecureDefaultsAcknowledged;
689
+ /**
690
+ * Emit the one-time boot audit entry for an applied security preset.
691
+ * Called from the constructor with the *original* (pre-preset) options
692
+ * so the log captures which fields the preset filled in vs. which the
693
+ * caller set explicitly. Logged at `info` so the line shows up in
694
+ * standard production log shipping without being noisy.
695
+ *
696
+ * Operators can audit the live posture at any time through
697
+ * {@link App.getSecurityPosture}.
698
+ *
699
+ * @since 0.34.0
700
+ */
701
+ private logSecurityPresetIfApplied;
702
+ /**
703
+ * Structured snapshot of the live security posture. Returns the same
704
+ * data the constructor logs under the `security.preset.applied` audit
705
+ * event plus the resolved values of every secure-by-default knob, so
706
+ * operators can build a `/__security` introspection route or a CI
707
+ * audit without parsing the framework source.
708
+ *
709
+ * @since 0.34.0
710
+ */
711
+ getSecurityPosture(): {
712
+ preset: SecurityPreset | undefined;
713
+ secureDefaults: boolean;
714
+ secureHeaders: boolean;
715
+ corsCrossOriginGuard: boolean;
716
+ csrf: "off" | "on";
717
+ crashOnUnhandledRejection: boolean | "default";
718
+ trustProxy: true | false | "unconfigured";
719
+ bodyLimitBytes: number;
720
+ requestTimeoutMs: number;
721
+ stripServerHeaders: boolean;
722
+ production: boolean;
723
+ };
611
724
  /**
612
725
  * Install the secure-by-default global hooks. Currently:
613
726
  * - {@link secureHeaders} as a group-level hook so every response carries