@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/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;
|
|
@@ -257,6 +257,8 @@ export function secureHeaders(opts = {}) {
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
const headerEntries = Object.entries(headers);
|
|
260
|
+
// Lowercased name set for the common-path fast apply below.
|
|
261
|
+
const headerKeySet = headerEntries.length > 0 ? new Set(headerEntries.map(([k]) => k)) : null;
|
|
260
262
|
const hooks = {};
|
|
261
263
|
if (cspIsDynamic) {
|
|
262
264
|
hooks.beforeHandle = (ctx) => {
|
|
@@ -276,12 +278,40 @@ export function secureHeaders(opts = {}) {
|
|
|
276
278
|
return undefined;
|
|
277
279
|
};
|
|
278
280
|
}
|
|
279
|
-
if (headerEntries.length > 0) {
|
|
281
|
+
if (headerEntries.length > 0 && headerKeySet !== null) {
|
|
282
|
+
// Apply baseline security headers without overwriting values the handler
|
|
283
|
+
// (or an earlier hook) already set. Two paths, same accept/reject
|
|
284
|
+
// semantics:
|
|
285
|
+
//
|
|
286
|
+
// 1. Fast path (common): response carries none of our keys (typical
|
|
287
|
+
// after serializeResult: content-type + content-length +
|
|
288
|
+
// x-request-id only). One cheap forEach over the small response
|
|
289
|
+
// header map, then unconditional set of each default — avoids N
|
|
290
|
+
// `has()` probes that miss on every request.
|
|
291
|
+
// 2. Careful path: at least one of our keys is already present; fall
|
|
292
|
+
// back to set-if-absent so user-supplied CSP / frame-options / etc.
|
|
293
|
+
// still win.
|
|
280
294
|
hooks.onResponse = (res) => {
|
|
281
|
-
|
|
282
|
-
|
|
295
|
+
let conflict = false;
|
|
296
|
+
// `for...of` over Headers.entries() is faster than the callback-based
|
|
297
|
+
// forEach and lets us break the instant we find a conflicting header.
|
|
298
|
+
// WHATWG Headers yields lowercased names, matching headerKeySet.
|
|
299
|
+
for (const [name] of res.headers) {
|
|
300
|
+
if (headerKeySet.has(name)) {
|
|
301
|
+
conflict = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!conflict) {
|
|
306
|
+
for (const [k, v] of headerEntries)
|
|
283
307
|
res.headers.set(k, v);
|
|
284
308
|
}
|
|
309
|
+
else {
|
|
310
|
+
for (const [k, v] of headerEntries) {
|
|
311
|
+
if (!res.headers.has(k))
|
|
312
|
+
res.headers.set(k, v);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
285
315
|
};
|
|
286
316
|
}
|
|
287
317
|
hooks[SECURE_HEADERS_MARKER] = true;
|
|
@@ -460,6 +490,51 @@ export const CSRF_HOOK_MARKER = Symbol.for("daloyjs.middleware.csrf");
|
|
|
460
490
|
* @since 1.0.0
|
|
461
491
|
*/
|
|
462
492
|
export const AUTH_HOOK_MARKER = Symbol.for("daloyjs.auth.hook");
|
|
493
|
+
/**
|
|
494
|
+
* Internal list of request-budget hooks that must run when a later `preBody`
|
|
495
|
+
* guard rejects. This preserves registration order for `rateLimit()` before
|
|
496
|
+
* authentication without moving body-aware middleware ahead of validation.
|
|
497
|
+
*
|
|
498
|
+
* @internal
|
|
499
|
+
*/
|
|
500
|
+
export const EARLY_REJECTION_HOOK_MARKER = Symbol.for("daloyjs.middleware.earlyRejectionHooks");
|
|
501
|
+
/**
|
|
502
|
+
* Compose `preBody` hooks while honoring request-budget hooks registered
|
|
503
|
+
* before the guard that rejects. Used internally by App and hook combinators.
|
|
504
|
+
*
|
|
505
|
+
* @param layers - Hook layers in registration order.
|
|
506
|
+
* @returns A composed `preBody` hook, or `undefined` when no layer has one.
|
|
507
|
+
* @internal
|
|
508
|
+
*/
|
|
509
|
+
export function _mergePreBodyWithEarlyRejections(layers) {
|
|
510
|
+
if (!layers.some((hooks) => hooks.preBody !== undefined))
|
|
511
|
+
return undefined;
|
|
512
|
+
return async (ctx) => {
|
|
513
|
+
const pending = [];
|
|
514
|
+
for (const hooks of layers) {
|
|
515
|
+
const early = hooks[EARLY_REJECTION_HOOK_MARKER];
|
|
516
|
+
if (Array.isArray(early)) {
|
|
517
|
+
for (const candidate of early) {
|
|
518
|
+
if (typeof candidate === "function")
|
|
519
|
+
pending.push(candidate);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (hooks.preBody === undefined)
|
|
523
|
+
continue;
|
|
524
|
+
const result = await hooks.preBody(ctx);
|
|
525
|
+
if (!(result instanceof Response))
|
|
526
|
+
continue;
|
|
527
|
+
let response = result;
|
|
528
|
+
for (const hook of pending) {
|
|
529
|
+
const replacement = await hook(ctx);
|
|
530
|
+
if (replacement instanceof Response)
|
|
531
|
+
response = replacement;
|
|
532
|
+
}
|
|
533
|
+
return response;
|
|
534
|
+
}
|
|
535
|
+
return undefined;
|
|
536
|
+
};
|
|
537
|
+
}
|
|
463
538
|
/**
|
|
464
539
|
* Mark a custom {@link Hooks} bundle as performing request authentication.
|
|
465
540
|
*
|
|
@@ -476,7 +551,7 @@ export const AUTH_HOOK_MARKER = Symbol.for("daloyjs.auth.hook");
|
|
|
476
551
|
* @example
|
|
477
552
|
* ```ts
|
|
478
553
|
* app.use(markAuthHook({
|
|
479
|
-
* async
|
|
554
|
+
* async preBody(ctx) {
|
|
480
555
|
* if (!(await myVerify(ctx.request))) throw new UnauthorizedError();
|
|
481
556
|
* },
|
|
482
557
|
* }));
|
|
@@ -663,6 +738,11 @@ class MemoryStore {
|
|
|
663
738
|
* `X-Forwarded-For` / `X-Real-IP` when behind a trusted proxy, or supply a
|
|
664
739
|
* custom `keyGenerator` (e.g. derive from the authenticated user id).
|
|
665
740
|
*
|
|
741
|
+
* Registration order remains security-significant: a limiter placed before a
|
|
742
|
+
* `preBody` auth hook counts rejected credentials and can replace the later
|
|
743
|
+
* `401` with `429`, without consuming the request body. On ordinary accepted
|
|
744
|
+
* requests it retains the validated `beforeHandle` timing.
|
|
745
|
+
*
|
|
666
746
|
* @example
|
|
667
747
|
* ```ts
|
|
668
748
|
* import { rateLimit } from "@daloyjs/core";
|
|
@@ -704,21 +784,22 @@ export function rateLimit(opts) {
|
|
|
704
784
|
}
|
|
705
785
|
return "global";
|
|
706
786
|
});
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
return undefined;
|
|
720
|
-
},
|
|
787
|
+
const enforce = async (ctx) => {
|
|
788
|
+
const key = `${groupPrefix}${keyOf(ctx)}`;
|
|
789
|
+
const { count, resetMs } = await store.hit(key, opts.windowMs);
|
|
790
|
+
const remaining = Math.max(0, opts.max - count);
|
|
791
|
+
ctx.set.headers.set("x-ratelimit-limit", String(opts.max));
|
|
792
|
+
ctx.set.headers.set("x-ratelimit-remaining", String(remaining));
|
|
793
|
+
ctx.set.headers.set("x-ratelimit-reset", String(Math.ceil(resetMs / 1000)));
|
|
794
|
+
if (count > opts.max) {
|
|
795
|
+
const retry = Math.ceil((resetMs - Date.now()) / 1000);
|
|
796
|
+
throw new TooManyRequestsError(opts.retryAfter !== false ? retry : undefined);
|
|
797
|
+
}
|
|
798
|
+
return undefined;
|
|
721
799
|
};
|
|
800
|
+
const hooks = { beforeHandle: enforce };
|
|
801
|
+
hooks[EARLY_REJECTION_HOOK_MARKER] = [enforce];
|
|
802
|
+
return hooks;
|
|
722
803
|
}
|
|
723
804
|
function assertNonNegativeInteger(name, value) {
|
|
724
805
|
if (!Number.isInteger(value) || value < 0) {
|
|
@@ -751,6 +832,8 @@ function wait(ms) {
|
|
|
751
832
|
* Mount the same `loginThrottle()` instance (or multiple instances with the
|
|
752
833
|
* same `groupId`) across related routes so an attacker cannot bypass the limit
|
|
753
834
|
* by rotating between password, OTP, and reset endpoints.
|
|
835
|
+
* When registered before `preBody` authentication it counts and progressively
|
|
836
|
+
* delays rejected credentials without consuming a declared request body.
|
|
754
837
|
*
|
|
755
838
|
* @param opts - Throttle tuning (see {@link LoginThrottleOptions}); every field has a safe default.
|
|
756
839
|
* @returns A {@link Hooks} bundle ready for `app.use(...)` or per-route `hooks`.
|
|
@@ -784,30 +867,31 @@ export function loginThrottle(opts = {}) {
|
|
|
784
867
|
slowdownBuckets = new Map();
|
|
785
868
|
SHARED_LOGIN_THROTTLE_BUCKETS.set(groupId, slowdownBuckets);
|
|
786
869
|
}
|
|
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);
|
|
870
|
+
const enforce = async (ctx) => {
|
|
871
|
+
const now = Date.now();
|
|
872
|
+
const key = `${groupId}:${keyGenerator(ctx)}`;
|
|
873
|
+
let bucket = slowdownBuckets.get(key);
|
|
874
|
+
if (!bucket || bucket.resetMs <= now) {
|
|
875
|
+
bucket = { count: 0, resetMs: now + windowMs };
|
|
876
|
+
slowdownBuckets.set(key, bucket);
|
|
877
|
+
}
|
|
878
|
+
bucket.count += 1;
|
|
879
|
+
if (slowdownBuckets.size > 10_000) {
|
|
880
|
+
for (const [bucketKey, value] of slowdownBuckets) {
|
|
881
|
+
if (value.resetMs <= now)
|
|
882
|
+
slowdownBuckets.delete(bucketKey);
|
|
807
883
|
}
|
|
808
|
-
|
|
809
|
-
|
|
884
|
+
}
|
|
885
|
+
if (bucket.count > delayAfter && delayMs > 0 && maxDelayMs > 0) {
|
|
886
|
+
const delay = Math.min(maxDelayMs, (bucket.count - delayAfter) * delayMs);
|
|
887
|
+
if (delay > 0)
|
|
888
|
+
await wait(delay);
|
|
889
|
+
}
|
|
890
|
+
return limiter.beforeHandle?.(ctx);
|
|
810
891
|
};
|
|
892
|
+
const hooks = { beforeHandle: enforce };
|
|
893
|
+
hooks[EARLY_REJECTION_HOOK_MARKER] = [enforce];
|
|
894
|
+
return hooks;
|
|
811
895
|
}
|
|
812
896
|
// ---------- Timing ----------
|
|
813
897
|
/**
|
|
@@ -851,6 +935,8 @@ export function timing(headerName = "server-timing") {
|
|
|
851
935
|
* optional `verify` hook is the integration point for revocation
|
|
852
936
|
* lists, token-version counters, and other per-request invalidation checks
|
|
853
937
|
* that `validate` cannot answer statelessly.
|
|
938
|
+
* Both checks run before request-body I/O; `verify` receives a
|
|
939
|
+
* {@link PreBodyContext} whose `body` is always `undefined`.
|
|
854
940
|
*
|
|
855
941
|
* @example
|
|
856
942
|
* ```ts
|
|
@@ -880,7 +966,7 @@ export function bearerAuth(opts) {
|
|
|
880
966
|
throw new Error("bearerAuth(): realm must not contain quotes, CR, LF, or NUL bytes.");
|
|
881
967
|
}
|
|
882
968
|
return markAuthHook({
|
|
883
|
-
async
|
|
969
|
+
async preBody(ctx) {
|
|
884
970
|
const h = ctx.request.headers.get("authorization") ?? "";
|
|
885
971
|
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
886
972
|
if (!m) {
|
|
@@ -1157,7 +1243,7 @@ export function basicAuth(opts) {
|
|
|
1157
1243
|
throw new Error("basicAuth(): maxCredentialBytes must be a positive integer.");
|
|
1158
1244
|
}
|
|
1159
1245
|
return markAuthHook({
|
|
1160
|
-
async
|
|
1246
|
+
async preBody(ctx) {
|
|
1161
1247
|
const header = ctx.request.headers.get("authorization") ?? "";
|
|
1162
1248
|
const match = BASIC_AUTH_TOKEN_RE.exec(header);
|
|
1163
1249
|
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,12 @@ 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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
418
|
+
// Without a configured verification header there is no cryptographic proof
|
|
419
|
+
// the terminator validated the chain. Default to unverified so
|
|
420
|
+
// requireVerified (default true) rejects spoofed identity-only headers.
|
|
421
|
+
// Operators that intentionally trust a proxy which only forwards identity
|
|
422
|
+
// must set requireVerified: false (and keep a strict behindProxy posture).
|
|
423
|
+
const verified = cfg.verify === undefined ? false : (verifyRaw ?? "").toLowerCase() === successValue;
|
|
425
424
|
const sans = [];
|
|
426
425
|
if (sanRaw) {
|
|
427
426
|
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/router.d.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Trie / radix-style router with a static-route fast path.
|
|
3
3
|
*
|
|
4
4
|
* Performance:
|
|
5
|
-
* -
|
|
5
|
+
* - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
|
|
6
6
|
* - Dynamic paths walk a trie, O(path-segments) regardless of route count.
|
|
7
|
-
* - Path
|
|
7
|
+
* - Path normalization and splitting avoid regular expressions.
|
|
8
8
|
*
|
|
9
9
|
* Safety:
|
|
10
10
|
* - Path traversal (`..`) and empty segments are rejected at lookup time.
|
package/dist/router.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Trie / radix-style router with a static-route fast path.
|
|
3
3
|
*
|
|
4
4
|
* Performance:
|
|
5
|
-
* -
|
|
5
|
+
* - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
|
|
6
6
|
* - Dynamic paths walk a trie, O(path-segments) regardless of route count.
|
|
7
|
-
* - Path
|
|
7
|
+
* - Path normalization and splitting avoid regular expressions.
|
|
8
8
|
*
|
|
9
9
|
* Safety:
|
|
10
10
|
* - Path traversal (`..`) and empty segments are rejected at lookup time.
|
|
@@ -102,9 +102,12 @@ export class Router {
|
|
|
102
102
|
if (path.includes("/../") || path.endsWith("/..") || path.includes("//")) {
|
|
103
103
|
return undefined;
|
|
104
104
|
}
|
|
105
|
-
// Static fast path.
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Static fast path. Avoid allocating a normalized string for the common
|
|
106
|
+
// exact-path case; only trim when a trailing slash is actually present.
|
|
107
|
+
let staticEntry = this.staticTable.get(path);
|
|
108
|
+
if (!staticEntry && path.endsWith("/")) {
|
|
109
|
+
staticEntry = this.staticTable.get(trimTrailingSlashes(path));
|
|
110
|
+
}
|
|
108
111
|
if (staticEntry && staticEntry[method]) {
|
|
109
112
|
return { handler: staticEntry[method], params: {} };
|
|
110
113
|
}
|
|
@@ -120,8 +123,10 @@ export class Router {
|
|
|
120
123
|
}
|
|
121
124
|
/** Returns the set of methods registered at this exact path (for 405 responses). */
|
|
122
125
|
allowedMethods(path) {
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
let fromStatic = this.staticTable.get(path);
|
|
127
|
+
if (!fromStatic && path.endsWith("/")) {
|
|
128
|
+
fromStatic = this.staticTable.get(trimTrailingSlashes(path));
|
|
129
|
+
}
|
|
125
130
|
if (fromStatic)
|
|
126
131
|
return Object.keys(fromStatic);
|
|
127
132
|
const segments = splitPath(path);
|
|
@@ -165,6 +170,8 @@ export class Router {
|
|
|
165
170
|
* than letting a `URIError` bubble up as a generic 500.
|
|
166
171
|
*/
|
|
167
172
|
function safeDecodeURIComponent(segment) {
|
|
173
|
+
if (!segment.includes("%"))
|
|
174
|
+
return segment;
|
|
168
175
|
try {
|
|
169
176
|
return decodeURIComponent(segment);
|
|
170
177
|
}
|
|
@@ -187,9 +194,17 @@ function decodeSegments(segs, index) {
|
|
|
187
194
|
}
|
|
188
195
|
return parts.join("/");
|
|
189
196
|
}
|
|
197
|
+
function trimTrailingSlashes(path) {
|
|
198
|
+
if (path.length === 0)
|
|
199
|
+
return "/";
|
|
200
|
+
let end = path.length;
|
|
201
|
+
while (end > 1 && path.charCodeAt(end - 1) === 47)
|
|
202
|
+
end--;
|
|
203
|
+
return end === path.length ? path : path.slice(0, end);
|
|
204
|
+
}
|
|
190
205
|
function splitPath(path) {
|
|
191
|
-
const clean = path
|
|
206
|
+
const clean = trimTrailingSlashes(path);
|
|
192
207
|
if (clean === "/")
|
|
193
208
|
return [];
|
|
194
|
-
return clean.
|
|
209
|
+
return (clean.charCodeAt(0) === 47 ? clean.slice(1) : clean).split("/");
|
|
195
210
|
}
|
package/dist/safe-redirect.d.ts
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* Percent-encoded protocol-relative path prefixes such as `/%2f%2f`
|
|
20
20
|
* are also refused so downstream decoders cannot turn a same-origin
|
|
21
21
|
* `Location` into an origin-escaping redirect.
|
|
22
|
+
* - Same-origin paths carrying any code point above `U+00FF` are refused:
|
|
23
|
+
* they cannot be represented in the ISO-8859-1 `Location` header, and this
|
|
24
|
+
* also blocks the Unicode slash homographs (`U+2044` `⁄`, `U+2215` `∕`,
|
|
25
|
+
* `U+FF0F` `/`) that `NFKC` normalization can fold into `/`.
|
|
22
26
|
* - Absolute URLs are only allowed when their `origin` exactly matches
|
|
23
27
|
* one of the entries in `allowedOrigins`.
|
|
24
28
|
* - `javascript:`, `data:`, `vbscript:`, and `file:` schemes are always
|
|
@@ -31,7 +35,10 @@
|
|
|
31
35
|
* ```ts
|
|
32
36
|
* import { safeRedirect } from "@daloyjs/core";
|
|
33
37
|
*
|
|
34
|
-
* app.get("/login/callback",
|
|
38
|
+
* app.get("/login/callback", {
|
|
39
|
+
* acknowledgeNoResponseBodySchema: true,
|
|
40
|
+
* responses: { 303: {} },
|
|
41
|
+
* }, (ctx) => {
|
|
35
42
|
* const next = new URL(ctx.request.url).searchParams.get("next") ?? "/";
|
|
36
43
|
* return safeRedirect(next, {
|
|
37
44
|
* allowedPaths: ["/", "/dashboard", "/account"],
|
|
@@ -44,7 +51,7 @@
|
|
|
44
51
|
* @since 0.35.0
|
|
45
52
|
*/
|
|
46
53
|
/** Reason an open-redirect candidate was refused. */
|
|
47
|
-
export type SafeRedirectBlockReason = "empty-target" | "invalid-control-characters" | "protocol-relative" | "backslash-path" | "path-not-allowed" | "origin-not-allowed" | "scheme-not-allowed" | "parse-failed";
|
|
54
|
+
export type SafeRedirectBlockReason = "empty-target" | "invalid-control-characters" | "non-latin1-target" | "protocol-relative" | "backslash-path" | "path-not-allowed" | "origin-not-allowed" | "scheme-not-allowed" | "parse-failed";
|
|
48
55
|
/** Thrown when {@link safeRedirect} refuses a candidate URL and no `fallback` is configured. */
|
|
49
56
|
export declare class OpenRedirectBlockedError extends Error {
|
|
50
57
|
/** Machine-readable {@link SafeRedirectBlockReason} explaining the refusal. */
|
package/dist/safe-redirect.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* Percent-encoded protocol-relative path prefixes such as `/%2f%2f`
|
|
20
20
|
* are also refused so downstream decoders cannot turn a same-origin
|
|
21
21
|
* `Location` into an origin-escaping redirect.
|
|
22
|
+
* - Same-origin paths carrying any code point above `U+00FF` are refused:
|
|
23
|
+
* they cannot be represented in the ISO-8859-1 `Location` header, and this
|
|
24
|
+
* also blocks the Unicode slash homographs (`U+2044` `⁄`, `U+2215` `∕`,
|
|
25
|
+
* `U+FF0F` `/`) that `NFKC` normalization can fold into `/`.
|
|
22
26
|
* - Absolute URLs are only allowed when their `origin` exactly matches
|
|
23
27
|
* one of the entries in `allowedOrigins`.
|
|
24
28
|
* - `javascript:`, `data:`, `vbscript:`, and `file:` schemes are always
|
|
@@ -31,7 +35,10 @@
|
|
|
31
35
|
* ```ts
|
|
32
36
|
* import { safeRedirect } from "@daloyjs/core";
|
|
33
37
|
*
|
|
34
|
-
* app.get("/login/callback",
|
|
38
|
+
* app.get("/login/callback", {
|
|
39
|
+
* acknowledgeNoResponseBodySchema: true,
|
|
40
|
+
* responses: { 303: {} },
|
|
41
|
+
* }, (ctx) => {
|
|
35
42
|
* const next = new URL(ctx.request.url).searchParams.get("next") ?? "/";
|
|
36
43
|
* return safeRedirect(next, {
|
|
37
44
|
* allowedPaths: ["/", "/dashboard", "/account"],
|
|
@@ -62,6 +69,12 @@ const ALLOWED_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
|
62
69
|
// response-splitting via the `Location` header.
|
|
63
70
|
// eslint-disable-next-line no-control-regex
|
|
64
71
|
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]/;
|
|
72
|
+
// Any code point above U+00FF (outside Latin-1). Such characters cannot be
|
|
73
|
+
// written to a `Location` header — which is serialized as an ISO-8859-1
|
|
74
|
+
// ByteString, so `Headers.set` throws a raw `TypeError` — and they cover the
|
|
75
|
+
// Unicode slash homographs (U+2044, U+2215, U+FF0F) used to smuggle a
|
|
76
|
+
// protocol-relative redirect past a same-origin path check.
|
|
77
|
+
const NON_LATIN1_RE = /[^\x00-\xff]/;
|
|
65
78
|
function buildResponse(location, status, headers) {
|
|
66
79
|
const merged = new Headers(headers);
|
|
67
80
|
merged.set("Location", location);
|
|
@@ -94,6 +107,14 @@ function classify(target, allowedPaths, allowedOrigins) {
|
|
|
94
107
|
// user agents and proxies — refuse them outright.
|
|
95
108
|
if (target.includes("\\"))
|
|
96
109
|
return { ok: false, reason: "backslash-path" };
|
|
110
|
+
// The path is written verbatim into the `Location` header (an ISO-8859-1
|
|
111
|
+
// ByteString). A code point above U+00FF cannot live there — `Headers.set`
|
|
112
|
+
// would throw a raw `TypeError`, escaping this helper's typed error
|
|
113
|
+
// contract and surfacing to callers as an uncaught 500 — so refuse it here
|
|
114
|
+
// with a proper `OpenRedirectBlockedError`. This also blocks Unicode slash
|
|
115
|
+
// homographs that normalization can fold into an origin-escaping `//`.
|
|
116
|
+
if (NON_LATIN1_RE.test(target))
|
|
117
|
+
return { ok: false, reason: "non-latin1-target" };
|
|
97
118
|
if (allowedPaths.length === 0) {
|
|
98
119
|
return { ok: false, reason: "path-not-allowed" };
|
|
99
120
|
}
|
|
@@ -188,10 +209,14 @@ export function safeRedirect(target, options = {}) {
|
|
|
188
209
|
if (result.ok)
|
|
189
210
|
return buildResponse(result.location, status, options.headers);
|
|
190
211
|
if (options.fallback !== undefined) {
|
|
191
|
-
|
|
192
|
-
|
|
212
|
+
// Fallback must pass the same path safety checks as a primary same-origin
|
|
213
|
+
// target (no protocol-relative, no backslash confusion, no controls).
|
|
214
|
+
// Do not widen Location emission beyond what classify() would accept.
|
|
215
|
+
const fallbackResult = classify(options.fallback, ["/*"], []);
|
|
216
|
+
if (!fallbackResult.ok || !options.fallback.startsWith("/") || options.fallback.startsWith("//")) {
|
|
217
|
+
throw new TypeError(`safeRedirect: fallback must be a safe same-origin path starting with "/"; got ${options.fallback}`);
|
|
193
218
|
}
|
|
194
|
-
return buildResponse(
|
|
219
|
+
return buildResponse(fallbackResult.location, status, options.headers);
|
|
195
220
|
}
|
|
196
221
|
throw new OpenRedirectBlockedError(result.reason, target);
|
|
197
222
|
}
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:4d7ef2ca-7207-5a53-933a-75950561ff0c",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-07-
|
|
7
|
+
"timestamp": "2026-07-20T09:04:35.490Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.0.0-rc.
|
|
12
|
+
"version": "1.0.0-rc.5"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.0.0-rc.
|
|
24
|
+
"version": "1.0.0-rc.5",
|
|
25
25
|
"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.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-1.0.0-rc.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.0.0-rc.5",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.0.0-rc.
|
|
51
|
+
"version": "1.0.0-rc.5",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-1.0.0-rc.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.
|
|
5
|
+
"name": "@daloyjs/core-1.0.0-rc.5",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.5-4d7ef2ca-7207-5a53-933a-75950561ff0c",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-07-
|
|
8
|
+
"created": "2026-07-20T09:04:35.490Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "1.0.0-rc.
|
|
19
|
+
"versionInfo": "1.0.0-rc.5",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.5"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|