@daloyjs/core 1.0.0-rc.3 → 1.0.0-rc.4
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 +90 -30
- package/dist/app.d.ts +106 -8
- package/dist/app.js +193 -179
- package/dist/cli.js +41 -1
- package/dist/client.d.ts +28 -11
- package/dist/client.js +29 -6
- package/dist/combine.d.ts +11 -11
- package/dist/combine.js +90 -47
- package/dist/docs.d.ts +5 -9
- package/dist/docs.js +36 -14
- package/dist/idempotency.js +2 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- 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/mcp.js +11 -6
- package/dist/middleware.d.ts +48 -7
- package/dist/middleware.js +96 -40
- package/dist/mtls.d.ts +6 -5
- package/dist/mtls.js +3 -9
- package/dist/openapi.js +1 -1
- package/dist/pagination.js +4 -1
- package/dist/response-cache.js +2 -1
- package/dist/safe-redirect.d.ts +4 -1
- package/dist/safe-redirect.js +4 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +21 -0
- package/dist/security.js +89 -0
- package/dist/tenancy.d.ts +2 -2
- package/dist/types.d.ts +85 -20
- package/dist/types.js +16 -1
- package/package.json +7 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responses reconstructed by first-party cache/idempotency middleware from a
|
|
3
|
+
* previously finalized response. The original response already crossed the
|
|
4
|
+
* route's schema-validation boundary, so replaying its stored bytes does not
|
|
5
|
+
* create the opaque-success bypass guarded by `App`.
|
|
6
|
+
*/
|
|
7
|
+
const schemaValidatedResponses = new WeakSet();
|
|
8
|
+
/**
|
|
9
|
+
* Mark a framework-generated replay as having crossed response validation.
|
|
10
|
+
* This is an internal capability and is not exported from the package barrel.
|
|
11
|
+
*
|
|
12
|
+
* @param response - Reconstructed response containing previously validated bytes.
|
|
13
|
+
* @returns The same response for allocation-free call-site composition.
|
|
14
|
+
*/
|
|
15
|
+
export function markSchemaValidatedResponse(response) {
|
|
16
|
+
schemaValidatedResponses.add(response);
|
|
17
|
+
return response;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Test whether a response is a trusted first-party replay of validated bytes.
|
|
21
|
+
*
|
|
22
|
+
* @param response - Hook response being considered for fail-closed handling.
|
|
23
|
+
* @returns `true` only for responses marked by first-party replay middleware.
|
|
24
|
+
*/
|
|
25
|
+
export function isSchemaValidatedResponse(response) {
|
|
26
|
+
return schemaValidatedResponses.has(response);
|
|
27
|
+
}
|
package/dist/jwk.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type JwtAlgorithm } from "./jwt.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { Hooks, PreBodyContext } from "./types.js";
|
|
3
3
|
/** Asymmetric algorithms accepted by {@link jwk}. */
|
|
4
4
|
export type JwkAlgorithm = Exclude<JwtAlgorithm, "HS256" | "HS384" | "HS512">;
|
|
5
5
|
/** Minimal JWKS document shape (RFC 7517 §5). */
|
|
@@ -12,8 +12,12 @@ export interface JwkSet {
|
|
|
12
12
|
* `https://` URL (fetched with TTL caching), or a custom async resolver.
|
|
13
13
|
*/
|
|
14
14
|
export type JwkSource = JwkSet | string | (() => JwkSet | Promise<JwkSet>);
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Per-request payload-revalidation hook run before request-body I/O.
|
|
17
|
+
* The supplied {@link PreBodyContext} exposes raw headers/query/params and an
|
|
18
|
+
* always-`undefined` body.
|
|
19
|
+
*/
|
|
20
|
+
export type JwkVerifyHook = (payload: Record<string, unknown>, ctx: PreBodyContext<any>) => boolean | void | Promise<boolean | void>;
|
|
17
21
|
/**
|
|
18
22
|
* Options for {@link jwk}: the JWKS source and asymmetric algorithm allowlist
|
|
19
23
|
* are required; issuer / audience / clock-skew checks, JWKS fetch caching,
|
|
@@ -61,10 +65,10 @@ export interface JwkOptions {
|
|
|
61
65
|
*/
|
|
62
66
|
fetch?: typeof fetch;
|
|
63
67
|
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
* Optional per-request revalidation hook. Returning `false` rejects the
|
|
69
|
+
* request with `403`; returning `true` or `undefined` accepts. Use for
|
|
70
|
+
* revocation lists / token-version counters / "user changed password since
|
|
71
|
+
* this JWT was issued". Runs before request-body I/O.
|
|
68
72
|
*
|
|
69
73
|
* @since 0.22.0
|
|
70
74
|
*/
|
package/dist/jwk.js
CHANGED
|
@@ -30,9 +30,15 @@
|
|
|
30
30
|
import { ForbiddenError } from "./errors.js";
|
|
31
31
|
import { createJwtVerifier, JwtError, } from "./jwt.js";
|
|
32
32
|
const ALLOWED_JWK_ALGS = new Set([
|
|
33
|
-
"RS256",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
33
|
+
"RS256",
|
|
34
|
+
"RS384",
|
|
35
|
+
"RS512",
|
|
36
|
+
"PS256",
|
|
37
|
+
"PS384",
|
|
38
|
+
"PS512",
|
|
39
|
+
"ES256",
|
|
40
|
+
"ES384",
|
|
41
|
+
"ES512",
|
|
36
42
|
"EdDSA",
|
|
37
43
|
]);
|
|
38
44
|
function unauthorized(realm, errorCode, description) {
|
|
@@ -59,9 +65,7 @@ function sanitizeAuthParam(value) {
|
|
|
59
65
|
return value.replace(/[\u0000-\u001f\u007f"\\]/g, "");
|
|
60
66
|
}
|
|
61
67
|
function isJwkSet(value) {
|
|
62
|
-
return (typeof value === "object" &&
|
|
63
|
-
value !== null &&
|
|
64
|
-
Array.isArray(value.keys));
|
|
68
|
+
return (typeof value === "object" && value !== null && Array.isArray(value.keys));
|
|
65
69
|
}
|
|
66
70
|
function findJwkByKid(jwks, kid) {
|
|
67
71
|
for (const k of jwks.keys) {
|
|
@@ -236,7 +240,7 @@ export function jwk(opts) {
|
|
|
236
240
|
return cachedVerifier;
|
|
237
241
|
}
|
|
238
242
|
const authHooks = {
|
|
239
|
-
async
|
|
243
|
+
async preBody(ctx) {
|
|
240
244
|
const header = ctx.request.headers.get("authorization") ?? "";
|
|
241
245
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
242
246
|
if (!match) {
|
package/dist/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { safeJsonParseLimited } from "./security.js";
|
|
2
2
|
/**
|
|
3
3
|
* Latest MCP protocol version DaloyJS negotiates by default.
|
|
4
4
|
*
|
|
@@ -693,11 +693,10 @@ export function createMcpHandler(options) {
|
|
|
693
693
|
}
|
|
694
694
|
let message;
|
|
695
695
|
try {
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
|
|
700
|
-
message = safeJsonParse(raw);
|
|
696
|
+
// Use the limited parser (proto stripping + key/depth bounds) so an
|
|
697
|
+
// untrusted MCP client cannot DoS us with wide or deeply-nested JSON-RPC
|
|
698
|
+
// payloads, even within the MCP body cap. Matches the REST body parsers.
|
|
699
|
+
message = safeJsonParseLimited(raw);
|
|
701
700
|
}
|
|
702
701
|
catch {
|
|
703
702
|
return rpcError(null, PARSE_ERROR, "Invalid JSON in request body.", undefined, 400, headers);
|
|
@@ -789,6 +788,10 @@ export function mcpRoutes(path, handler, options = {}) {
|
|
|
789
788
|
path,
|
|
790
789
|
operationId: "mcpPost",
|
|
791
790
|
summary: "MCP Streamable HTTP endpoint",
|
|
791
|
+
// The transport handler owns JSON-RPC serialization and may also emit
|
|
792
|
+
// empty/streaming responses, so its web-standard Response is
|
|
793
|
+
// intentionally opaque to Daloy's response serializer.
|
|
794
|
+
acknowledgeNoResponseBodySchema: true,
|
|
792
795
|
responses,
|
|
793
796
|
handler: ({ request }) => handler(request),
|
|
794
797
|
},
|
|
@@ -797,6 +800,7 @@ export function mcpRoutes(path, handler, options = {}) {
|
|
|
797
800
|
path,
|
|
798
801
|
operationId: "mcpGet",
|
|
799
802
|
summary: "MCP Streamable HTTP discovery hint",
|
|
803
|
+
acknowledgeNoResponseBodySchema: true,
|
|
800
804
|
responses,
|
|
801
805
|
handler: ({ request }) => handler(request),
|
|
802
806
|
},
|
|
@@ -805,6 +809,7 @@ export function mcpRoutes(path, handler, options = {}) {
|
|
|
805
809
|
path,
|
|
806
810
|
operationId: "mcpOptions",
|
|
807
811
|
summary: "MCP Streamable HTTP preflight",
|
|
812
|
+
acknowledgeNoResponseBodySchema: true,
|
|
808
813
|
responses,
|
|
809
814
|
handler: ({ request }) => handler(request),
|
|
810
815
|
},
|
package/dist/middleware.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* All middlewares return `Hooks` objects so they compose with `app.use(...)`,
|
|
5
5
|
* groups, and per-route hooks identically.
|
|
6
6
|
*/
|
|
7
|
-
import type { Hooks, BaseContext } from "./types.js";
|
|
7
|
+
import type { Hooks, BaseContext, PreBodyContext } from "./types.js";
|
|
8
8
|
import { timingSafeEqual } from "./security.js";
|
|
9
9
|
/** Options for {@link requestId}. */
|
|
10
10
|
export interface RequestIdOptions {
|
|
@@ -327,6 +327,23 @@ export declare const CSRF_HOOK_MARKER: unique symbol;
|
|
|
327
327
|
* @since 1.0.0
|
|
328
328
|
*/
|
|
329
329
|
export declare const AUTH_HOOK_MARKER: unique symbol;
|
|
330
|
+
/**
|
|
331
|
+
* Internal list of request-budget hooks that must run when a later `preBody`
|
|
332
|
+
* guard rejects. This preserves registration order for `rateLimit()` before
|
|
333
|
+
* authentication without moving body-aware middleware ahead of validation.
|
|
334
|
+
*
|
|
335
|
+
* @internal
|
|
336
|
+
*/
|
|
337
|
+
export declare const EARLY_REJECTION_HOOK_MARKER: unique symbol;
|
|
338
|
+
/**
|
|
339
|
+
* Compose `preBody` hooks while honoring request-budget hooks registered
|
|
340
|
+
* before the guard that rejects. Used internally by App and hook combinators.
|
|
341
|
+
*
|
|
342
|
+
* @param layers - Hook layers in registration order.
|
|
343
|
+
* @returns A composed `preBody` hook, or `undefined` when no layer has one.
|
|
344
|
+
* @internal
|
|
345
|
+
*/
|
|
346
|
+
export declare function _mergePreBodyWithEarlyRejections(layers: Hooks[]): NonNullable<Hooks["preBody"]> | undefined;
|
|
330
347
|
/**
|
|
331
348
|
* Mark a custom {@link Hooks} bundle as performing request authentication.
|
|
332
349
|
*
|
|
@@ -343,7 +360,7 @@ export declare const AUTH_HOOK_MARKER: unique symbol;
|
|
|
343
360
|
* @example
|
|
344
361
|
* ```ts
|
|
345
362
|
* app.use(markAuthHook({
|
|
346
|
-
* async
|
|
363
|
+
* async preBody(ctx) {
|
|
347
364
|
* if (!(await myVerify(ctx.request))) throw new UnauthorizedError();
|
|
348
365
|
* },
|
|
349
366
|
* }));
|
|
@@ -426,14 +443,27 @@ export interface RateLimitStore {
|
|
|
426
443
|
resetMs: number;
|
|
427
444
|
}>;
|
|
428
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Context accepted by rate-limit key generators. It is validated on the
|
|
448
|
+
* ordinary path, but may be a pre-body context when a limiter registered
|
|
449
|
+
* before authentication counts an early credential rejection.
|
|
450
|
+
*
|
|
451
|
+
* @since 1.0.0
|
|
452
|
+
*/
|
|
453
|
+
export type RateLimitContext = PreBodyContext<any> | BaseContext<any, any>;
|
|
429
454
|
/** Options for {@link rateLimit}. */
|
|
430
455
|
export interface RateLimitOptions {
|
|
431
456
|
/** Rolling-window width in milliseconds (e.g. `60_000` for one minute). */
|
|
432
457
|
windowMs: number;
|
|
433
458
|
/** Maximum allowed requests per `windowMs` per key. */
|
|
434
459
|
max: number;
|
|
435
|
-
/**
|
|
436
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Derive the bucket key from `ctx`. Default returns `"global"`. When the
|
|
462
|
+
* limiter precedes `preBody` authentication, it also receives failed
|
|
463
|
+
* attempts before body I/O; rely only on the raw request and state populated
|
|
464
|
+
* by earlier `preBody` hooks.
|
|
465
|
+
*/
|
|
466
|
+
keyGenerator?: (ctx: RateLimitContext) => string;
|
|
437
467
|
/** Custom backend (default: shared in-memory store). */
|
|
438
468
|
store?: RateLimitStore;
|
|
439
469
|
/**
|
|
@@ -483,6 +513,11 @@ export declare function _resetSharedRateLimitStoresForTests(): void;
|
|
|
483
513
|
* `X-Forwarded-For` / `X-Real-IP` when behind a trusted proxy, or supply a
|
|
484
514
|
* custom `keyGenerator` (e.g. derive from the authenticated user id).
|
|
485
515
|
*
|
|
516
|
+
* Registration order remains security-significant: a limiter placed before a
|
|
517
|
+
* `preBody` auth hook counts rejected credentials and can replace the later
|
|
518
|
+
* `401` with `429`, without consuming the request body. On ordinary accepted
|
|
519
|
+
* requests it retains the validated `beforeHandle` timing.
|
|
520
|
+
*
|
|
486
521
|
* @example
|
|
487
522
|
* ```ts
|
|
488
523
|
* import { rateLimit } from "@daloyjs/core";
|
|
@@ -508,7 +543,7 @@ export interface LoginThrottleOptions {
|
|
|
508
543
|
/** Shared bucket id. Default: `"login"`. */
|
|
509
544
|
groupId?: string;
|
|
510
545
|
/** Derive the caller key. Defaults to trusted proxy headers only when enabled. */
|
|
511
|
-
keyGenerator?: (ctx:
|
|
546
|
+
keyGenerator?: (ctx: RateLimitContext) => string;
|
|
512
547
|
/** Shared store for the hard limit. Uses rateLimit()'s in-memory group bucket by default. */
|
|
513
548
|
store?: RateLimitStore;
|
|
514
549
|
/** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false. */
|
|
@@ -530,6 +565,8 @@ export interface LoginThrottleOptions {
|
|
|
530
565
|
* Mount the same `loginThrottle()` instance (or multiple instances with the
|
|
531
566
|
* same `groupId`) across related routes so an attacker cannot bypass the limit
|
|
532
567
|
* by rotating between password, OTP, and reset endpoints.
|
|
568
|
+
* When registered before `preBody` authentication it counts and progressively
|
|
569
|
+
* delays rejected credentials without consuming a declared request body.
|
|
533
570
|
*
|
|
534
571
|
* @param opts - Throttle tuning (see {@link LoginThrottleOptions}); every field has a safe default.
|
|
535
572
|
* @returns A {@link Hooks} bundle ready for `app.use(...)` or per-route `hooks`.
|
|
@@ -588,7 +625,7 @@ export declare function timing(headerName?: string): Hooks;
|
|
|
588
625
|
*
|
|
589
626
|
* @since 0.22.0
|
|
590
627
|
*/
|
|
591
|
-
export type BearerAuthVerifyHook<TCredentials = string> = (credentials: TCredentials, ctx:
|
|
628
|
+
export type BearerAuthVerifyHook<TCredentials = string> = (credentials: TCredentials, ctx: PreBodyContext<any>) => boolean | void | Promise<boolean | void>;
|
|
592
629
|
/** Options for {@link bearerAuth}. */
|
|
593
630
|
export interface BearerAuthOptions {
|
|
594
631
|
/** Cheap, stateless token check (signature / format). */
|
|
@@ -616,6 +653,8 @@ export interface BearerAuthOptions {
|
|
|
616
653
|
* optional `verify` hook is the integration point for revocation
|
|
617
654
|
* lists, token-version counters, and other per-request invalidation checks
|
|
618
655
|
* that `validate` cannot answer statelessly.
|
|
656
|
+
* Both checks run before request-body I/O; `verify` receives a
|
|
657
|
+
* {@link PreBodyContext} whose `body` is always `undefined`.
|
|
619
658
|
*
|
|
620
659
|
* @example
|
|
621
660
|
* ```ts
|
|
@@ -757,13 +796,15 @@ export interface BasicAuthOptions {
|
|
|
757
796
|
* after the framework has stamped `ctx.state.user`. Use this to decorate
|
|
758
797
|
* `ctx.state` with extra fields (typed via `AppState`) so handlers do not
|
|
759
798
|
* re-parse the `Authorization` header in every route.
|
|
799
|
+
* Runs before request-body I/O with a {@link PreBodyContext}; `ctx.body` is
|
|
800
|
+
* always `undefined`.
|
|
760
801
|
*
|
|
761
802
|
* @since 0.22.0
|
|
762
803
|
*/
|
|
763
804
|
onAuthSuccess?: (creds: {
|
|
764
805
|
username: string;
|
|
765
806
|
password: string;
|
|
766
|
-
}, ctx:
|
|
807
|
+
}, ctx: PreBodyContext<any>) => void | Promise<void>;
|
|
767
808
|
}
|
|
768
809
|
/**
|
|
769
810
|
* HTTP Basic Authentication middleware (RFC 7617).
|
package/dist/middleware.js
CHANGED
|
@@ -31,7 +31,7 @@ export function requestId(opts = {}) {
|
|
|
31
31
|
const header = (opts.header ?? "x-request-id").toLowerCase();
|
|
32
32
|
const gen = opts.generator ?? randomId;
|
|
33
33
|
return {
|
|
34
|
-
|
|
34
|
+
preBody(ctx) {
|
|
35
35
|
const incoming = opts.trustIncoming ? ctx.request.headers.get(header) : null;
|
|
36
36
|
const id = incoming && /^[A-Za-z0-9._-]{1,200}$/.test(incoming) ? incoming : gen();
|
|
37
37
|
ctx.state.requestId = id;
|
|
@@ -460,6 +460,51 @@ export const CSRF_HOOK_MARKER = Symbol.for("daloyjs.middleware.csrf");
|
|
|
460
460
|
* @since 1.0.0
|
|
461
461
|
*/
|
|
462
462
|
export const AUTH_HOOK_MARKER = Symbol.for("daloyjs.auth.hook");
|
|
463
|
+
/**
|
|
464
|
+
* Internal list of request-budget hooks that must run when a later `preBody`
|
|
465
|
+
* guard rejects. This preserves registration order for `rateLimit()` before
|
|
466
|
+
* authentication without moving body-aware middleware ahead of validation.
|
|
467
|
+
*
|
|
468
|
+
* @internal
|
|
469
|
+
*/
|
|
470
|
+
export const EARLY_REJECTION_HOOK_MARKER = Symbol.for("daloyjs.middleware.earlyRejectionHooks");
|
|
471
|
+
/**
|
|
472
|
+
* Compose `preBody` hooks while honoring request-budget hooks registered
|
|
473
|
+
* before the guard that rejects. Used internally by App and hook combinators.
|
|
474
|
+
*
|
|
475
|
+
* @param layers - Hook layers in registration order.
|
|
476
|
+
* @returns A composed `preBody` hook, or `undefined` when no layer has one.
|
|
477
|
+
* @internal
|
|
478
|
+
*/
|
|
479
|
+
export function _mergePreBodyWithEarlyRejections(layers) {
|
|
480
|
+
if (!layers.some((hooks) => hooks.preBody !== undefined))
|
|
481
|
+
return undefined;
|
|
482
|
+
return async (ctx) => {
|
|
483
|
+
const pending = [];
|
|
484
|
+
for (const hooks of layers) {
|
|
485
|
+
const early = hooks[EARLY_REJECTION_HOOK_MARKER];
|
|
486
|
+
if (Array.isArray(early)) {
|
|
487
|
+
for (const candidate of early) {
|
|
488
|
+
if (typeof candidate === "function")
|
|
489
|
+
pending.push(candidate);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (hooks.preBody === undefined)
|
|
493
|
+
continue;
|
|
494
|
+
const result = await hooks.preBody(ctx);
|
|
495
|
+
if (!(result instanceof Response))
|
|
496
|
+
continue;
|
|
497
|
+
let response = result;
|
|
498
|
+
for (const hook of pending) {
|
|
499
|
+
const replacement = await hook(ctx);
|
|
500
|
+
if (replacement instanceof Response)
|
|
501
|
+
response = replacement;
|
|
502
|
+
}
|
|
503
|
+
return response;
|
|
504
|
+
}
|
|
505
|
+
return undefined;
|
|
506
|
+
};
|
|
507
|
+
}
|
|
463
508
|
/**
|
|
464
509
|
* Mark a custom {@link Hooks} bundle as performing request authentication.
|
|
465
510
|
*
|
|
@@ -476,7 +521,7 @@ export const AUTH_HOOK_MARKER = Symbol.for("daloyjs.auth.hook");
|
|
|
476
521
|
* @example
|
|
477
522
|
* ```ts
|
|
478
523
|
* app.use(markAuthHook({
|
|
479
|
-
* async
|
|
524
|
+
* async preBody(ctx) {
|
|
480
525
|
* if (!(await myVerify(ctx.request))) throw new UnauthorizedError();
|
|
481
526
|
* },
|
|
482
527
|
* }));
|
|
@@ -663,6 +708,11 @@ class MemoryStore {
|
|
|
663
708
|
* `X-Forwarded-For` / `X-Real-IP` when behind a trusted proxy, or supply a
|
|
664
709
|
* custom `keyGenerator` (e.g. derive from the authenticated user id).
|
|
665
710
|
*
|
|
711
|
+
* Registration order remains security-significant: a limiter placed before a
|
|
712
|
+
* `preBody` auth hook counts rejected credentials and can replace the later
|
|
713
|
+
* `401` with `429`, without consuming the request body. On ordinary accepted
|
|
714
|
+
* requests it retains the validated `beforeHandle` timing.
|
|
715
|
+
*
|
|
666
716
|
* @example
|
|
667
717
|
* ```ts
|
|
668
718
|
* import { rateLimit } from "@daloyjs/core";
|
|
@@ -704,21 +754,22 @@ export function rateLimit(opts) {
|
|
|
704
754
|
}
|
|
705
755
|
return "global";
|
|
706
756
|
});
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
return undefined;
|
|
720
|
-
},
|
|
757
|
+
const enforce = async (ctx) => {
|
|
758
|
+
const key = `${groupPrefix}${keyOf(ctx)}`;
|
|
759
|
+
const { count, resetMs } = await store.hit(key, opts.windowMs);
|
|
760
|
+
const remaining = Math.max(0, opts.max - count);
|
|
761
|
+
ctx.set.headers.set("x-ratelimit-limit", String(opts.max));
|
|
762
|
+
ctx.set.headers.set("x-ratelimit-remaining", String(remaining));
|
|
763
|
+
ctx.set.headers.set("x-ratelimit-reset", String(Math.ceil(resetMs / 1000)));
|
|
764
|
+
if (count > opts.max) {
|
|
765
|
+
const retry = Math.ceil((resetMs - Date.now()) / 1000);
|
|
766
|
+
throw new TooManyRequestsError(opts.retryAfter !== false ? retry : undefined);
|
|
767
|
+
}
|
|
768
|
+
return undefined;
|
|
721
769
|
};
|
|
770
|
+
const hooks = { beforeHandle: enforce };
|
|
771
|
+
hooks[EARLY_REJECTION_HOOK_MARKER] = [enforce];
|
|
772
|
+
return hooks;
|
|
722
773
|
}
|
|
723
774
|
function assertNonNegativeInteger(name, value) {
|
|
724
775
|
if (!Number.isInteger(value) || value < 0) {
|
|
@@ -751,6 +802,8 @@ function wait(ms) {
|
|
|
751
802
|
* Mount the same `loginThrottle()` instance (or multiple instances with the
|
|
752
803
|
* same `groupId`) across related routes so an attacker cannot bypass the limit
|
|
753
804
|
* by rotating between password, OTP, and reset endpoints.
|
|
805
|
+
* When registered before `preBody` authentication it counts and progressively
|
|
806
|
+
* delays rejected credentials without consuming a declared request body.
|
|
754
807
|
*
|
|
755
808
|
* @param opts - Throttle tuning (see {@link LoginThrottleOptions}); every field has a safe default.
|
|
756
809
|
* @returns A {@link Hooks} bundle ready for `app.use(...)` or per-route `hooks`.
|
|
@@ -784,30 +837,31 @@ export function loginThrottle(opts = {}) {
|
|
|
784
837
|
slowdownBuckets = new Map();
|
|
785
838
|
SHARED_LOGIN_THROTTLE_BUCKETS.set(groupId, slowdownBuckets);
|
|
786
839
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
slowdownBuckets.delete(bucketKey);
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
if (bucket.count > delayAfter && delayMs > 0 && maxDelayMs > 0) {
|
|
804
|
-
const delay = Math.min(maxDelayMs, (bucket.count - delayAfter) * delayMs);
|
|
805
|
-
if (delay > 0)
|
|
806
|
-
await wait(delay);
|
|
840
|
+
const enforce = async (ctx) => {
|
|
841
|
+
const now = Date.now();
|
|
842
|
+
const key = `${groupId}:${keyGenerator(ctx)}`;
|
|
843
|
+
let bucket = slowdownBuckets.get(key);
|
|
844
|
+
if (!bucket || bucket.resetMs <= now) {
|
|
845
|
+
bucket = { count: 0, resetMs: now + windowMs };
|
|
846
|
+
slowdownBuckets.set(key, bucket);
|
|
847
|
+
}
|
|
848
|
+
bucket.count += 1;
|
|
849
|
+
if (slowdownBuckets.size > 10_000) {
|
|
850
|
+
for (const [bucketKey, value] of slowdownBuckets) {
|
|
851
|
+
if (value.resetMs <= now)
|
|
852
|
+
slowdownBuckets.delete(bucketKey);
|
|
807
853
|
}
|
|
808
|
-
|
|
809
|
-
|
|
854
|
+
}
|
|
855
|
+
if (bucket.count > delayAfter && delayMs > 0 && maxDelayMs > 0) {
|
|
856
|
+
const delay = Math.min(maxDelayMs, (bucket.count - delayAfter) * delayMs);
|
|
857
|
+
if (delay > 0)
|
|
858
|
+
await wait(delay);
|
|
859
|
+
}
|
|
860
|
+
return limiter.beforeHandle?.(ctx);
|
|
810
861
|
};
|
|
862
|
+
const hooks = { beforeHandle: enforce };
|
|
863
|
+
hooks[EARLY_REJECTION_HOOK_MARKER] = [enforce];
|
|
864
|
+
return hooks;
|
|
811
865
|
}
|
|
812
866
|
// ---------- Timing ----------
|
|
813
867
|
/**
|
|
@@ -851,6 +905,8 @@ export function timing(headerName = "server-timing") {
|
|
|
851
905
|
* optional `verify` hook is the integration point for revocation
|
|
852
906
|
* lists, token-version counters, and other per-request invalidation checks
|
|
853
907
|
* that `validate` cannot answer statelessly.
|
|
908
|
+
* Both checks run before request-body I/O; `verify` receives a
|
|
909
|
+
* {@link PreBodyContext} whose `body` is always `undefined`.
|
|
854
910
|
*
|
|
855
911
|
* @example
|
|
856
912
|
* ```ts
|
|
@@ -880,7 +936,7 @@ export function bearerAuth(opts) {
|
|
|
880
936
|
throw new Error("bearerAuth(): realm must not contain quotes, CR, LF, or NUL bytes.");
|
|
881
937
|
}
|
|
882
938
|
return markAuthHook({
|
|
883
|
-
async
|
|
939
|
+
async preBody(ctx) {
|
|
884
940
|
const h = ctx.request.headers.get("authorization") ?? "";
|
|
885
941
|
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
886
942
|
if (!m) {
|
|
@@ -1157,7 +1213,7 @@ export function basicAuth(opts) {
|
|
|
1157
1213
|
throw new Error("basicAuth(): maxCredentialBytes must be a positive integer.");
|
|
1158
1214
|
}
|
|
1159
1215
|
return markAuthHook({
|
|
1160
|
-
async
|
|
1216
|
+
async preBody(ctx) {
|
|
1161
1217
|
const header = ctx.request.headers.get("authorization") ?? "";
|
|
1162
1218
|
const match = BASIC_AUTH_TOKEN_RE.exec(header);
|
|
1163
1219
|
if (!match || match[1].length > maxBytes)
|
package/dist/mtls.d.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* @module
|
|
28
28
|
* @since 0.37.0
|
|
29
29
|
*/
|
|
30
|
-
import type {
|
|
30
|
+
import type { Hooks, PreBodyContext } from "./types.js";
|
|
31
31
|
/**
|
|
32
32
|
* Normalized view of a TLS client certificate, independent of how it was
|
|
33
33
|
* obtained (native socket vs. forwarded proxy header). Every field except
|
|
@@ -197,9 +197,9 @@ export interface ClientCertAuthOptions {
|
|
|
197
197
|
/**
|
|
198
198
|
* Override how the certificate is sourced. Defaults to reading whatever the
|
|
199
199
|
* adapter attached via {@link setClientCertificate} (native TLS), falling
|
|
200
|
-
* back to {@link header} parsing when configured.
|
|
200
|
+
* back to {@link header} parsing when configured. Runs before body I/O.
|
|
201
201
|
*/
|
|
202
|
-
resolve?: (ctx:
|
|
202
|
+
resolve?: (ctx: PreBodyContext<any>) => ClientCertificate | undefined;
|
|
203
203
|
/**
|
|
204
204
|
* Read the certificate from a trusted-proxy header instead of (or in
|
|
205
205
|
* addition to) the native adapter source. **Spoofable** unless the app is
|
|
@@ -237,9 +237,10 @@ export interface ClientCertAuthOptions {
|
|
|
237
237
|
checkValidity?: boolean;
|
|
238
238
|
/**
|
|
239
239
|
* Custom per-request check, run after all built-in checks pass. Returning
|
|
240
|
-
* `false` rejects with `403`; `true`/`undefined` accepts.
|
|
240
|
+
* `false` rejects with `403`; `true`/`undefined` accepts. The context body is
|
|
241
|
+
* unavailable because certificate authentication runs before body I/O.
|
|
241
242
|
*/
|
|
242
|
-
verify?: (cert: ClientCertificate, ctx:
|
|
243
|
+
verify?: (cert: ClientCertificate, ctx: PreBodyContext<any>) => boolean | void | Promise<boolean | void>;
|
|
243
244
|
/** Rejection message for the `403` responses. Default: `"Client certificate not permitted"`. */
|
|
244
245
|
message?: string;
|
|
245
246
|
/** `ctx.state` key the accepted certificate is stamped on. Default: `"clientCertificate"`. */
|
package/dist/mtls.js
CHANGED
|
@@ -348,7 +348,7 @@ export function clientCertAuth(opts = {}) {
|
|
|
348
348
|
return undefined;
|
|
349
349
|
});
|
|
350
350
|
const authHooks = {
|
|
351
|
-
async
|
|
351
|
+
async preBody(ctx) {
|
|
352
352
|
const cert = resolve(ctx);
|
|
353
353
|
if (!cert) {
|
|
354
354
|
return new Response(MISSING_CERT_BODY, {
|
|
@@ -396,11 +396,7 @@ function assertHeaderConfig(cfg) {
|
|
|
396
396
|
if (cfg.format === "xfcc")
|
|
397
397
|
return;
|
|
398
398
|
if (cfg.format === "structured") {
|
|
399
|
-
if (!cfg.subjectDN &&
|
|
400
|
-
!cfg.fingerprint &&
|
|
401
|
-
!cfg.san &&
|
|
402
|
-
!cfg.serialNumber &&
|
|
403
|
-
!cfg.issuerDN) {
|
|
399
|
+
if (!cfg.subjectDN && !cfg.fingerprint && !cfg.san && !cfg.serialNumber && !cfg.issuerDN) {
|
|
404
400
|
throw new Error("clientCertAuth(): structured header config must name at least one of subjectDN/issuerDN/fingerprint/serialNumber/san.");
|
|
405
401
|
}
|
|
406
402
|
return;
|
|
@@ -419,9 +415,7 @@ function certFromHeaders(request, cfg) {
|
|
|
419
415
|
const sanRaw = readHeader(request, cfg.san);
|
|
420
416
|
const verifyRaw = cfg.verify ? readHeader(request, cfg.verify) : undefined;
|
|
421
417
|
const successValue = (cfg.verifySuccessValue ?? "SUCCESS").toLowerCase();
|
|
422
|
-
const verified = cfg.verify === undefined
|
|
423
|
-
? true
|
|
424
|
-
: (verifyRaw ?? "").toLowerCase() === successValue;
|
|
418
|
+
const verified = cfg.verify === undefined ? true : (verifyRaw ?? "").toLowerCase() === successValue;
|
|
425
419
|
const sans = [];
|
|
426
420
|
if (sanRaw) {
|
|
427
421
|
for (const piece of sanRaw.split(",")) {
|
package/dist/openapi.js
CHANGED
|
@@ -208,7 +208,7 @@ function buildOperation(route, path) {
|
|
|
208
208
|
? { ...(metaResponseExamples ?? {}), ...(spec.examples ?? {}) }
|
|
209
209
|
: undefined;
|
|
210
210
|
responses[status] = {
|
|
211
|
-
description: spec.description
|
|
211
|
+
description: spec.description ?? `HTTP ${status} response`,
|
|
212
212
|
...(spec.body
|
|
213
213
|
? {
|
|
214
214
|
content: {
|
package/dist/pagination.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* @since 0.37.0
|
|
29
29
|
*/
|
|
30
30
|
import { BadRequestError } from "./errors.js";
|
|
31
|
+
import { safeJsonParseLimited } from "./security.js";
|
|
31
32
|
import { isForbiddenObjectKey } from "./security.js";
|
|
32
33
|
/**
|
|
33
34
|
* Hard cap on the length of an encoded cursor string accepted by
|
|
@@ -91,7 +92,9 @@ export function decodeCursor(cursor) {
|
|
|
91
92
|
}
|
|
92
93
|
let parsed;
|
|
93
94
|
try {
|
|
94
|
-
|
|
95
|
+
// Use limited parse for structural safety (wide/deep cursors).
|
|
96
|
+
// Cursors are length-capped at 4k so even default limits are very generous here.
|
|
97
|
+
parsed = safeJsonParseLimited(json, 1000, 20);
|
|
95
98
|
}
|
|
96
99
|
catch {
|
|
97
100
|
throw new BadRequestError("Malformed pagination cursor.");
|
package/dist/response-cache.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* @module
|
|
40
40
|
* @since 0.37.0
|
|
41
41
|
*/
|
|
42
|
+
import { markSchemaValidatedResponse } from "./internal-response.js";
|
|
42
43
|
/** Internal `ctx.state` key carrying the pending cache key between hooks. */
|
|
43
44
|
const PENDING_STATE_KEY = "__responseCachePending";
|
|
44
45
|
/**
|
|
@@ -183,7 +184,7 @@ function buildResponseFromCache(entry, outcome, statusHeaderName, isHead) {
|
|
|
183
184
|
if (statusHeaderName)
|
|
184
185
|
headers.set(statusHeaderName, outcome);
|
|
185
186
|
const body = isHead || entry.body === "" ? null : base64ToBytes(entry.body);
|
|
186
|
-
return new Response(body, { status: entry.status, headers });
|
|
187
|
+
return markSchemaValidatedResponse(new Response(body, { status: entry.status, headers }));
|
|
187
188
|
}
|
|
188
189
|
function isPromiseLike(value) {
|
|
189
190
|
return (value !== null &&
|
package/dist/safe-redirect.d.ts
CHANGED
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
* ```ts
|
|
32
32
|
* import { safeRedirect } from "@daloyjs/core";
|
|
33
33
|
*
|
|
34
|
-
* app.get("/login/callback",
|
|
34
|
+
* app.get("/login/callback", {
|
|
35
|
+
* acknowledgeNoResponseBodySchema: true,
|
|
36
|
+
* responses: { 303: {} },
|
|
37
|
+
* }, (ctx) => {
|
|
35
38
|
* const next = new URL(ctx.request.url).searchParams.get("next") ?? "/";
|
|
36
39
|
* return safeRedirect(next, {
|
|
37
40
|
* allowedPaths: ["/", "/dashboard", "/account"],
|
package/dist/safe-redirect.js
CHANGED
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
* ```ts
|
|
32
32
|
* import { safeRedirect } from "@daloyjs/core";
|
|
33
33
|
*
|
|
34
|
-
* app.get("/login/callback",
|
|
34
|
+
* app.get("/login/callback", {
|
|
35
|
+
* acknowledgeNoResponseBodySchema: true,
|
|
36
|
+
* responses: { 303: {} },
|
|
37
|
+
* }, (ctx) => {
|
|
35
38
|
* const next = new URL(ctx.request.url).searchParams.get("next") ?? "/";
|
|
36
39
|
* return safeRedirect(next, {
|
|
37
40
|
* allowedPaths: ["/", "/dashboard", "/account"],
|