@daloyjs/core 1.0.0-rc.5 → 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.
package/dist/tenancy.d.ts 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
  *
@@ -161,6 +167,40 @@ export interface ClaimTenantOptions {
161
167
  */
162
168
  export declare function tenantFromClaim(claim: string, opts?: ClaimTenantOptions): TenantResolver;
163
169
  /** Status codes acceptable for an unresolved-tenant rejection. @since 0.42.0 */
170
+ /**
171
+ * Marker stamped on the `Hooks` object returned by {@link tenancy}, so the `App`
172
+ * boot guard can verify that a `responseCache()` in the same chain is mounted
173
+ * *after* tenancy — i.e. that the tenant is in `ctx.state` by the time the cache
174
+ * key is built.
175
+ *
176
+ * @since 1.0.0
177
+ */
178
+ export declare const TENANCY_HOOK_MARKER: unique symbol;
179
+ /**
180
+ * `ctx.state` symbol under which {@link tenancy} records the tenant it resolved,
181
+ * independently of the configurable {@link TenancyOptions.stateKey}.
182
+ *
183
+ * Consumers that must partition shared state per tenant — notably
184
+ * `responseCache()`, which folds it into the cache key to prevent cross-tenant
185
+ * cached-response disclosure (CWE-524) — read this instead of guessing the
186
+ * `stateKey`. Kept in the global symbol registry so a consumer can re-derive it
187
+ * with `Symbol.for(...)` without importing this module.
188
+ *
189
+ * @since 1.0.0
190
+ */
191
+ export declare const TENANCY_RESOLVED_MARKER: unique symbol;
192
+ /**
193
+ * Value recorded under {@link TENANCY_RESOLVED_MARKER} when {@link tenancy} ran
194
+ * but resolved no tenant (only reachable with `tenancy({ require: false })`).
195
+ *
196
+ * Distinguishing "tenancy is active and resolved nothing" from "no tenancy at
197
+ * all" lets a consumer keep tenant-less traffic in its own partition rather than
198
+ * sharing the unpartitioned one. The leading space cannot occur in a normalized
199
+ * tenant id, so it can never collide with a real one.
200
+ *
201
+ * @since 1.0.0
202
+ */
203
+ export declare const TENANT_UNRESOLVED = " unresolved";
164
204
  export type UnresolvedStatus = 400 | 401 | 403 | 404;
165
205
  /** Status codes acceptable for an unknown/disallowed-tenant rejection. @since 0.42.0 */
166
206
  export type InvalidStatus = 400 | 403 | 404;
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
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,
@@ -171,13 +173,30 @@ function safeDecode(value) {
171
173
  * deliver query/path values.
172
174
  */
173
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;
174
191
  /**
175
192
  * Expand a single inbound string into the variants the WAF should scan.
176
193
  *
177
194
  * Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
178
- * a `+`→space form (URLSearchParams parity), and a SQL-comment-stripped
195
+ * a `+`→space form (URLSearchParams parity), a SQL-comment-stripped
179
196
  * form so comment-split keywords (e.g. OR wrapped in block comments) score
180
- * the same as the whitespace-separated form.
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`).
181
200
  *
182
201
  * Scanning variants is pure defense-in-depth: the handler still receives
183
202
  * whatever the framework's single-decode path produced. Each variant is
@@ -214,6 +233,19 @@ function inspectionVariants(value, maxValueLength) {
214
233
  push(v.replace(/\+/g, " "));
215
234
  if (v.includes("/*"))
216
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
+ }
217
249
  }
218
250
  return out;
219
251
  }
@@ -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.5",
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": {
@@ -254,7 +254,7 @@
254
254
  "bench:json-e2e": "node --import tsx bench/json-body-e2e.bench.ts",
255
255
  "bench:ablation": "pnpm build && node --import tsx bench/ablation.bench.ts",
256
256
  "test": "node --import tsx --test tests/**/*.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",
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",
258
258
  "red-team:live": "node --import tsx red-team-live/run.ts",
259
259
  "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
260
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",