@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +34 -22
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +42 -7
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +117 -46
  9. package/dist/app.d.ts +30 -4
  10. package/dist/app.js +187 -45
  11. package/dist/auto-ban.js +1 -3
  12. package/dist/bot-guard.js +30 -3
  13. package/dist/cli.js +9 -6
  14. package/dist/client.d.ts +36 -7
  15. package/dist/client.js +7 -0
  16. package/dist/compression.d.ts +9 -0
  17. package/dist/compression.js +72 -1
  18. package/dist/config.js +1 -3
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/errors.d.ts +12 -3
  22. package/dist/errors.js +14 -8
  23. package/dist/etag.js +12 -2
  24. package/dist/fetch-guard.d.ts +27 -19
  25. package/dist/fetch-guard.js +50 -8
  26. package/dist/geo-block.js +4 -9
  27. package/dist/hashing.js +1 -1
  28. package/dist/http-signatures.d.ts +4 -1
  29. package/dist/http-signatures.js +16 -9
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.js +3 -3
  32. package/dist/ip-reputation.js +1 -1
  33. package/dist/ip-restriction.js +3 -12
  34. package/dist/jwt.js +12 -14
  35. package/dist/logger.d.ts +45 -0
  36. package/dist/logger.js +135 -0
  37. package/dist/mcp.js +10 -9
  38. package/dist/middleware.js +33 -3
  39. package/dist/mtls.js +6 -1
  40. package/dist/multipart.js +9 -12
  41. package/dist/openapi.d.ts +1 -1
  42. package/dist/openapi.js +2 -2
  43. package/dist/rate-limit-redis.d.ts +4 -4
  44. package/dist/response-cache.d.ts +179 -21
  45. package/dist/response-cache.js +338 -29
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +5 -1
  49. package/dist/safe-redirect.js +27 -3
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security-schemes.js +1 -2
  53. package/dist/security.d.ts +41 -0
  54. package/dist/security.js +131 -15
  55. package/dist/session.d.ts +13 -2
  56. package/dist/session.js +111 -17
  57. package/dist/subdomains.js +1 -4
  58. package/dist/tenancy.d.ts +40 -0
  59. package/dist/tenancy.js +54 -3
  60. package/dist/time-claims.js +3 -1
  61. package/dist/waf.js +124 -32
  62. package/dist/webhook-delivery.js +19 -3
  63. package/dist/websocket.d.ts +8 -0
  64. package/dist/websocket.js +19 -4
  65. package/package.json +6 -5
package/dist/tenancy.js CHANGED
@@ -32,6 +32,12 @@
32
32
  * (as the first group hook, or in `AppOptions.hooks`) so `ctx.state.tenant`
33
33
  * is populated by the time their `keyGenerator` / `scope` callbacks run.
34
34
  *
35
+ * `responseCache()` is a special case in two ways: it partitions on the resolved
36
+ * tenant **automatically** (no `keyGenerator` needed — see
37
+ * {@link TENANCY_RESOLVED_MARKER}), and because a mis-ordered cache is a silent
38
+ * cross-tenant disclosure rather than a merely wrong bucket, mounting it *ahead*
39
+ * of `tenancy()` refuses to boot in production instead of leaking.
40
+ *
35
41
  * ```ts
36
42
  * import { App, tenancy, tenantFromSubdomain, tenantScope, rateLimit } from "@daloyjs/core";
37
43
  *
@@ -48,7 +54,7 @@
48
54
  *
49
55
  * @since 0.42.0
50
56
  */
51
- import { BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError, } from "./errors.js";
57
+ import { BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError } from "./errors.js";
52
58
  import { subdomains } from "./subdomains.js";
53
59
  /**
54
60
  * Conservative default tenant-id grammar: a DNS-label-like token, lowercase
@@ -181,6 +187,41 @@ export function tenantFromClaim(claim, opts = {}) {
181
187
  return undefined;
182
188
  };
183
189
  }
190
+ /** Status codes acceptable for an unresolved-tenant rejection. @since 0.42.0 */
191
+ /**
192
+ * Marker stamped on the `Hooks` object returned by {@link tenancy}, so the `App`
193
+ * boot guard can verify that a `responseCache()` in the same chain is mounted
194
+ * *after* tenancy — i.e. that the tenant is in `ctx.state` by the time the cache
195
+ * key is built.
196
+ *
197
+ * @since 1.0.0
198
+ */
199
+ export const TENANCY_HOOK_MARKER = Symbol.for("daloyjs.tenancy.hook");
200
+ /**
201
+ * `ctx.state` symbol under which {@link tenancy} records the tenant it resolved,
202
+ * independently of the configurable {@link TenancyOptions.stateKey}.
203
+ *
204
+ * Consumers that must partition shared state per tenant — notably
205
+ * `responseCache()`, which folds it into the cache key to prevent cross-tenant
206
+ * cached-response disclosure (CWE-524) — read this instead of guessing the
207
+ * `stateKey`. Kept in the global symbol registry so a consumer can re-derive it
208
+ * with `Symbol.for(...)` without importing this module.
209
+ *
210
+ * @since 1.0.0
211
+ */
212
+ export const TENANCY_RESOLVED_MARKER = Symbol.for("daloyjs.tenancy.resolved");
213
+ /**
214
+ * Value recorded under {@link TENANCY_RESOLVED_MARKER} when {@link tenancy} ran
215
+ * but resolved no tenant (only reachable with `tenancy({ require: false })`).
216
+ *
217
+ * Distinguishing "tenancy is active and resolved nothing" from "no tenancy at
218
+ * all" lets a consumer keep tenant-less traffic in its own partition rather than
219
+ * sharing the unpartitioned one. The leading space cannot occur in a normalized
220
+ * tenant id, so it can never collide with a real one.
221
+ *
222
+ * @since 1.0.0
223
+ */
224
+ export const TENANT_UNRESOLVED = " unresolved";
184
225
  /** Build the right `HttpError` for a configured status. */
185
226
  function rejection(status, detail) {
186
227
  switch (status) {
@@ -233,8 +274,13 @@ export function tenancy(opts) {
233
274
  else if (typeof opts.allow === "function") {
234
275
  allowFn = opts.allow;
235
276
  }
236
- return {
277
+ const hooks = {
237
278
  async beforeHandle(ctx) {
279
+ const state = ctx.state;
280
+ // Record "tenancy ran" up front, so a tenant-less request is partitioned
281
+ // as such by downstream consumers instead of falling into the shared,
282
+ // unpartitioned bucket alongside resolved tenants.
283
+ state[TENANCY_RESOLVED_MARKER] = TENANT_UNRESOLVED;
238
284
  let raw;
239
285
  for (const resolve of resolvers) {
240
286
  raw = await resolve(ctx);
@@ -259,9 +305,14 @@ export function tenancy(opts) {
259
305
  if (allowFn && !(await allowFn(id, ctx))) {
260
306
  throw rejection(invalidStatus, "Unknown tenant.");
261
307
  }
262
- ctx.state[stateKey] = id;
308
+ state[stateKey] = id;
309
+ state[TENANCY_RESOLVED_MARKER] = id;
263
310
  },
264
311
  };
312
+ // Let the App boot guard see tenancy's position in the hook chain relative to
313
+ // any responseCache() that must partition on the tenant it resolves.
314
+ hooks[TENANCY_HOOK_MARKER] = true;
315
+ return hooks;
265
316
  }
266
317
  /**
267
318
  * Build a `(ctx) => string` key function that reads the resolved tenant and
@@ -69,7 +69,9 @@ export function assertTemporalClaims(claims, opts) {
69
69
  if (!isFiniteNumber(claims.exp)) {
70
70
  throw new TemporalClaimError("invalid_exp", "payload.exp is not a finite number.");
71
71
  }
72
- if (now > claims.exp + skew) {
72
+ // RFC 7519 §4.1.4: current time must be *before* exp. At the exact
73
+ // expiration second (after skew) the token is no longer valid.
74
+ if (now >= claims.exp + skew) {
73
75
  throw new TemporalClaimError("token_expired", "token has expired (exp).");
74
76
  }
75
77
  }
package/dist/waf.js CHANGED
@@ -45,12 +45,7 @@ import { ForbiddenError } from "./errors.js";
45
45
  import { hasMongoOperatorKeys } from "./security.js";
46
46
  import { readRemoteAddress } from "./conn-info.js";
47
47
  /** The four built-in rule categories, in stable order. */
48
- const ALL_RULE_IDS = Object.freeze([
49
- "sqli",
50
- "xss",
51
- "nosqli",
52
- "cmdi",
53
- ]);
48
+ const ALL_RULE_IDS = Object.freeze(["sqli", "xss", "nosqli", "cmdi"]);
54
49
  /** Default anomaly score contributed by each rule when it matches. */
55
50
  const DEFAULT_RULE_SCORE = 5;
56
51
  /** Default total anomaly score at which a request is blocked / reported. */
@@ -69,6 +64,13 @@ const SQLI_SIGNATURES = Object.freeze([
69
64
  /\bUNION\b[\s\S]{0,40}?\bSELECT\b/i,
70
65
  /\b(?:OR|AND)\b\s+['"]?\d+['"]?\s*=\s*['"]?\d+/i,
71
66
  /'\s*(?:OR|AND)\s+'?[\w]+'?\s*=\s*'?[\w]+/i,
67
+ // Parenthesized subquery behind a boolean operator — `1 OR (SELECT 1)`. The
68
+ // tautology patterns above anchor on `= <digit>`, so a subquery carrying no
69
+ // comparison slipped through. Paired with the comment-stripped inspection
70
+ // variant this also catches `1/**/OR/**/(SELECT/**/1)`. High confidence:
71
+ // prose query values virtually never contain `OR (` immediately followed by
72
+ // the SELECT keyword.
73
+ /\b(?:OR|AND)\s*\(\s*SELECT\b/i,
72
74
  /;\s*(?:DROP|DELETE|INSERT|UPDATE|TRUNCATE|ALTER|CREATE)\b/i,
73
75
  /\b(?:SLEEP|BENCHMARK|PG_SLEEP)\s*\(/i,
74
76
  /\bWAITFOR\s+DELAY\b/i,
@@ -161,6 +163,105 @@ function safeDecode(value) {
161
163
  return value;
162
164
  }
163
165
  }
166
+ /**
167
+ * Maximum percent-decode passes applied when expanding inspection variants.
168
+ *
169
+ * One pass matches what most HTTP stacks hand the handler. A second pass
170
+ * catches classic double-encoding WAF evasions (`%2527` → `%27` → `'`). A
171
+ * third is omitted on purpose: deeper recursive decoding inflates false
172
+ * positives on legitimately percent-bearing text and is not how frameworks
173
+ * deliver query/path values.
174
+ */
175
+ const MAX_DECODE_PASSES = 2;
176
+ /**
177
+ * Control characters that are NOT matched by JS `\s`, used to split keywords
178
+ * past whitespace-anchored signatures (e.g. `1'%00OR%001=1`).
179
+ *
180
+ * U+0009-U+000D (\t \n \v \f \r) are deliberately absent: `\s` already
181
+ * matches them and every signature separates tokens with `\s`, `\b`, or
182
+ * `[\s\S]`, so normalizing them would only duplicate an existing variant.
183
+ *
184
+ * Hoisted to module scope so the hot path neither re-creates the RegExp object
185
+ * nor pays literal-evaluation overhead per inspected value. The probe is
186
+ * non-global (stateless `test()`); the replace copy is global and `replace()`
187
+ * resets `lastIndex`, so neither carries state between calls.
188
+ */
189
+ const CONTROL_CHAR_PROBE = /[\u0000-\u0008\u000e-\u001f\u007f]/;
190
+ const CONTROL_CHAR_GLOBAL = /[\u0000-\u0008\u000e-\u001f\u007f]/g;
191
+ /**
192
+ * Expand a single inbound string into the variants the WAF should scan.
193
+ *
194
+ * Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
195
+ * a `+`→space form (URLSearchParams parity), a SQL-comment-stripped
196
+ * form so comment-split keywords (e.g. OR wrapped in block comments) score
197
+ * the same as the whitespace-separated form, and a control-character→space
198
+ * form so embedded NUL / escape bytes cannot split keywords past the
199
+ * whitespace-anchored signatures (e.g. `1'%00OR%001=1` → `1' OR 1=1`).
200
+ *
201
+ * Scanning variants is pure defense-in-depth: the handler still receives
202
+ * whatever the framework's single-decode path produced. Each variant is
203
+ * truncated to `maxValueLength` and deduplicated so hostile inputs cannot
204
+ * explode the scan set.
205
+ *
206
+ * @param value - Raw or already-decoded string from path/query/header/body.
207
+ * @param maxValueLength - Cap applied to every variant before scanning.
208
+ * @returns Deduplicated inspection variants in stable insertion order.
209
+ */
210
+ function inspectionVariants(value, maxValueLength) {
211
+ const seen = new Set();
212
+ const out = [];
213
+ const push = (v) => {
214
+ const truncated = v.length > maxValueLength ? v.slice(0, maxValueLength) : v;
215
+ if (!seen.has(truncated)) {
216
+ seen.add(truncated);
217
+ out.push(truncated);
218
+ }
219
+ };
220
+ let current = value;
221
+ push(current);
222
+ for (let i = 0; i < MAX_DECODE_PASSES; i++) {
223
+ const decoded = safeDecode(current);
224
+ if (decoded === current)
225
+ break;
226
+ push(decoded);
227
+ current = decoded;
228
+ }
229
+ // Snapshot before secondary transforms so we only expand the decode chain.
230
+ const decodedChain = out.slice();
231
+ for (const v of decodedChain) {
232
+ if (v.includes("+"))
233
+ push(v.replace(/\+/g, " "));
234
+ if (v.includes("/*"))
235
+ push(v.replace(/\/\*[\s\S]*?\*\//g, " "));
236
+ // Control characters (notably NUL) are not `\s`, so `1'%00OR%001=1` split
237
+ // `OR` from `1=1` and walked past the whitespace-anchored signatures. Scan
238
+ // a control-char→space form; benign traffic carries almost no C0 bytes, so
239
+ // the false-positive surface is negligible.
240
+ //
241
+ // The class deliberately excludes U+0009-U+000D (\t \n \v \f \r): JS `\s`
242
+ // already matches those, and every signature separates tokens with `\s`,
243
+ // `\b`, or `[\s\S]`, so normalizing them only ever yields a variant that
244
+ // scores identically to one already in the set. Including them cost ~13%
245
+ // on every request carrying a multi-line body or query value.
246
+ if (CONTROL_CHAR_PROBE.test(v)) {
247
+ push(v.replace(CONTROL_CHAR_GLOBAL, " "));
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+ /**
253
+ * Scan every inspection variant of `value` for the active rule set.
254
+ *
255
+ * @see inspectionVariants
256
+ */
257
+ function scanValueVariants(value, location, rules, scored, maxValueLength) {
258
+ for (const variant of inspectionVariants(value, maxValueLength)) {
259
+ scanValue(variant, location, rules, scored);
260
+ // Early exit once every rule has already fired — no further variants needed.
261
+ if (scored.size === rules.length)
262
+ return;
263
+ }
264
+ }
164
265
  /**
165
266
  * Collect up to `maxNodes` string values from a parsed body value (object /
166
267
  * array / scalar), each truncated to `maxValueLength`. Depth and node count are
@@ -268,37 +369,29 @@ export function waf(opts = {}) {
268
369
  const scored = new Map();
269
370
  const url = new URL(ctx.request.url);
270
371
  if (inspectPath) {
271
- scanValue(safeDecode(url.pathname), "path", rules, scored);
372
+ // Path is scanned across raw + up to two decode passes so double-
373
+ // encoded traversal / injection tokens in path segments still score.
374
+ scanValueVariants(url.pathname, "path", rules, scored, maxValueLength);
272
375
  }
273
376
  if (inspectQuery && url.search.length > 1) {
274
- // Scan both the raw query string and a best-effort decoded form so an
275
- // encoded payload (`%27%20OR%201=1`) is caught after normalization.
276
- // This is a SINGLE decode on purpose: the framework's request path also
277
- // decodes the query exactly once, so the WAF sees the same bytes the
278
- // handler will. Recursive decoding is deliberately avoided — it would
279
- // false-positive on values that legitimately contain percent-encoded
280
- // text, and a double-encoded payload stays inert (`%3Cscript%3E`) all
281
- // the way to the handler. See red-team-attacks-6 "DOCUMENTED LIMITATION".
377
+ // Scan the raw query, bounded multi-decode variants, and each
378
+ // URLSearchParams key/value. Multi-decode (max 2) closes classic
379
+ // double-encoding WAF evasions (`%2527` `%27` `'`) without open-
380
+ // ended recursive decoding. URLSearchParams also turns `+` into
381
+ // space; inspectionVariants covers that form so `1+OR+1=1` scores
382
+ // the same as `1 OR 1=1` (parser-differential defense).
282
383
  const raw = url.search.slice(1);
283
- scanValue(raw, "query", rules, scored);
284
- const decoded = safeDecode(raw);
285
- if (decoded !== raw)
286
- scanValue(decoded, "query", rules, scored);
287
- // Additionally inspect each key/value the way the app's OWN query parser
288
- // (`URLSearchParams`) decodes them: notably `+` becomes a space, which a
289
- // plain `decodeURIComponent` does NOT do. Without this, `1+OR+1=1` slipped
290
- // past the WAF while the handler still received `1 OR 1=1` (a parser
291
- // differential — the WAF must inspect the bytes the app actually parses).
384
+ scanValueVariants(raw, "query", rules, scored, maxValueLength);
292
385
  for (const [k, v] of url.searchParams) {
293
- scanValue(k, "query", rules, scored);
294
- scanValue(v, "query", rules, scored);
386
+ scanValueVariants(k, "query", rules, scored, maxValueLength);
387
+ scanValueVariants(v, "query", rules, scored, maxValueLength);
295
388
  }
296
389
  }
297
390
  if (headerAllowlist.length > 0) {
298
391
  for (const name of headerAllowlist) {
299
392
  const value = ctx.request.headers.get(name);
300
393
  if (value)
301
- scanValue(value, "header", rules, scored);
394
+ scanValueVariants(value, "header", rules, scored, maxValueLength);
302
395
  }
303
396
  }
304
397
  if (inspectBody && ctx.body !== undefined && ctx.body !== null) {
@@ -316,14 +409,13 @@ export function waf(opts = {}) {
316
409
  });
317
410
  }
318
411
  if (typeof ctx.body === "string") {
319
- scanValue(ctx.body.length > maxValueLength
320
- ? ctx.body.slice(0, maxValueLength)
321
- : ctx.body, "body", rules, scored);
412
+ scanValueVariants(ctx.body, "body", rules, scored, maxValueLength);
322
413
  }
323
414
  else if (typeof ctx.body === "object") {
324
415
  const strings = collectBodyStrings(ctx.body, maxBodyNodes, maxValueLength);
325
- for (const value of strings)
326
- scanValue(value, "body", rules, scored);
416
+ for (const value of strings) {
417
+ scanValueVariants(value, "body", rules, scored, maxValueLength);
418
+ }
327
419
  }
328
420
  }
329
421
  if (scored.size === 0)
@@ -256,11 +256,25 @@ export function createWebhookSender(options) {
256
256
  lastError = undefined;
257
257
  if (response.ok) {
258
258
  options.onAttempt?.({ id, attempt, status: response.status, willRetry: false });
259
- return { ok: true, id, eventType: event.eventType, attempts: attempt, status: response.status, response, deadLettered: false };
259
+ return {
260
+ ok: true,
261
+ id,
262
+ eventType: event.eventType,
263
+ attempts: attempt,
264
+ status: response.status,
265
+ response,
266
+ deadLettered: false,
267
+ };
260
268
  }
261
269
  const retryable = retryStatuses.has(response.status) && attempt < maxAttempts;
262
270
  const delayMs = retryable ? backoffFor(attempt, response) : undefined;
263
- options.onAttempt?.({ id, attempt, status: response.status, willRetry: retryable, delayMs });
271
+ options.onAttempt?.({
272
+ id,
273
+ attempt,
274
+ status: response.status,
275
+ willRetry: retryable,
276
+ delayMs,
277
+ });
264
278
  if (!retryable)
265
279
  break;
266
280
  await sleep(delayMs);
@@ -291,7 +305,9 @@ export function createWebhookSender(options) {
291
305
  contentType,
292
306
  attempts: madeAttempts,
293
307
  ...(lastStatus !== undefined ? { lastStatus } : {}),
294
- ...(lastError !== undefined ? { lastError: lastError instanceof Error ? lastError.message : String(lastError) } : {}),
308
+ ...(lastError !== undefined
309
+ ? { lastError: lastError instanceof Error ? lastError.message : String(lastError) }
310
+ : {}),
295
311
  timestamp,
296
312
  failedAt: now(),
297
313
  });
@@ -445,14 +445,22 @@ export declare const FRAME_INCOMPLETE: unique symbol;
445
445
  * @param buf - Buffered socket bytes beginning at a frame boundary.
446
446
  * @param opts - `requireMask: true` enforces the RFC 6455 rule that
447
447
  * client-to-server frames are masked. Defaults to `{}` (not enforced).
448
+ * `maxPayload` rejects data frames whose **declared** payload length
449
+ * exceeds the limit as soon as the header is parsed, before the payload
450
+ * bytes have arrived — this keeps a slow-or-stalled sender from making
451
+ * the caller buffer an oversized incomplete frame. Control frames are
452
+ * already capped at 125 bytes and are not affected.
448
453
  * @returns The decoded {@link ParsedFrame}, or {@link FRAME_INCOMPLETE}
449
454
  * when more bytes are needed.
450
455
  * @throws WebSocketProtocolError on RSV bits, unknown opcodes, fragmented
451
456
  * or oversized control frames, unmasked client frames, or payload lengths
452
457
  * above `Number.MAX_SAFE_INTEGER`.
458
+ * @throws WebSocketPayloadTooLargeError when `maxPayload` is set and a data
459
+ * frame declares a payload length above it.
453
460
  */
454
461
  export declare function parseFrame(buf: Uint8Array, opts?: {
455
462
  requireMask?: boolean;
463
+ maxPayload?: number;
456
464
  }): ParsedFrame | typeof FRAME_INCOMPLETE;
457
465
  /**
458
466
  * Encode a single frame. By default the frame is emitted unmasked (server
package/dist/websocket.js CHANGED
@@ -92,7 +92,8 @@ function assertWebSocketOriginPolicy(policy) {
92
92
  }
93
93
  }
94
94
  function schemaToJson(schema) {
95
- const converter = schema?.toJSONSchema;
95
+ const converter = schema
96
+ ?.toJSONSchema;
96
97
  if (typeof converter !== "function")
97
98
  return undefined;
98
99
  try {
@@ -501,11 +502,18 @@ export const FRAME_INCOMPLETE = Symbol("daloy.ws.frameIncomplete");
501
502
  * @param buf - Buffered socket bytes beginning at a frame boundary.
502
503
  * @param opts - `requireMask: true` enforces the RFC 6455 rule that
503
504
  * client-to-server frames are masked. Defaults to `{}` (not enforced).
505
+ * `maxPayload` rejects data frames whose **declared** payload length
506
+ * exceeds the limit as soon as the header is parsed, before the payload
507
+ * bytes have arrived — this keeps a slow-or-stalled sender from making
508
+ * the caller buffer an oversized incomplete frame. Control frames are
509
+ * already capped at 125 bytes and are not affected.
504
510
  * @returns The decoded {@link ParsedFrame}, or {@link FRAME_INCOMPLETE}
505
511
  * when more bytes are needed.
506
512
  * @throws WebSocketProtocolError on RSV bits, unknown opcodes, fragmented
507
513
  * or oversized control frames, unmasked client frames, or payload lengths
508
514
  * above `Number.MAX_SAFE_INTEGER`.
515
+ * @throws WebSocketPayloadTooLargeError when `maxPayload` is set and a data
516
+ * frame declares a payload length above it.
509
517
  */
510
518
  export function parseFrame(buf, opts = {}) {
511
519
  if (buf.length < 2)
@@ -525,9 +533,7 @@ export function parseFrame(buf, opts = {}) {
525
533
  throw new WebSocketProtocolError("Control frames must not be fragmented");
526
534
  if (payloadLen > WS_MAX_CONTROL_PAYLOAD)
527
535
  throw new WebSocketProtocolError("Control frame payload exceeds 125 bytes");
528
- if (opcode !== WS_OPCODE.CLOSE &&
529
- opcode !== WS_OPCODE.PING &&
530
- opcode !== WS_OPCODE.PONG)
536
+ if (opcode !== WS_OPCODE.CLOSE && opcode !== WS_OPCODE.PING && opcode !== WS_OPCODE.PONG)
531
537
  throw new WebSocketProtocolError(`Unknown control opcode 0x${opcode.toString(16)}`);
532
538
  }
533
539
  else if (opcode !== WS_OPCODE.CONTINUATION &&
@@ -558,6 +564,14 @@ export function parseFrame(buf, opts = {}) {
558
564
  payloadLen = hi * 2 ** 32 + lo;
559
565
  offset += 8;
560
566
  }
567
+ // Reject oversized declared lengths as soon as the header is complete —
568
+ // before waiting on mask or payload bytes — so an attacker cannot make the
569
+ // caller buffer an unbounded incomplete frame by trickling payload bytes.
570
+ // Cumulative accounting across fragments stays with the caller; a declared
571
+ // length above the limit always implies the assembled message exceeds it.
572
+ if ((opcode & 0x8) === 0 && opts.maxPayload !== undefined && payloadLen > opts.maxPayload) {
573
+ throw new WebSocketPayloadTooLargeError(opts.maxPayload, payloadLen);
574
+ }
561
575
  if (opts.requireMask && !masked) {
562
576
  throw new WebSocketProtocolError("Client frames must be masked");
563
577
  }
@@ -745,6 +759,7 @@ export class FrameSink {
745
759
  while (this.buffer.length > 0) {
746
760
  const frame = parseFrame(this.buffer, {
747
761
  requireMask: this.opts.requireMask,
762
+ maxPayload: this.opts.maxPayloadLength,
748
763
  });
749
764
  if (frame === FRAME_INCOMPLETE)
750
765
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.0.0-rc.4",
3
+ "version": "1.0.0-rc.6",
4
4
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -236,12 +236,12 @@
236
236
  }
237
237
  },
238
238
  "devDependencies": {
239
- "@hey-api/openapi-ts": "^0.99.0",
239
+ "@hey-api/openapi-ts": "0.0.0-next-20260711024907",
240
240
  "@types/node": "^26.0.1",
241
241
  "fast-check": "^4.8.0",
242
242
  "prettier": "^3.8.3",
243
243
  "tsx": "^4.22.3",
244
- "typescript": "^6.0.3",
244
+ "typescript": "^7.0.2",
245
245
  "zod": "^4.4.3"
246
246
  },
247
247
  "scripts": {
@@ -249,11 +249,12 @@
249
249
  "dev": "tsc -w -p tsconfig.json",
250
250
  "example": "node --import tsx examples/basic.ts",
251
251
  "bench": "node --import tsx bench/router.bench.ts",
252
- "bench:serverless": "node --import tsx bench/serverless-cold-path.bench.ts",
252
+ "bench:serverless": "pnpm build && node --import tsx bench/serverless-cold-path.bench.ts",
253
253
  "bench:json": "node --import tsx bench/json-body.bench.ts",
254
254
  "bench:json-e2e": "node --import tsx bench/json-body-e2e.bench.ts",
255
+ "bench:ablation": "pnpm build && node --import tsx bench/ablation.bench.ts",
255
256
  "test": "node --import tsx --test tests/**/*.test.ts",
256
- "test:red-team": "node --import tsx --test tests/red-team-attacks.test.ts tests/red-team-attacks-2.test.ts tests/red-team-attacks-3.test.ts tests/red-team-attacks-4.test.ts tests/red-team-attacks-5.test.ts tests/red-team-attacks-6.test.ts tests/red-team-attacks-7.test.ts tests/red-team-attacks-8.test.ts tests/red-team-attacks-9.test.ts tests/red-team-attacks-10.test.ts",
257
+ "test:red-team": "node --import tsx --test tests/red-team-attacks.test.ts tests/red-team-attacks-2.test.ts tests/red-team-attacks-3.test.ts tests/red-team-attacks-4.test.ts tests/red-team-attacks-5.test.ts tests/red-team-attacks-6.test.ts tests/red-team-attacks-7.test.ts tests/red-team-attacks-8.test.ts tests/red-team-attacks-9.test.ts tests/red-team-attacks-10.test.ts tests/red-team-attacks-11.test.ts",
257
258
  "red-team:live": "node --import tsx red-team-live/run.ts",
258
259
  "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
259
260
  "coverage:branches": "tsc -p tsconfig.coverage.json && node --test --experimental-test-coverage --test-coverage-include='dist-coverage/src/**' --test-coverage-branches=92 dist-coverage/tests/**/*.test.js",