@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/README.md +14 -12
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +41 -5
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +104 -19
- package/dist/app.d.ts +25 -3
- package/dist/app.js +113 -39
- package/dist/bot-guard.js +30 -3
- package/dist/client.d.ts +36 -7
- package/dist/client.js +7 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +12 -3
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +13 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +137 -0
- package/dist/mcp.js +10 -9
- package/dist/middleware.js +33 -3
- package/dist/mtls.js +6 -1
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +5 -1
- package/dist/safe-redirect.js +25 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +41 -0
- package/dist/security.js +131 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/time-claims.js +3 -1
- package/dist/waf.js +86 -26
- package/package.json +5 -4
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
|
-
|
|
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
|
-
|
|
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",
|
|
705
|
-
"
|
|
706
|
-
"
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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:
|
|
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);
|
package/dist/time-claims.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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": "^
|
|
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",
|