@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.
Files changed (61) hide show
  1. package/README.md +103 -41
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +41 -5
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +104 -19
  9. package/dist/app.d.ts +131 -11
  10. package/dist/app.js +305 -217
  11. package/dist/bot-guard.js +30 -3
  12. package/dist/cli.js +41 -1
  13. package/dist/client.d.ts +64 -18
  14. package/dist/client.js +36 -6
  15. package/dist/combine.d.ts +11 -11
  16. package/dist/combine.js +90 -47
  17. package/dist/compression.d.ts +9 -0
  18. package/dist/compression.js +72 -1
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/docs.d.ts +5 -9
  22. package/dist/docs.js +36 -14
  23. package/dist/errors.d.ts +12 -3
  24. package/dist/errors.js +12 -3
  25. package/dist/fetch-guard.d.ts +27 -19
  26. package/dist/fetch-guard.js +50 -8
  27. package/dist/http-signatures.d.ts +4 -1
  28. package/dist/http-signatures.js +13 -1
  29. package/dist/idempotency.js +2 -1
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +3 -3
  32. package/dist/internal-response.d.ts +15 -0
  33. package/dist/internal-response.js +27 -0
  34. package/dist/jwk.d.ts +11 -7
  35. package/dist/jwk.js +11 -7
  36. package/dist/logger.d.ts +45 -0
  37. package/dist/logger.js +137 -0
  38. package/dist/mcp.js +21 -15
  39. package/dist/middleware.d.ts +48 -7
  40. package/dist/middleware.js +129 -43
  41. package/dist/mtls.d.ts +6 -5
  42. package/dist/mtls.js +8 -9
  43. package/dist/openapi.js +1 -1
  44. package/dist/pagination.js +4 -1
  45. package/dist/response-cache.js +2 -1
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +9 -2
  49. package/dist/safe-redirect.js +29 -4
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security.d.ts +62 -0
  53. package/dist/security.js +220 -15
  54. package/dist/session.d.ts +13 -2
  55. package/dist/session.js +111 -17
  56. package/dist/tenancy.d.ts +2 -2
  57. package/dist/time-claims.js +3 -1
  58. package/dist/types.d.ts +85 -20
  59. package/dist/types.js +16 -1
  60. package/dist/waf.js +86 -26
  61. package/package.json +11 -4
@@ -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
- beforeHandle(ctx) {
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
- for (const [k, v] of headerEntries) {
282
- if (!res.headers.has(k))
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 beforeHandle(ctx) {
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
- return {
708
- async beforeHandle(ctx) {
709
- const key = `${groupPrefix}${keyOf(ctx)}`;
710
- const { count, resetMs } = await store.hit(key, opts.windowMs);
711
- const remaining = Math.max(0, opts.max - count);
712
- ctx.set.headers.set("x-ratelimit-limit", String(opts.max));
713
- ctx.set.headers.set("x-ratelimit-remaining", String(remaining));
714
- ctx.set.headers.set("x-ratelimit-reset", String(Math.ceil(resetMs / 1000)));
715
- if (count > opts.max) {
716
- const retry = Math.ceil((resetMs - Date.now()) / 1000);
717
- throw new TooManyRequestsError(opts.retryAfter !== false ? retry : undefined);
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
- return {
788
- async beforeHandle(ctx) {
789
- const now = Date.now();
790
- const key = `${groupId}:${keyGenerator(ctx)}`;
791
- let bucket = slowdownBuckets.get(key);
792
- if (!bucket || bucket.resetMs <= now) {
793
- bucket = { count: 0, resetMs: now + windowMs };
794
- slowdownBuckets.set(key, bucket);
795
- }
796
- bucket.count += 1;
797
- if (slowdownBuckets.size > 10_000) {
798
- for (const [bucketKey, value] of slowdownBuckets) {
799
- if (value.resetMs <= now)
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
- return limiter.beforeHandle?.(ctx);
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 beforeHandle(ctx) {
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 beforeHandle(ctx) {
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 { BaseContext, Hooks } from "./types.js";
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: BaseContext<any, any>) => ClientCertificate | undefined;
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: BaseContext<any, any>) => boolean | void | Promise<boolean | void>;
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 beforeHandle(ctx) {
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
- const verified = cfg.verify === undefined
423
- ? true
424
- : (verifyRaw ?? "").toLowerCase() === successValue;
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: {
@@ -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
- parsed = JSON.parse(json);
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.");
@@ -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
- * - Static (parameter-free) paths resolve via a single Map.get — O(1).
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 string is split by `indexOf` rather than a regex/replace.
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
- * - Static (parameter-free) paths resolve via a single Map.get — O(1).
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 string is split by `indexOf` rather than a regex/replace.
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
- const normalized = path.replace(/\/+$/, "") || "/";
107
- const staticEntry = this.staticTable.get(normalized);
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
- const normalized = path.replace(/\/+$/, "") || "/";
124
- const fromStatic = this.staticTable.get(normalized);
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.replace(/\/+$/, "") || "/";
206
+ const clean = trimTrailingSlashes(path);
192
207
  if (clean === "/")
193
208
  return [];
194
- return clean.replace(/^\//, "").split("/");
209
+ return (clean.charCodeAt(0) === 47 ? clean.slice(1) : clean).split("/");
195
210
  }
@@ -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", (ctx) => {
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. */
@@ -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", (ctx) => {
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
- if (!options.fallback.startsWith("/") || options.fallback.startsWith("//")) {
192
- throw new TypeError(`safeRedirect: fallback must be a same-origin path starting with "/"; got ${options.fallback}`);
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(options.fallback, status, options.headers);
219
+ return buildResponse(fallbackResult.location, status, options.headers);
195
220
  }
196
221
  throw new OpenRedirectBlockedError(result.reason, target);
197
222
  }
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:50e7cfc8-73fe-5f9a-96b0-38df20d401b5",
4
+ "serialNumber": "urn:uuid:4d7ef2ca-7207-5a53-933a-75950561ff0c",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-07-09T19:29:34.036Z",
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.3"
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.3",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-rc.3",
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.3",
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.3",
49
+ "tagId": "swidtag--daloyjs-core-1.0.0-rc.5",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-rc.3",
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.3",
60
+ "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -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.3",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.3-50e7cfc8-73fe-5f9a-96b0-38df20d401b5",
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-09T19:29:34.036Z",
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.3",
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.3"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.5"
31
31
  }
32
32
  ]
33
33
  }