@daloyjs/core 1.0.0-rc.3 → 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 +103 -41
- 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 +131 -11
- package/dist/app.js +305 -217
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +41 -1
- package/dist/client.d.ts +64 -18
- package/dist/client.js +36 -6
- package/dist/combine.d.ts +11 -11
- package/dist/combine.js +90 -47
- 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/docs.d.ts +5 -9
- package/dist/docs.js +36 -14
- 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/idempotency.js +2 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/internal-response.d.ts +15 -0
- package/dist/internal-response.js +27 -0
- package/dist/jwk.d.ts +11 -7
- package/dist/jwk.js +11 -7
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +137 -0
- package/dist/mcp.js +21 -15
- package/dist/middleware.d.ts +48 -7
- package/dist/middleware.js +129 -43
- package/dist/mtls.d.ts +6 -5
- package/dist/mtls.js +8 -9
- package/dist/openapi.js +1 -1
- package/dist/pagination.js +4 -1
- package/dist/response-cache.js +2 -1
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +9 -2
- package/dist/safe-redirect.js +29 -4
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +62 -0
- package/dist/security.js +220 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/tenancy.d.ts +2 -2
- package/dist/time-claims.js +3 -1
- package/dist/types.d.ts +85 -20
- package/dist/types.js +16 -1
- package/dist/waf.js +86 -26
- package/package.json +11 -4
package/dist/security.d.ts
CHANGED
|
@@ -141,6 +141,11 @@ export declare const SMUGGLING_SINGLETON_HEADERS: readonly string[];
|
|
|
141
141
|
* Throws {@link BadRequestError} so the framework returns a structured
|
|
142
142
|
* `400 problem+json` instead of forwarding a smuggling-class request.
|
|
143
143
|
*
|
|
144
|
+
* The framework's dispatch path runs this check, the reserved-prefix check,
|
|
145
|
+
* and the header-count cap in a single shared walk internally (see
|
|
146
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
147
|
+
* needed for custom pipelines.
|
|
148
|
+
*
|
|
144
149
|
* @param headers - Normalized request headers to inspect.
|
|
145
150
|
* @since 0.15.0
|
|
146
151
|
*/
|
|
@@ -179,6 +184,10 @@ export declare const RESERVED_INBOUND_HEADER_PREFIXES: readonly string[];
|
|
|
179
184
|
* `400 problem+json` instead of routing a request that may be probing
|
|
180
185
|
* for an internal-dispatch bypass.
|
|
181
186
|
*
|
|
187
|
+
* The framework's dispatch path runs this check and the header-count cap
|
|
188
|
+
* in a single shared walk internally (see {@link assertInboundHeaderGuards});
|
|
189
|
+
* calling this helper directly is only needed for custom pipelines.
|
|
190
|
+
*
|
|
182
191
|
* @param headers - Normalized request headers to inspect (names arrive lowercased).
|
|
183
192
|
* @since 0.36.0
|
|
184
193
|
*/
|
|
@@ -212,11 +221,43 @@ export declare const DEFAULT_MAX_HEADER_COUNT = 100;
|
|
|
212
221
|
* framework returns a structured `problem+json` response instead of routing
|
|
213
222
|
* a flood.
|
|
214
223
|
*
|
|
224
|
+
* The framework's dispatch path runs this check and the reserved-prefix
|
|
225
|
+
* check in a single shared walk internally (see
|
|
226
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
227
|
+
* needed for custom pipelines.
|
|
228
|
+
*
|
|
215
229
|
* @param headers - The incoming request headers.
|
|
216
230
|
* @param limit - Maximum distinct header fields to allow. `0` disables.
|
|
217
231
|
* @since 0.38.0
|
|
218
232
|
*/
|
|
219
233
|
export declare function assertHeaderCountWithinLimit(headers: Headers, limit: number): void;
|
|
234
|
+
/**
|
|
235
|
+
* Combined inbound header guards for the dispatch hot path (internal).
|
|
236
|
+
*
|
|
237
|
+
* Runs the singleton-duplicate check ({@link assertNoDuplicateSingletonHeaders}),
|
|
238
|
+
* the reserved-internal-prefix check ({@link assertNoReservedInternalHeaders}),
|
|
239
|
+
* and the header-count cap ({@link assertHeaderCountWithinLimit}) in a
|
|
240
|
+
* **single** walk of the header map, with the same observable semantics as
|
|
241
|
+
* calling the helpers in sequence: any `400`-class violation (duplicate
|
|
242
|
+
* singleton header or reserved internal header) anywhere in the map is
|
|
243
|
+
* rejected with `400` even when the map also exceeds the count cap. To
|
|
244
|
+
* preserve that precedence, the `431` count-cap rejection is deferred until
|
|
245
|
+
* the scan has covered every header — the same full-walk cost the
|
|
246
|
+
* sequential helpers already paid.
|
|
247
|
+
*
|
|
248
|
+
* A non-positive / non-finite `limit` disables the count cap (same as
|
|
249
|
+
* {@link assertHeaderCountWithinLimit}) while still rejecting duplicate
|
|
250
|
+
* singleton headers and reserved internal prefixes.
|
|
251
|
+
*
|
|
252
|
+
* @param headers - Normalized request headers (names arrive lowercased).
|
|
253
|
+
* @param limit - Maximum distinct header fields to allow. `0` disables the count cap.
|
|
254
|
+
* @throws {BadRequestError} When a duplicate singleton header or a reserved
|
|
255
|
+
* internal header is present (takes precedence over the count cap).
|
|
256
|
+
* @throws {RequestHeaderFieldsTooLargeError} When no `400`-class violation is
|
|
257
|
+
* present and the distinct-header count exceeds `limit`.
|
|
258
|
+
* @since 1.0.0
|
|
259
|
+
*/
|
|
260
|
+
export declare function assertInboundHeaderGuards(headers: Headers, limit: number): void;
|
|
220
261
|
/**
|
|
221
262
|
* Minimum acceptable secret length in bytes for HMAC / signing material in
|
|
222
263
|
* production (boot guard). Matches the OWASP "Secret Management"
|
|
@@ -460,3 +501,24 @@ export declare function hasMongoOperatorKeys(value: unknown): boolean;
|
|
|
460
501
|
* @since 0.35.0
|
|
461
502
|
*/
|
|
462
503
|
export declare function assertNoMongoOperators(value: unknown): void;
|
|
504
|
+
/**
|
|
505
|
+
* Like {@link safeJsonParse}, but additionally enforces structural limits
|
|
506
|
+
* (total object keys across the tree and maximum nesting depth) to defend
|
|
507
|
+
* against wide-object / hash-flood and deep-nesting DoS payloads that stay
|
|
508
|
+
* under the byte cap.
|
|
509
|
+
*
|
|
510
|
+
* The limits are checked with a single pre-parse text scan
|
|
511
|
+
* ({@link assertJsonTextStructure}), so an oversized structure is rejected
|
|
512
|
+
* *before* it is parsed or allocated. On success the text is handed to
|
|
513
|
+
* {@link safeJsonParse}, which applies the same prototype-pollution stripping
|
|
514
|
+
* as every other body parser — so the security posture is identical, only the
|
|
515
|
+
* structural bounds are added.
|
|
516
|
+
*
|
|
517
|
+
* @param text - JSON text. Empty string returns `undefined`.
|
|
518
|
+
* @param maxKeys - Maximum total object keys (0 or negative = unlimited).
|
|
519
|
+
* @param maxDepth - Maximum nesting depth (0 or negative = unlimited).
|
|
520
|
+
* @returns Parsed value (with dangerous keys stripped).
|
|
521
|
+
* @throws {BadRequestError} on invalid JSON or when limits are exceeded.
|
|
522
|
+
* @since 1.0.0
|
|
523
|
+
*/
|
|
524
|
+
export declare function safeJsonParseLimited(text: string, maxKeys?: number, maxDepth?: number): unknown;
|
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
|
|
@@ -876,3 +992,92 @@ export function assertNoMongoOperators(value) {
|
|
|
876
992
|
throw new BadRequestError("Operator-prefixed key rejected");
|
|
877
993
|
}
|
|
878
994
|
}
|
|
995
|
+
/**
|
|
996
|
+
* Enforces structural limits by scanning raw JSON *text* in a single
|
|
997
|
+
* allocation-free pass, before the value is parsed — so a wide-object or
|
|
998
|
+
* deep-nesting bomb is rejected without ever being materialized into memory.
|
|
999
|
+
*
|
|
1000
|
+
* Object keys are counted via `:` delimiters that sit outside string literals:
|
|
1001
|
+
* every JSON object member is `"key": value`, and array elements carry no
|
|
1002
|
+
* colon, so the number of structural colons equals the total object-key count
|
|
1003
|
+
* across the whole tree. Nesting depth tracks the running `{`/`[` … `}`/`]`
|
|
1004
|
+
* balance, again ignoring characters inside strings. Both checks short-circuit
|
|
1005
|
+
* the instant a limit is exceeded, giving bounded-time rejection.
|
|
1006
|
+
*
|
|
1007
|
+
* This mirrors the accounting of the older recursive object walk but costs one
|
|
1008
|
+
* tight character loop instead of a second full traversal of the parsed graph,
|
|
1009
|
+
* keeping the common (small-body) path close to a bare `JSON.parse`.
|
|
1010
|
+
*
|
|
1011
|
+
* @param text - The raw JSON text (already known to be non-empty).
|
|
1012
|
+
* @param maxKeys - Maximum total object keys (`<= 0` disables the key check).
|
|
1013
|
+
* @param maxDepth - Maximum nesting depth (`<= 0` disables the depth check).
|
|
1014
|
+
* @throws {BadRequestError} When the key or depth limit is exceeded.
|
|
1015
|
+
* @internal
|
|
1016
|
+
*/
|
|
1017
|
+
function assertJsonTextStructure(text, maxKeys, maxDepth) {
|
|
1018
|
+
let depth = 0;
|
|
1019
|
+
let keyCount = 0;
|
|
1020
|
+
let inString = false;
|
|
1021
|
+
let escaped = false;
|
|
1022
|
+
for (let i = 0; i < text.length; i++) {
|
|
1023
|
+
const c = text.charCodeAt(i);
|
|
1024
|
+
if (inString) {
|
|
1025
|
+
if (escaped)
|
|
1026
|
+
escaped = false;
|
|
1027
|
+
else if (c === 0x5c /* \ */)
|
|
1028
|
+
escaped = true;
|
|
1029
|
+
else if (c === 0x22 /* " */)
|
|
1030
|
+
inString = false;
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
switch (c) {
|
|
1034
|
+
case 0x22 /* " */:
|
|
1035
|
+
inString = true;
|
|
1036
|
+
break;
|
|
1037
|
+
case 0x7b /* { */:
|
|
1038
|
+
case 0x5b /* [ */:
|
|
1039
|
+
depth++;
|
|
1040
|
+
if (maxDepth > 0 && depth > maxDepth) {
|
|
1041
|
+
throw new BadRequestError("JSON exceeds maximum nesting depth");
|
|
1042
|
+
}
|
|
1043
|
+
break;
|
|
1044
|
+
case 0x7d /* } */:
|
|
1045
|
+
case 0x5d /* ] */:
|
|
1046
|
+
depth--;
|
|
1047
|
+
break;
|
|
1048
|
+
case 0x3a /* : */:
|
|
1049
|
+
if (maxKeys > 0 && ++keyCount > maxKeys) {
|
|
1050
|
+
throw new BadRequestError("JSON exceeds maximum key count");
|
|
1051
|
+
}
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Like {@link safeJsonParse}, but additionally enforces structural limits
|
|
1058
|
+
* (total object keys across the tree and maximum nesting depth) to defend
|
|
1059
|
+
* against wide-object / hash-flood and deep-nesting DoS payloads that stay
|
|
1060
|
+
* under the byte cap.
|
|
1061
|
+
*
|
|
1062
|
+
* The limits are checked with a single pre-parse text scan
|
|
1063
|
+
* ({@link assertJsonTextStructure}), so an oversized structure is rejected
|
|
1064
|
+
* *before* it is parsed or allocated. On success the text is handed to
|
|
1065
|
+
* {@link safeJsonParse}, which applies the same prototype-pollution stripping
|
|
1066
|
+
* as every other body parser — so the security posture is identical, only the
|
|
1067
|
+
* structural bounds are added.
|
|
1068
|
+
*
|
|
1069
|
+
* @param text - JSON text. Empty string returns `undefined`.
|
|
1070
|
+
* @param maxKeys - Maximum total object keys (0 or negative = unlimited).
|
|
1071
|
+
* @param maxDepth - Maximum nesting depth (0 or negative = unlimited).
|
|
1072
|
+
* @returns Parsed value (with dangerous keys stripped).
|
|
1073
|
+
* @throws {BadRequestError} on invalid JSON or when limits are exceeded.
|
|
1074
|
+
* @since 1.0.0
|
|
1075
|
+
*/
|
|
1076
|
+
export function safeJsonParseLimited(text, maxKeys = 10_000, maxDepth = 50) {
|
|
1077
|
+
if (text.length === 0)
|
|
1078
|
+
return undefined;
|
|
1079
|
+
if (maxKeys > 0 || maxDepth > 0) {
|
|
1080
|
+
assertJsonTextStructure(text, maxKeys, maxDepth);
|
|
1081
|
+
}
|
|
1082
|
+
return safeJsonParse(text);
|
|
1083
|
+
}
|
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/tenancy.d.ts
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
*
|
|
49
49
|
* @since 0.42.0
|
|
50
50
|
*/
|
|
51
|
-
import type { BaseContext, Hooks } from "./types.js";
|
|
51
|
+
import type { BaseContext, Hooks, PreBodyContext } from "./types.js";
|
|
52
52
|
/**
|
|
53
53
|
* Resolves a raw (un-normalized) tenant id from a request, or a nullish value
|
|
54
54
|
* when this strategy cannot determine one. Resolvers are tried in order and
|
|
@@ -249,4 +249,4 @@ export interface TenantScopeOptions {
|
|
|
249
249
|
* @returns A key function suitable for `keyGenerator` / `scope`.
|
|
250
250
|
* @since 0.42.0
|
|
251
251
|
*/
|
|
252
|
-
export declare function tenantScope(opts?: TenantScopeOptions): (ctx: BaseContext<any, any>) => string;
|
|
252
|
+
export declare function tenantScope(opts?: TenantScopeOptions): (ctx: BaseContext<any, any> | PreBodyContext<any>) => string;
|
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
|
}
|