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

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/security.js CHANGED
@@ -260,6 +260,11 @@ export const SMUGGLING_SINGLETON_HEADERS = Object.freeze([
260
260
  * Throws {@link BadRequestError} so the framework returns a structured
261
261
  * `400 problem+json` instead of forwarding a smuggling-class request.
262
262
  *
263
+ * The framework's dispatch path runs this check, the reserved-prefix check,
264
+ * and the header-count cap in a single shared walk internally (see
265
+ * {@link assertInboundHeaderGuards}); calling this helper directly is only
266
+ * needed for custom pipelines.
267
+ *
263
268
  * @param headers - Normalized request headers to inspect.
264
269
  * @since 0.15.0
265
270
  */
@@ -271,6 +276,13 @@ export function assertNoDuplicateSingletonHeaders(headers) {
271
276
  }
272
277
  }
273
278
  }
279
+ /**
280
+ * Set view of {@link SMUGGLING_SINGLETON_HEADERS}, built once at module load
281
+ * so the per-request walk in {@link assertInboundHeaderGuards} pays a Set
282
+ * lookup instead of three undici `Headers.get()` calls (each of which runs
283
+ * WebIDL ByteString conversion + header-name token validation).
284
+ */
285
+ const SMUGGLING_SINGLETON_SET = new Set(SMUGGLING_SINGLETON_HEADERS);
274
286
  /**
275
287
  * Reserved inbound header namespaces that an external client must never
276
288
  * be allowed to set. These prefixes are owned by the framework so that no
@@ -299,6 +311,29 @@ export const RESERVED_INBOUND_HEADER_PREFIXES = Object.freeze([
299
311
  "x-daloy-internal-",
300
312
  "x-daloyjs-internal-",
301
313
  ]);
314
+ /**
315
+ * Longest common prefix shared by every entry in
316
+ * {@link RESERVED_INBOUND_HEADER_PREFIXES}, computed once at module load.
317
+ *
318
+ * Used by {@link assertInboundHeaderGuards} as a cheap first-pass filter so
319
+ * the inner prefix loop is skipped for the overwhelming majority of header
320
+ * names. Because it is *derived* from the list (never hardcoded), adding a
321
+ * future reserved prefix that diverges simply shrinks this filter — in the
322
+ * worst case to `""`, where `String.prototype.startsWith("")` is always
323
+ * `true` and every header falls through to the full prefix scan. The fast
324
+ * path can therefore never cause a reserved header to be silently accepted.
325
+ */
326
+ const RESERVED_PREFIX_COMMON = (() => {
327
+ let common = RESERVED_INBOUND_HEADER_PREFIXES[0] ?? "";
328
+ for (const prefix of RESERVED_INBOUND_HEADER_PREFIXES) {
329
+ let i = 0;
330
+ const max = Math.min(common.length, prefix.length);
331
+ while (i < max && common.charCodeAt(i) === prefix.charCodeAt(i))
332
+ i++;
333
+ common = common.slice(0, i);
334
+ }
335
+ return common;
336
+ })();
302
337
  /**
303
338
  * Reject requests that carry any header in
304
339
  * {@link RESERVED_INBOUND_HEADER_PREFIXES}. See that constant for the
@@ -308,6 +343,10 @@ export const RESERVED_INBOUND_HEADER_PREFIXES = Object.freeze([
308
343
  * `400 problem+json` instead of routing a request that may be probing
309
344
  * for an internal-dispatch bypass.
310
345
  *
346
+ * The framework's dispatch path runs this check and the header-count cap
347
+ * in a single shared walk internally (see {@link assertInboundHeaderGuards});
348
+ * calling this helper directly is only needed for custom pipelines.
349
+ *
311
350
  * @param headers - Normalized request headers to inspect (names arrive lowercased).
312
351
  * @since 0.36.0
313
352
  */
@@ -352,6 +391,11 @@ export const DEFAULT_MAX_HEADER_COUNT = 100;
352
391
  * framework returns a structured `problem+json` response instead of routing
353
392
  * a flood.
354
393
  *
394
+ * The framework's dispatch path runs this check and the reserved-prefix
395
+ * check in a single shared walk internally (see
396
+ * {@link assertInboundHeaderGuards}); calling this helper directly is only
397
+ * needed for custom pipelines.
398
+ *
355
399
  * @param headers - The incoming request headers.
356
400
  * @param limit - Maximum distinct header fields to allow. `0` disables.
357
401
  * @since 0.38.0
@@ -370,6 +414,67 @@ export function assertHeaderCountWithinLimit(headers, limit) {
370
414
  }
371
415
  });
372
416
  }
417
+ /**
418
+ * Combined inbound header guards for the dispatch hot path (internal).
419
+ *
420
+ * Runs the singleton-duplicate check ({@link assertNoDuplicateSingletonHeaders}),
421
+ * the reserved-internal-prefix check ({@link assertNoReservedInternalHeaders}),
422
+ * and the header-count cap ({@link assertHeaderCountWithinLimit}) in a
423
+ * **single** walk of the header map, with the same observable semantics as
424
+ * calling the helpers in sequence: any `400`-class violation (duplicate
425
+ * singleton header or reserved internal header) anywhere in the map is
426
+ * rejected with `400` even when the map also exceeds the count cap. To
427
+ * preserve that precedence, the `431` count-cap rejection is deferred until
428
+ * the scan has covered every header — the same full-walk cost the
429
+ * sequential helpers already paid.
430
+ *
431
+ * A non-positive / non-finite `limit` disables the count cap (same as
432
+ * {@link assertHeaderCountWithinLimit}) while still rejecting duplicate
433
+ * singleton headers and reserved internal prefixes.
434
+ *
435
+ * @param headers - Normalized request headers (names arrive lowercased).
436
+ * @param limit - Maximum distinct header fields to allow. `0` disables the count cap.
437
+ * @throws {BadRequestError} When a duplicate singleton header or a reserved
438
+ * internal header is present (takes precedence over the count cap).
439
+ * @throws {RequestHeaderFieldsTooLargeError} When no `400`-class violation is
440
+ * present and the distinct-header count exceeds `limit`.
441
+ * @since 1.0.0
442
+ */
443
+ export function assertInboundHeaderGuards(headers, limit) {
444
+ const enforceCount = limit > 0 && Number.isFinite(limit);
445
+ let count = 0;
446
+ let overLimit = false;
447
+ // Single forEach: singleton-duplicate rejection + reserved-prefix rejection
448
+ // + distinct-name count. The 400-class checks throw immediately; the
449
+ // count-cap rejection is deferred past the walk so a violation after
450
+ // position `limit` still yields 400 (matching the sequential guards,
451
+ // where the 400-class scans ran first).
452
+ headers.forEach((value, name) => {
453
+ // Smuggling-singleton check: the WHATWG Headers collection coalesces
454
+ // duplicate fields to a comma-joined value, so "value contains a comma"
455
+ // means the client sent the header more than once (same semantics as
456
+ // assertNoDuplicateSingletonHeaders, folded into this walk to avoid
457
+ // three extra undici Headers.get() calls per request).
458
+ if (SMUGGLING_SINGLETON_SET.has(name) && value.indexOf(",") !== -1) {
459
+ throw new BadRequestError(`Duplicate ${name} header rejected`);
460
+ }
461
+ // Fast path: skip the inner prefix loop unless the name starts with the
462
+ // common prefix shared by every reserved prefix (derived at module load
463
+ // from RESERVED_INBOUND_HEADER_PREFIXES — see RESERVED_PREFIX_COMMON —
464
+ // so this filter can never silently drop a future reserved prefix).
465
+ if (name.startsWith(RESERVED_PREFIX_COMMON)) {
466
+ for (const prefix of RESERVED_INBOUND_HEADER_PREFIXES) {
467
+ if (name.startsWith(prefix)) {
468
+ throw new BadRequestError(`Reserved internal header rejected: ${name}`);
469
+ }
470
+ }
471
+ }
472
+ if (enforceCount && ++count > limit)
473
+ overLimit = true;
474
+ });
475
+ if (overLimit)
476
+ throw new RequestHeaderFieldsTooLargeError(limit);
477
+ }
373
478
  /**
374
479
  * Minimum acceptable secret length in bytes for HMAC / signing material in
375
480
  * production (boot guard). Matches the OWASP "Secret Management"
@@ -635,12 +740,8 @@ export async function verifyWebhookSignature(opts) {
635
740
  if (Math.abs(nowSeconds - timestamp.seconds) > tolerance)
636
741
  return false;
637
742
  }
638
- const payloadBytes = typeof opts.payload === "string"
639
- ? new TextEncoder().encode(opts.payload)
640
- : opts.payload;
641
- const secretBytes = typeof opts.secret === "string"
642
- ? new TextEncoder().encode(opts.secret)
643
- : opts.secret;
743
+ const payloadBytes = typeof opts.payload === "string" ? new TextEncoder().encode(opts.payload) : opts.payload;
744
+ const secretBytes = typeof opts.secret === "string" ? new TextEncoder().encode(opts.secret) : opts.secret;
644
745
  const providedBytes = decodeWebhookSignature(opts.signature, algo.name, algo.signatureBytes);
645
746
  if (!providedBytes)
646
747
  return false;
@@ -675,12 +776,8 @@ export async function signWebhookPayload(opts) {
675
776
  throw new TypeError("signWebhookPayload(): timestamp must be a non-negative integer number of seconds");
676
777
  }
677
778
  }
678
- const payloadBytes = typeof opts.payload === "string"
679
- ? new TextEncoder().encode(opts.payload)
680
- : opts.payload;
681
- const secretBytes = typeof opts.secret === "string"
682
- ? new TextEncoder().encode(opts.secret)
683
- : opts.secret;
779
+ const payloadBytes = typeof opts.payload === "string" ? new TextEncoder().encode(opts.payload) : opts.payload;
780
+ const secretBytes = typeof opts.secret === "string" ? new TextEncoder().encode(opts.secret) : opts.secret;
684
781
  const c = globalThis.crypto;
685
782
  if (!c?.subtle)
686
783
  throw new Error("WebCrypto unavailable: cannot sign webhook payload");
@@ -701,9 +798,28 @@ export async function signWebhookPayload(opts) {
701
798
  const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/g;
702
799
  const WINDOWS_RESERVED_CHARS_RE = /[<>:"|?*]/g;
703
800
  const WINDOWS_RESERVED_NAMES = new Set([
704
- "con", "prn", "aux", "nul",
705
- "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
706
- "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
801
+ "con",
802
+ "prn",
803
+ "aux",
804
+ "nul",
805
+ "com1",
806
+ "com2",
807
+ "com3",
808
+ "com4",
809
+ "com5",
810
+ "com6",
811
+ "com7",
812
+ "com8",
813
+ "com9",
814
+ "lpt1",
815
+ "lpt2",
816
+ "lpt3",
817
+ "lpt4",
818
+ "lpt5",
819
+ "lpt6",
820
+ "lpt7",
821
+ "lpt8",
822
+ "lpt9",
707
823
  ]);
708
824
  /**
709
825
  * Return a single-segment, storage-safe basename derived from a
package/dist/session.d.ts CHANGED
@@ -38,7 +38,13 @@ export declare const SESSION_HOOK_MARKER: unique symbol;
38
38
  export declare const SESSION_SECRETS_MARKER: unique symbol;
39
39
  /** A persisted session entry as stored by a {@link SessionStore}. */
40
40
  export interface SessionRecord {
41
- /** Arbitrary serializable session payload. Mutating this object marks the session dirty. */
41
+ /**
42
+ * Arbitrary JSON-serializable session payload. Mutating this object (including
43
+ * nested properties and array elements via the proxy on
44
+ * `ctx.state.session.data`) marks the session dirty so it is persisted on
45
+ * the response. Prefer plain objects/arrays/primitives; non-cloneable
46
+ * values (functions, DOM nodes) are not supported.
47
+ */
42
48
  data: Record<string, unknown>;
43
49
  /** Absolute expiration as ms since epoch. */
44
50
  expiresAt: number;
@@ -110,7 +116,12 @@ export interface SessionOptions {
110
116
  export type SessionContext = {
111
117
  /** Current session id. Refreshed by `regenerate()`. */
112
118
  readonly id: string;
113
- /** Session payload. Mutating this object marks the session dirty. */
119
+ /**
120
+ * Session payload. Top-level and nested mutations (including array element
121
+ * writes) mark the session dirty so {@link SessionStore.set} runs on the
122
+ * response. Prefer `set`/`delete` for top-level keys when you do not need
123
+ * nested objects.
124
+ */
114
125
  readonly data: Record<string, unknown>;
115
126
  /** Read a single payload key. */
116
127
  get<T = unknown>(key: string): T | undefined;
package/dist/session.js CHANGED
@@ -104,21 +104,106 @@ function sessionCookieAttributes(opts) {
104
104
  function markDirty(internal) {
105
105
  internal.dirty = true;
106
106
  }
107
- function makeSessionContext(id, data, internal, regenerate) {
108
- const proxy = new Proxy(data, {
109
- set(target, key, value) {
110
- target[key] = value;
111
- markDirty(internal);
112
- return true;
107
+ /**
108
+ * Deep-clone a session payload so store load/store never shares nested object
109
+ * references with the live request-scoped data.
110
+ *
111
+ * Prefers {@link structuredClone} (available on every supported runtime:
112
+ * Node >= 17, Deno, Bun, Workers, Vercel edge). Unlike a JSON round-trip it
113
+ * preserves `Date`, `Map`, `Set`, typed arrays, `BigInt`, and `undefined`
114
+ * property values, and it handles cyclic graphs — so a session that legitimately
115
+ * holds those values is neither silently corrupted (JSON stringifies a `Date`
116
+ * to a string and drops `undefined`) nor rejected (JSON throws on `BigInt` or a
117
+ * cycle). Falls back to the JSON round-trip only for the rare value
118
+ * `structuredClone` cannot copy (e.g. a function), which is unsupported in a
119
+ * session payload anyway.
120
+ *
121
+ * @param value - Session data tree (a plain object).
122
+ * @returns An independent deep copy.
123
+ */
124
+ function cloneSessionData(value) {
125
+ if (typeof structuredClone === "function") {
126
+ try {
127
+ return structuredClone(value);
128
+ }
129
+ catch {
130
+ // structuredClone throws on non-cloneable values (functions, symbols);
131
+ // fall through to the JSON round-trip for best-effort compatibility.
132
+ }
133
+ }
134
+ return JSON.parse(JSON.stringify(value));
135
+ }
136
+ /**
137
+ * Whether a value should be wrapped by the deep dirty-tracking proxy: only
138
+ * plain objects and arrays. Exotic objects (`Date`, `Map`, `Set`, `RegExp`,
139
+ * typed arrays, class instances) are returned unwrapped so methods that rely
140
+ * on an internal slot as `this` (e.g. `Date.prototype.getTime`) keep working —
141
+ * a proxy would break them. Reassigning such a value on its parent is still
142
+ * dirty-tracked; only in-place internal mutation of the exotic value is not.
143
+ *
144
+ * @param value - Candidate nested value.
145
+ * @returns `true` for plain objects / arrays.
146
+ */
147
+ function isProxyableContainer(value) {
148
+ if (value === null || typeof value !== "object")
149
+ return false;
150
+ if (Array.isArray(value))
151
+ return true;
152
+ const proto = Object.getPrototypeOf(value);
153
+ return proto === Object.prototype || proto === null;
154
+ }
155
+ /**
156
+ * Recursive proxy that marks the session dirty on any nested set/delete,
157
+ * including array element assignment. Nested plain objects and arrays returned
158
+ * from `get` are re-wrapped so mutations deep in the tree are observed.
159
+ *
160
+ * Proxies are memoized per underlying target in `cache` (a {@link WeakMap}), so
161
+ * reading the same nested object twice yields the *same* proxy and object
162
+ * identity is preserved (`data.user === data.user`). The cache is scoped to a
163
+ * single session context (one request), so it never leaks across requests and
164
+ * its keys are collected with their targets.
165
+ *
166
+ * @param target - Underlying plain object/array held by the session.
167
+ * @param onDirty - Invoked on every successful mutation.
168
+ * @param cache - Per-context target→proxy memo table.
169
+ * @returns A proxy with the same surface as `target`.
170
+ */
171
+ function createDeepDirtyProxy(target, onDirty, cache) {
172
+ const existing = cache.get(target);
173
+ if (existing)
174
+ return existing;
175
+ const proxy = new Proxy(target, {
176
+ get(t, prop, receiver) {
177
+ const value = Reflect.get(t, prop, receiver);
178
+ if (isProxyableContainer(value)) {
179
+ return createDeepDirtyProxy(value, onDirty, cache);
180
+ }
181
+ return value;
113
182
  },
114
- deleteProperty(target, key) {
115
- const had = key in target;
116
- delete target[key];
117
- if (had)
118
- markDirty(internal);
119
- return true;
183
+ set(t, prop, value, receiver) {
184
+ const ok = Reflect.set(t, prop, value, receiver);
185
+ if (ok)
186
+ onDirty();
187
+ return ok;
188
+ },
189
+ deleteProperty(t, prop) {
190
+ const had = Reflect.has(t, prop);
191
+ const ok = Reflect.deleteProperty(t, prop);
192
+ if (ok && had)
193
+ onDirty();
194
+ return ok;
120
195
  },
121
196
  });
197
+ cache.set(target, proxy);
198
+ return proxy;
199
+ }
200
+ function makeSessionContext(id, data, internal, regenerate) {
201
+ const onDirty = () => {
202
+ markDirty(internal);
203
+ };
204
+ // Per-request memo table so nested reads return a stable proxy identity.
205
+ const proxyCache = new WeakMap();
206
+ const proxy = createDeepDirtyProxy(data, onDirty, proxyCache);
122
207
  let currentId = id;
123
208
  return {
124
209
  get id() {
@@ -271,6 +356,7 @@ export function session(opts) {
271
356
  destroyed: false,
272
357
  regenerated: false,
273
358
  created: false,
359
+ rawData: {},
274
360
  };
275
361
  const raw = readRequestCookie(ctx.request.headers.get("cookie"), cookieName);
276
362
  internal.hadCookie = raw !== null;
@@ -287,7 +373,9 @@ export function session(opts) {
287
373
  const rec = await store.get(candidateId);
288
374
  if (rec && rec.expiresAt > Date.now()) {
289
375
  id = candidateId;
290
- data = rec.data ? { ...rec.data } : {};
376
+ // Deep-clone so nested mutations cannot mutate the store's
377
+ // retained object graph without going through dirty tracking.
378
+ data = rec.data ? cloneSessionData(rec.data) : {};
291
379
  }
292
380
  break;
293
381
  }
@@ -326,6 +414,9 @@ export function session(opts) {
326
414
  internal.originalId = null;
327
415
  return next;
328
416
  };
417
+ // Retain the raw object so onSend can deep-clone it directly.
418
+ // `regenerate` mutates this same reference, so it stays authoritative.
419
+ internal.rawData = data;
329
420
  const sessionCtx = makeSessionContext(id, data, internal, regenerate);
330
421
  const state = ctx.state;
331
422
  state[STATE_KEY] = sessionCtx;
@@ -347,8 +438,9 @@ export function session(opts) {
347
438
  }
348
439
  return undefined;
349
440
  }
350
- const sessionCtx = state[STATE_KEY];
351
- const data = sessionCtx.data;
441
+ // Clone the raw (un-proxied) data: structuredClone rejects a Proxy, and
442
+ // the proxy writes through to this object so it holds every mutation.
443
+ const data = internal.rawData;
352
444
  const sid = internal.activeId;
353
445
  const expiresAt = Date.now() + internal.ttlMs;
354
446
  // A brand-new, untouched session is a no-op unless saveUninitialized is on.
@@ -357,13 +449,15 @@ export function session(opts) {
357
449
  return undefined;
358
450
  const mustPersist = internal.dirty || internal.created || internal.regenerated;
359
451
  if (mustPersist) {
360
- await store.set(sid, { data: { ...data }, expiresAt });
452
+ // Deep-clone so nested mutations persist as an independent tree and
453
+ // never share references with the store.
454
+ await store.set(sid, { data: cloneSessionData(data), expiresAt });
361
455
  }
362
456
  else if (internal.rolling) {
363
457
  if (store.touch)
364
458
  await store.touch(sid, expiresAt);
365
459
  else
366
- await store.set(sid, { data: { ...data }, expiresAt });
460
+ await store.set(sid, { data: cloneSessionData(data), expiresAt });
367
461
  }
368
462
  if (internal.regenerated && internal.originalId && internal.originalId !== sid) {
369
463
  await store.destroy(internal.originalId);
@@ -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
@@ -161,6 +161,75 @@ function safeDecode(value) {
161
161
  return value;
162
162
  }
163
163
  }
164
+ /**
165
+ * Maximum percent-decode passes applied when expanding inspection variants.
166
+ *
167
+ * One pass matches what most HTTP stacks hand the handler. A second pass
168
+ * catches classic double-encoding WAF evasions (`%2527` → `%27` → `'`). A
169
+ * third is omitted on purpose: deeper recursive decoding inflates false
170
+ * positives on legitimately percent-bearing text and is not how frameworks
171
+ * deliver query/path values.
172
+ */
173
+ const MAX_DECODE_PASSES = 2;
174
+ /**
175
+ * Expand a single inbound string into the variants the WAF should scan.
176
+ *
177
+ * Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
178
+ * a `+`→space form (URLSearchParams parity), and a SQL-comment-stripped
179
+ * form so comment-split keywords (e.g. OR wrapped in block comments) score
180
+ * the same as the whitespace-separated form.
181
+ *
182
+ * Scanning variants is pure defense-in-depth: the handler still receives
183
+ * whatever the framework's single-decode path produced. Each variant is
184
+ * truncated to `maxValueLength` and deduplicated so hostile inputs cannot
185
+ * explode the scan set.
186
+ *
187
+ * @param value - Raw or already-decoded string from path/query/header/body.
188
+ * @param maxValueLength - Cap applied to every variant before scanning.
189
+ * @returns Deduplicated inspection variants in stable insertion order.
190
+ */
191
+ function inspectionVariants(value, maxValueLength) {
192
+ const seen = new Set();
193
+ const out = [];
194
+ const push = (v) => {
195
+ const truncated = v.length > maxValueLength ? v.slice(0, maxValueLength) : v;
196
+ if (!seen.has(truncated)) {
197
+ seen.add(truncated);
198
+ out.push(truncated);
199
+ }
200
+ };
201
+ let current = value;
202
+ push(current);
203
+ for (let i = 0; i < MAX_DECODE_PASSES; i++) {
204
+ const decoded = safeDecode(current);
205
+ if (decoded === current)
206
+ break;
207
+ push(decoded);
208
+ current = decoded;
209
+ }
210
+ // Snapshot before secondary transforms so we only expand the decode chain.
211
+ const decodedChain = out.slice();
212
+ for (const v of decodedChain) {
213
+ if (v.includes("+"))
214
+ push(v.replace(/\+/g, " "));
215
+ if (v.includes("/*"))
216
+ push(v.replace(/\/\*[\s\S]*?\*\//g, " "));
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * Scan every inspection variant of `value` for the active rule set.
222
+ *
223
+ * @see inspectionVariants
224
+ */
225
+ function scanValueVariants(value, location, rules, scored, maxValueLength) {
226
+ for (const variant of inspectionVariants(value, maxValueLength)) {
227
+ scanValue(variant, location, rules, scored);
228
+ // Early exit once every rule has already fired — no further variants needed.
229
+ if (scored.size === rules.length)
230
+ return;
231
+ }
232
+ }
164
233
  /**
165
234
  * Collect up to `maxNodes` string values from a parsed body value (object /
166
235
  * array / scalar), each truncated to `maxValueLength`. Depth and node count are
@@ -268,37 +337,29 @@ export function waf(opts = {}) {
268
337
  const scored = new Map();
269
338
  const url = new URL(ctx.request.url);
270
339
  if (inspectPath) {
271
- scanValue(safeDecode(url.pathname), "path", rules, scored);
340
+ // Path is scanned across raw + up to two decode passes so double-
341
+ // encoded traversal / injection tokens in path segments still score.
342
+ scanValueVariants(url.pathname, "path", rules, scored, maxValueLength);
272
343
  }
273
344
  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".
345
+ // Scan the raw query, bounded multi-decode variants, and each
346
+ // URLSearchParams key/value. Multi-decode (max 2) closes classic
347
+ // double-encoding WAF evasions (`%2527` `%27` `'`) without open-
348
+ // ended recursive decoding. URLSearchParams also turns `+` into
349
+ // space; inspectionVariants covers that form so `1+OR+1=1` scores
350
+ // the same as `1 OR 1=1` (parser-differential defense).
282
351
  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).
352
+ scanValueVariants(raw, "query", rules, scored, maxValueLength);
292
353
  for (const [k, v] of url.searchParams) {
293
- scanValue(k, "query", rules, scored);
294
- scanValue(v, "query", rules, scored);
354
+ scanValueVariants(k, "query", rules, scored, maxValueLength);
355
+ scanValueVariants(v, "query", rules, scored, maxValueLength);
295
356
  }
296
357
  }
297
358
  if (headerAllowlist.length > 0) {
298
359
  for (const name of headerAllowlist) {
299
360
  const value = ctx.request.headers.get(name);
300
361
  if (value)
301
- scanValue(value, "header", rules, scored);
362
+ scanValueVariants(value, "header", rules, scored, maxValueLength);
302
363
  }
303
364
  }
304
365
  if (inspectBody && ctx.body !== undefined && ctx.body !== null) {
@@ -316,14 +377,13 @@ export function waf(opts = {}) {
316
377
  });
317
378
  }
318
379
  if (typeof ctx.body === "string") {
319
- scanValue(ctx.body.length > maxValueLength
320
- ? ctx.body.slice(0, maxValueLength)
321
- : ctx.body, "body", rules, scored);
380
+ scanValueVariants(ctx.body, "body", rules, scored, maxValueLength);
322
381
  }
323
382
  else if (typeof ctx.body === "object") {
324
383
  const strings = collectBodyStrings(ctx.body, maxBodyNodes, maxValueLength);
325
- for (const value of strings)
326
- scanValue(value, "body", rules, scored);
384
+ for (const value of strings) {
385
+ scanValueVariants(value, "body", rules, scored, maxValueLength);
386
+ }
327
387
  }
328
388
  }
329
389
  if (scored.size === 0)
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.5",
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,9 +249,10 @@
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
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
258
  "red-team:live": "node --import tsx red-team-live/run.ts",