@daloyjs/core 0.34.3 → 0.35.1

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 (186) hide show
  1. package/README.md +151 -32
  2. package/dist/adapters/bun.d.ts +0 -1
  3. package/dist/adapters/bun.js +0 -1
  4. package/dist/adapters/cloudflare.d.ts +0 -1
  5. package/dist/adapters/cloudflare.js +0 -1
  6. package/dist/adapters/deno.d.ts +0 -1
  7. package/dist/adapters/deno.js +0 -1
  8. package/dist/adapters/fastly.d.ts +0 -1
  9. package/dist/adapters/fastly.js +0 -1
  10. package/dist/adapters/lambda.d.ts +0 -1
  11. package/dist/adapters/lambda.js +0 -1
  12. package/dist/adapters/node.d.ts +0 -1
  13. package/dist/adapters/node.js +175 -35
  14. package/dist/adapters/vercel.d.ts +0 -1
  15. package/dist/adapters/vercel.js +0 -1
  16. package/dist/app.d.ts +18 -1
  17. package/dist/app.js +396 -118
  18. package/dist/banner.d.ts +0 -1
  19. package/dist/banner.js +0 -1
  20. package/dist/cli.d.ts +32 -1
  21. package/dist/cli.js +62 -5
  22. package/dist/client.d.ts +0 -1
  23. package/dist/client.js +0 -1
  24. package/dist/combine.d.ts +0 -1
  25. package/dist/combine.js +0 -1
  26. package/dist/compression.d.ts +0 -1
  27. package/dist/compression.js +0 -1
  28. package/dist/config.d.ts +0 -1
  29. package/dist/config.js +0 -1
  30. package/dist/conn-info.d.ts +0 -1
  31. package/dist/conn-info.js +0 -1
  32. package/dist/contract.d.ts +0 -1
  33. package/dist/contract.js +0 -1
  34. package/dist/cookie.d.ts +0 -1
  35. package/dist/cookie.js +0 -1
  36. package/dist/dependency.d.ts +0 -1
  37. package/dist/dependency.js +0 -1
  38. package/dist/discriminator.d.ts +0 -1
  39. package/dist/discriminator.js +0 -1
  40. package/dist/docs.d.ts +0 -1
  41. package/dist/docs.js +0 -1
  42. package/dist/errors.d.ts +0 -1
  43. package/dist/errors.js +0 -1
  44. package/dist/etag.d.ts +0 -1
  45. package/dist/etag.js +0 -1
  46. package/dist/fetch-guard.d.ts +50 -6
  47. package/dist/fetch-guard.js +83 -18
  48. package/dist/hashing.d.ts +0 -1
  49. package/dist/hashing.js +0 -1
  50. package/dist/index.d.ts +5 -4
  51. package/dist/index.js +3 -3
  52. package/dist/ip-restriction.d.ts +0 -1
  53. package/dist/ip-restriction.js +0 -1
  54. package/dist/jwk.d.ts +0 -1
  55. package/dist/jwk.js +0 -1
  56. package/dist/jwt.d.ts +15 -1
  57. package/dist/jwt.js +56 -3
  58. package/dist/load-shedding.d.ts +0 -1
  59. package/dist/load-shedding.js +0 -1
  60. package/dist/logger.d.ts +0 -1
  61. package/dist/logger.js +0 -1
  62. package/dist/middleware.d.ts +78 -1
  63. package/dist/middleware.js +150 -18
  64. package/dist/multipart.d.ts +0 -1
  65. package/dist/multipart.js +0 -1
  66. package/dist/openapi.d.ts +0 -1
  67. package/dist/openapi.js +0 -1
  68. package/dist/rate-limit-redis.d.ts +0 -1
  69. package/dist/rate-limit-redis.js +0 -1
  70. package/dist/router.d.ts +0 -1
  71. package/dist/router.js +0 -1
  72. package/dist/safe-redirect.d.ts +91 -0
  73. package/dist/safe-redirect.js +168 -0
  74. package/dist/sbom.cdx.json +13 -9
  75. package/dist/sbom.spdx.json +5 -5
  76. package/dist/schema.d.ts +0 -1
  77. package/dist/schema.js +0 -1
  78. package/dist/security-schemes.d.ts +0 -1
  79. package/dist/security-schemes.js +0 -1
  80. package/dist/security.d.ts +134 -3
  81. package/dist/security.js +265 -6
  82. package/dist/session.d.ts +0 -1
  83. package/dist/session.js +0 -1
  84. package/dist/streaming.d.ts +0 -1
  85. package/dist/streaming.js +0 -1
  86. package/dist/subdomains.d.ts +0 -1
  87. package/dist/subdomains.js +0 -1
  88. package/dist/time-claims.d.ts +0 -1
  89. package/dist/time-claims.js +0 -1
  90. package/dist/tracing.d.ts +0 -1
  91. package/dist/tracing.js +0 -1
  92. package/dist/types.d.ts +0 -1
  93. package/dist/types.js +0 -1
  94. package/dist/websocket.d.ts +0 -1
  95. package/dist/websocket.js +0 -1
  96. package/package.json +35 -15
  97. package/dist/adapters/bun.d.ts.map +0 -1
  98. package/dist/adapters/bun.js.map +0 -1
  99. package/dist/adapters/cloudflare.d.ts.map +0 -1
  100. package/dist/adapters/cloudflare.js.map +0 -1
  101. package/dist/adapters/deno.d.ts.map +0 -1
  102. package/dist/adapters/deno.js.map +0 -1
  103. package/dist/adapters/fastly.d.ts.map +0 -1
  104. package/dist/adapters/fastly.js.map +0 -1
  105. package/dist/adapters/lambda.d.ts.map +0 -1
  106. package/dist/adapters/lambda.js.map +0 -1
  107. package/dist/adapters/node.d.ts.map +0 -1
  108. package/dist/adapters/node.js.map +0 -1
  109. package/dist/adapters/vercel.d.ts.map +0 -1
  110. package/dist/adapters/vercel.js.map +0 -1
  111. package/dist/app.d.ts.map +0 -1
  112. package/dist/app.js.map +0 -1
  113. package/dist/banner.d.ts.map +0 -1
  114. package/dist/banner.js.map +0 -1
  115. package/dist/cli.d.ts.map +0 -1
  116. package/dist/cli.js.map +0 -1
  117. package/dist/client.d.ts.map +0 -1
  118. package/dist/client.js.map +0 -1
  119. package/dist/combine.d.ts.map +0 -1
  120. package/dist/combine.js.map +0 -1
  121. package/dist/compression.d.ts.map +0 -1
  122. package/dist/compression.js.map +0 -1
  123. package/dist/config.d.ts.map +0 -1
  124. package/dist/config.js.map +0 -1
  125. package/dist/conn-info.d.ts.map +0 -1
  126. package/dist/conn-info.js.map +0 -1
  127. package/dist/contract.d.ts.map +0 -1
  128. package/dist/contract.js.map +0 -1
  129. package/dist/cookie.d.ts.map +0 -1
  130. package/dist/cookie.js.map +0 -1
  131. package/dist/dependency.d.ts.map +0 -1
  132. package/dist/dependency.js.map +0 -1
  133. package/dist/discriminator.d.ts.map +0 -1
  134. package/dist/discriminator.js.map +0 -1
  135. package/dist/docs.d.ts.map +0 -1
  136. package/dist/docs.js.map +0 -1
  137. package/dist/errors.d.ts.map +0 -1
  138. package/dist/errors.js.map +0 -1
  139. package/dist/etag.d.ts.map +0 -1
  140. package/dist/etag.js.map +0 -1
  141. package/dist/fetch-guard.d.ts.map +0 -1
  142. package/dist/fetch-guard.js.map +0 -1
  143. package/dist/hashing.d.ts.map +0 -1
  144. package/dist/hashing.js.map +0 -1
  145. package/dist/index.d.ts.map +0 -1
  146. package/dist/index.js.map +0 -1
  147. package/dist/ip-restriction.d.ts.map +0 -1
  148. package/dist/ip-restriction.js.map +0 -1
  149. package/dist/jwk.d.ts.map +0 -1
  150. package/dist/jwk.js.map +0 -1
  151. package/dist/jwt.d.ts.map +0 -1
  152. package/dist/jwt.js.map +0 -1
  153. package/dist/load-shedding.d.ts.map +0 -1
  154. package/dist/load-shedding.js.map +0 -1
  155. package/dist/logger.d.ts.map +0 -1
  156. package/dist/logger.js.map +0 -1
  157. package/dist/middleware.d.ts.map +0 -1
  158. package/dist/middleware.js.map +0 -1
  159. package/dist/multipart.d.ts.map +0 -1
  160. package/dist/multipart.js.map +0 -1
  161. package/dist/openapi.d.ts.map +0 -1
  162. package/dist/openapi.js.map +0 -1
  163. package/dist/rate-limit-redis.d.ts.map +0 -1
  164. package/dist/rate-limit-redis.js.map +0 -1
  165. package/dist/router.d.ts.map +0 -1
  166. package/dist/router.js.map +0 -1
  167. package/dist/schema.d.ts.map +0 -1
  168. package/dist/schema.js.map +0 -1
  169. package/dist/security-schemes.d.ts.map +0 -1
  170. package/dist/security-schemes.js.map +0 -1
  171. package/dist/security.d.ts.map +0 -1
  172. package/dist/security.js.map +0 -1
  173. package/dist/session.d.ts.map +0 -1
  174. package/dist/session.js.map +0 -1
  175. package/dist/streaming.d.ts.map +0 -1
  176. package/dist/streaming.js.map +0 -1
  177. package/dist/subdomains.d.ts.map +0 -1
  178. package/dist/subdomains.js.map +0 -1
  179. package/dist/time-claims.d.ts.map +0 -1
  180. package/dist/time-claims.js.map +0 -1
  181. package/dist/tracing.d.ts.map +0 -1
  182. package/dist/tracing.js.map +0 -1
  183. package/dist/types.d.ts.map +0 -1
  184. package/dist/types.js.map +0 -1
  185. package/dist/websocket.d.ts.map +0 -1
  186. package/dist/websocket.js.map +0 -1
@@ -203,4 +203,3 @@ function parseIPv6(input) {
203
203
  }
204
204
  return { bytes, family: 6 };
205
205
  }
206
- //# sourceMappingURL=ip-restriction.js.map
package/dist/jwk.d.ts CHANGED
@@ -79,4 +79,3 @@ export interface JwkOptions {
79
79
  * @since 0.22.0
80
80
  */
81
81
  export declare function jwk(opts: JwkOptions): Hooks;
82
- //# sourceMappingURL=jwk.d.ts.map
package/dist/jwk.js CHANGED
@@ -266,4 +266,3 @@ function extractScopes(payload) {
266
266
  }
267
267
  return out;
268
268
  }
269
- //# sourceMappingURL=jwk.js.map
package/dist/jwt.d.ts CHANGED
@@ -74,6 +74,21 @@ export interface JwtVerifierOptions {
74
74
  * RS256 + JWK silently accepts an HS256 token signed with the public key.
75
75
  */
76
76
  refuseSymmetricWithJwk?: boolean;
77
+ /**
78
+ * Optional revocation / blocklist hook. Invoked after signature and
79
+ * temporal/issuer/audience checks have passed, receiving the verified
80
+ * `{ header, payload }`. Return `true` (or a promise resolving to `true`)
81
+ * to reject the token with `JwtError("token_revoked", …)` — typically by
82
+ * looking up the token's `jti` (or `sub` for global logout) in a Redis
83
+ * blocklist or database. Closes the well-documented "Fastify does not
84
+ * offer JWT blocklisting" gap called out in
85
+ * [Snyk's framework comparison](https://snyk.io/blog/comparing-node-js-web-frameworks/),
86
+ * without forcing `@daloyjs/core` to ship a runtime dependency — the
87
+ * caller owns the storage layer. The hook runs last so a revoked token
88
+ * with a tampered signature is still rejected as `invalid_signature`,
89
+ * never `token_revoked` (which would leak the existence of the `jti`).
90
+ */
91
+ isRevoked?: (verified: JwtVerified) => boolean | Promise<boolean>;
77
92
  /** Optional injectable clock for tests. */
78
93
  now?: () => number;
79
94
  }
@@ -100,4 +115,3 @@ export declare function createJwtSigner(opts: JwtSignerOptions): {
100
115
  export declare function createJwtVerifier(opts: JwtVerifierOptions): {
101
116
  verify(token: string): Promise<JwtVerified>;
102
117
  };
103
- //# sourceMappingURL=jwt.d.ts.map
package/dist/jwt.js CHANGED
@@ -43,6 +43,19 @@ const ALL_ALGS = new Set([...SYMMETRIC, ...ASYMMETRIC]);
43
43
  * stronger HMAC than HS256-with-a-256-bit-key.
44
44
  */
45
45
  const MIN_HS_KEY_BYTES = 32;
46
+ /**
47
+ * Minimum RSA modulus length (in bits) accepted for `RS*` / `PS*` JWT
48
+ * algorithms. NIST SP 800-131A has disallowed RSA moduli shorter than
49
+ * 2048 bits for signature generation/verification since 2014; the same
50
+ * floor is called out in
51
+ * [Snyk's encryption guidance](https://snyk.io/blog/symmetric-vs-asymmetric-encryption-python/)
52
+ * ("undersized keys (fewer than 2048 bits) — which hackers can crack").
53
+ * WebCrypto happily imports 1024-bit or smaller keys, so the framework
54
+ * enforces the floor itself and refuses to import a weak key for either
55
+ * signing or verification — issuing or accepting a JWT signed with a
56
+ * 1024-bit RSA key is effectively a weak-signature footgun.
57
+ */
58
+ const MIN_RSA_KEY_BITS = 2048;
46
59
  /** Default lifetime cap when none is declared in development (`30d`). */
47
60
  export const DEFAULT_JWT_MAX_LIFETIME_SECONDS = 30 * 24 * 60 * 60;
48
61
  /** Structured error thrown by every JWT helper. */
@@ -147,8 +160,10 @@ function algParams(alg) {
147
160
  async function importKey(alg, material, usage) {
148
161
  const params = algParams(alg);
149
162
  const c = getCrypto();
150
- if (isCryptoKey(material))
163
+ if (isCryptoKey(material)) {
164
+ assertRsaModulusFloor(alg, material);
151
165
  return material;
166
+ }
152
167
  if (material instanceof Uint8Array) {
153
168
  if (!SYMMETRIC.has(alg)) {
154
169
  throw new JwtError("invalid_key", `jwt(): raw byte keys are only supported for HS256/HS384/HS512; got ${alg}.`);
@@ -172,10 +187,31 @@ async function importKey(alg, material, usage) {
172
187
  : params.name === "RSASSA-PKCS1-v1_5"
173
188
  ? { name: "RSASSA-PKCS1-v1_5", hash: params.hash }
174
189
  : { name: "Ed25519" };
175
- return c.subtle.importKey("jwk", material, importAlgorithm, false, [usage]);
190
+ const imported = await c.subtle.importKey("jwk", material, importAlgorithm, false, [usage]);
191
+ assertRsaModulusFloor(alg, imported);
192
+ return imported;
176
193
  }
177
194
  throw new JwtError("invalid_key", "jwt(): unsupported key material.");
178
195
  }
196
+ /**
197
+ * Refuse RSA keys whose modulus is shorter than {@link MIN_RSA_KEY_BITS}.
198
+ * Only applies to `RS*` / `PS*` algorithms — non-RSA keys are ignored. The
199
+ * imported `CryptoKey.algorithm` for any RSA key carries a numeric
200
+ * `modulusLength`; when WebCrypto reports a length below the floor we
201
+ * refuse the key for both signing and verification.
202
+ */
203
+ function assertRsaModulusFloor(alg, key) {
204
+ const params = algParams(alg);
205
+ if (params.name !== "RSASSA-PKCS1-v1_5" && params.name !== "RSA-PSS")
206
+ return;
207
+ const algorithm = key.algorithm;
208
+ const modulusLength = algorithm?.modulusLength;
209
+ if (typeof modulusLength !== "number" || !Number.isFinite(modulusLength))
210
+ return;
211
+ if (modulusLength < MIN_RSA_KEY_BITS) {
212
+ throw new JwtError("weak_rsa_key", `jwt(): ${alg} key modulus must be at least ${MIN_RSA_KEY_BITS} bits (NIST SP 800-131A); got ${modulusLength}.`);
213
+ }
214
+ }
179
215
  function buildSignAlgorithm(alg) {
180
216
  const params = algParams(alg);
181
217
  if (params.name === "RSA-PSS")
@@ -322,6 +358,9 @@ export function createJwtVerifier(opts) {
322
358
  throw new JwtError("invalid_clock_skew", "jwt(): clockSkewSeconds must be a non-negative finite number.");
323
359
  }
324
360
  }
361
+ if (opts.isRevoked !== undefined && typeof opts.isRevoked !== "function") {
362
+ throw new JwtError("invalid_is_revoked", "jwt(): isRevoked must be a function (verified) => boolean | Promise<boolean>.");
363
+ }
325
364
  const issuers = normalizeStringSet(opts.issuer);
326
365
  const audiences = normalizeStringSet(opts.audience);
327
366
  const keyCache = new Map();
@@ -349,6 +388,7 @@ export function createJwtVerifier(opts) {
349
388
  issuers,
350
389
  audiences,
351
390
  clockSkewSeconds: opts.clockSkewSeconds ?? 0,
391
+ isRevoked: opts.isRevoked ?? null,
352
392
  now: opts.now ?? (() => Math.floor(Date.now() / 1000)),
353
393
  };
354
394
  return {
@@ -444,6 +484,19 @@ async function verifyInternal(token, r) {
444
484
  throw new JwtError("invalid_audience", "jwt(): payload.aud is missing or not a string / string[].");
445
485
  }
446
486
  }
487
+ if (r.isRevoked) {
488
+ const verified = { header, payload };
489
+ let revoked;
490
+ try {
491
+ revoked = await r.isRevoked(verified);
492
+ }
493
+ catch (err) {
494
+ const detail = err instanceof Error ? err.message : String(err);
495
+ throw new JwtError("revocation_check_failed", `jwt(): isRevoked threw: ${detail}`);
496
+ }
497
+ if (revoked === true) {
498
+ throw new JwtError("token_revoked", "jwt(): token has been revoked.");
499
+ }
500
+ }
447
501
  return { header, payload };
448
502
  }
449
- //# sourceMappingURL=jwt.js.map
@@ -70,4 +70,3 @@ export declare function loadShedding(opts?: LoadSheddingOptions): Hooks;
70
70
  * @internal
71
71
  */
72
72
  export declare const LOAD_SHEDDING_MARKER: unique symbol;
73
- //# sourceMappingURL=load-shedding.d.ts.map
@@ -168,4 +168,3 @@ export function loadShedding(opts = {}) {
168
168
  * @internal
169
169
  */
170
170
  export const LOAD_SHEDDING_MARKER = Symbol.for("daloyjs.loadShedding");
171
- //# sourceMappingURL=load-shedding.js.map
package/dist/logger.d.ts CHANGED
@@ -121,4 +121,3 @@ export declare function createLogger(opts?: ConsoleLoggerOptions): Logger;
121
121
  */
122
122
  export declare const noopLogger: Logger;
123
123
  export {};
124
- //# sourceMappingURL=logger.d.ts.map
package/dist/logger.js CHANGED
@@ -258,4 +258,3 @@ export const noopLogger = {
258
258
  return noopLogger;
259
259
  },
260
260
  };
261
- //# sourceMappingURL=logger.js.map
@@ -89,6 +89,18 @@ export interface SecureHeadersOptions {
89
89
  permissionsPolicy?: string | false;
90
90
  crossOriginOpenerPolicy?: string | false;
91
91
  crossOriginResourcePolicy?: string | false;
92
+ /**
93
+ * `Cross-Origin-Embedder-Policy` (COEP). Off by default because
94
+ * `require-corp` breaks any cross-origin embed that has not opted in via
95
+ * CORP or CORS — opt in explicitly when you want a
96
+ * `crossOriginIsolated` document (enables `SharedArrayBuffer`,
97
+ * high-resolution timers, and closes additional XS-Leaks side
98
+ * channels). Recommended values: `"require-corp"` or
99
+ * `"credentialless"`. See https://xsleaks.dev/docs/defenses/isolation-policies/coep/.
100
+ *
101
+ * @since 0.37.0
102
+ */
103
+ crossOriginEmbedderPolicy?: "require-corp" | "credentialless" | "unsafe-none" | false;
92
104
  noSniff?: boolean;
93
105
  xssProtection?: boolean;
94
106
  /**
@@ -156,6 +168,72 @@ export declare const SECURE_HEADERS_MARKER: unique symbol;
156
168
  * @since 0.1.0
157
169
  */
158
170
  export declare function secureHeaders(opts?: SecureHeadersOptions): Hooks;
171
+ /** Options for {@link fetchMetadata}. */
172
+ export interface FetchMetadataOptions {
173
+ /**
174
+ * `Sec-Fetch-Dest` values that are always allowed even from cross-site
175
+ * contexts. Defaults to the destinations a browser uses for top-level
176
+ * navigation and embeds you typically *want* to be cross-site loadable
177
+ * (`document`, `embed`, `object`, `audio`, `video`, `font`, `style`,
178
+ * `image`). Override to tighten (e.g. `["document"]`) or extend.
179
+ */
180
+ allowedDestinations?: readonly string[];
181
+ /**
182
+ * Request methods that are always allowed when `Sec-Fetch-Mode` is
183
+ * `navigate` (so users can submit forms cross-site to your endpoints
184
+ * when you want that). Defaults to `["GET", "HEAD"]` — state-changing
185
+ * top-level navigations are still rejected, matching the
186
+ * Resource Isolation Policy from
187
+ * https://xsleaks.dev/docs/defenses/isolation-policies/resource-isolation/.
188
+ */
189
+ allowedNavigationMethods?: readonly string[];
190
+ /**
191
+ * Origins permitted as cross-site callers (full origin strings like
192
+ * `https://trusted.example`). Compared against the request `Origin`
193
+ * header. Empty by default.
194
+ */
195
+ allowedOrigins?: readonly string[];
196
+ /**
197
+ * Custom predicate run before any built-in check. Return `true` to allow
198
+ * the request unconditionally (e.g. for a public CDN-style endpoint).
199
+ */
200
+ allow?: (req: Request) => boolean;
201
+ }
202
+ /**
203
+ * Resource Isolation Policy middleware that uses the browser-set
204
+ * `Sec-Fetch-Site` / `Sec-Fetch-Mode` / `Sec-Fetch-Dest` request headers
205
+ * to reject cross-site requests that have no legitimate reason to reach
206
+ * your origin. This is the modern, tokenless defense against the
207
+ * XS-Leaks class of attacks documented at https://xsleaks.dev/ and
208
+ * complements {@link secureHeaders} (which sets COOP / CORP / CSP
209
+ * response-side) and {@link csrf} (which protects state-changing
210
+ * endpoints from forged submissions).
211
+ *
212
+ * Default policy (matches https://web.dev/articles/fetch-metadata):
213
+ * - Allow when `Sec-Fetch-Site` is missing (non-browser client / legacy
214
+ * browser — covered by other defenses) or one of `same-origin`,
215
+ * `same-site`, `none`.
216
+ * - Allow cross-site top-level navigations (`Sec-Fetch-Mode: navigate`)
217
+ * using a safe method (`GET` / `HEAD`).
218
+ * - Allow cross-site requests whose `Sec-Fetch-Dest` is in a small
219
+ * allowlist of embed-friendly destinations.
220
+ * - Reject everything else with `403 problem+json`.
221
+ *
222
+ * Browsers send `Sec-Fetch-*` since Chrome 76 / Firefox 90 / Safari 16.4,
223
+ * so for modern user agents this guard blocks the typical XS-Leak
224
+ * vectors (`<img>`, `<script>`, `<iframe>`, `fetch` no-cors, ...).
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * import { fetchMetadata } from "@daloyjs/core";
229
+ * app.use(fetchMetadata());
230
+ * ```
231
+ *
232
+ * @param opts - Policy overrides.
233
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
234
+ * @since 0.37.0
235
+ */
236
+ export declare function fetchMetadata(opts?: FetchMetadataOptions): Hooks;
159
237
  /**
160
238
  * Marker stamped on the `Hooks` object returned by {@link cors} so the
161
239
  * framework can detect that a CORS policy has been installed. Used by the
@@ -649,4 +727,3 @@ export declare const REQUIRE_SCOPES_HOOK_MARKER: unique symbol;
649
727
  */
650
728
  export declare function requireScopes(scopes: readonly string[]): Hooks;
651
729
  export { timingSafeEqual };
652
- //# sourceMappingURL=middleware.d.ts.map
@@ -223,7 +223,7 @@ export function secureHeaders(opts = {}) {
223
223
  const ref = opts.referrerPolicy ?? "no-referrer";
224
224
  if (ref !== false)
225
225
  headers["referrer-policy"] = ref;
226
- const perm = opts.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()";
226
+ const perm = opts.permissionsPolicy ?? "camera=(), microphone=(), geolocation=(), clipboard-write=()";
227
227
  if (perm !== false)
228
228
  headers["permissions-policy"] = perm;
229
229
  const coop = opts.crossOriginOpenerPolicy ?? "same-origin";
@@ -232,6 +232,11 @@ export function secureHeaders(opts = {}) {
232
232
  const corp = opts.crossOriginResourcePolicy ?? "same-origin";
233
233
  if (corp !== false)
234
234
  headers["cross-origin-resource-policy"] = corp;
235
+ // COEP is opt-in: `require-corp` would block any cross-origin embed that
236
+ // has not opted in via CORP / CORS, which is too aggressive as a default.
237
+ const coep = opts.crossOriginEmbedderPolicy;
238
+ if (coep)
239
+ headers["cross-origin-embedder-policy"] = coep;
235
240
  if (opts.noSniff !== false)
236
241
  headers["x-content-type-options"] = "nosniff";
237
242
  if (opts.xssProtection ?? false)
@@ -254,14 +259,16 @@ export function secureHeaders(opts = {}) {
254
259
  .join(", ");
255
260
  }
256
261
  }
257
- const hooks = {
258
- beforeHandle(ctx) {
259
- if (cspIsDynamic && cspOpt.nonce) {
262
+ const headerEntries = Object.entries(headers);
263
+ const hooks = {};
264
+ if (cspIsDynamic) {
265
+ hooks.beforeHandle = (ctx) => {
266
+ if (cspOpt.nonce) {
260
267
  ctx.state[CSP_NONCE_STATE] = generateCspNonce();
261
268
  }
262
- },
263
- onSend(res, ctx) {
264
- if (cspIsDynamic && !res.headers.has("content-security-policy")) {
269
+ };
270
+ hooks.onSend = (res, ctx) => {
271
+ if (!res.headers.has("content-security-policy")) {
265
272
  const nonce = ctx
266
273
  ? ctx.state[CSP_NONCE_STATE]
267
274
  : undefined;
@@ -270,17 +277,100 @@ export function secureHeaders(opts = {}) {
270
277
  res.headers.set("content-security-policy", header);
271
278
  }
272
279
  return undefined;
273
- },
274
- onResponse(res) {
275
- for (const [k, v] of Object.entries(headers)) {
280
+ };
281
+ }
282
+ if (headerEntries.length > 0) {
283
+ hooks.onResponse = (res) => {
284
+ for (const [k, v] of headerEntries) {
276
285
  if (!res.headers.has(k))
277
286
  res.headers.set(k, v);
278
287
  }
279
- },
280
- };
288
+ };
289
+ }
281
290
  hooks[SECURE_HEADERS_MARKER] = true;
282
291
  return hooks;
283
292
  }
293
+ const FETCH_METADATA_DEFAULT_DESTS = Object.freeze([
294
+ // Embed-only destinations that browsers use for legitimate cross-site
295
+ // resource loads (`<img>`, `<audio>`, `<video>`, `<link rel=stylesheet>`,
296
+ // `<embed>`, `<object>`, `@font-face`). `document` is intentionally
297
+ // excluded — that destination is for top-level navigation, which is
298
+ // handled separately via `Sec-Fetch-Mode: navigate` + safe method.
299
+ "embed",
300
+ "object",
301
+ "audio",
302
+ "video",
303
+ "font",
304
+ "style",
305
+ "image",
306
+ ]);
307
+ const FETCH_METADATA_DEFAULT_NAV_METHODS = Object.freeze(["GET", "HEAD"]);
308
+ /**
309
+ * Resource Isolation Policy middleware that uses the browser-set
310
+ * `Sec-Fetch-Site` / `Sec-Fetch-Mode` / `Sec-Fetch-Dest` request headers
311
+ * to reject cross-site requests that have no legitimate reason to reach
312
+ * your origin. This is the modern, tokenless defense against the
313
+ * XS-Leaks class of attacks documented at https://xsleaks.dev/ and
314
+ * complements {@link secureHeaders} (which sets COOP / CORP / CSP
315
+ * response-side) and {@link csrf} (which protects state-changing
316
+ * endpoints from forged submissions).
317
+ *
318
+ * Default policy (matches https://web.dev/articles/fetch-metadata):
319
+ * - Allow when `Sec-Fetch-Site` is missing (non-browser client / legacy
320
+ * browser — covered by other defenses) or one of `same-origin`,
321
+ * `same-site`, `none`.
322
+ * - Allow cross-site top-level navigations (`Sec-Fetch-Mode: navigate`)
323
+ * using a safe method (`GET` / `HEAD`).
324
+ * - Allow cross-site requests whose `Sec-Fetch-Dest` is in a small
325
+ * allowlist of embed-friendly destinations.
326
+ * - Reject everything else with `403 problem+json`.
327
+ *
328
+ * Browsers send `Sec-Fetch-*` since Chrome 76 / Firefox 90 / Safari 16.4,
329
+ * so for modern user agents this guard blocks the typical XS-Leak
330
+ * vectors (`<img>`, `<script>`, `<iframe>`, `fetch` no-cors, ...).
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * import { fetchMetadata } from "@daloyjs/core";
335
+ * app.use(fetchMetadata());
336
+ * ```
337
+ *
338
+ * @param opts - Policy overrides.
339
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
340
+ * @since 0.37.0
341
+ */
342
+ export function fetchMetadata(opts = {}) {
343
+ const allowedDests = new Set(opts.allowedDestinations ?? FETCH_METADATA_DEFAULT_DESTS);
344
+ const allowedNavMethods = new Set((opts.allowedNavigationMethods ?? FETCH_METADATA_DEFAULT_NAV_METHODS).map((m) => m.toUpperCase()));
345
+ const allowedOrigins = new Set(opts.allowedOrigins ?? []);
346
+ const customAllow = opts.allow;
347
+ return {
348
+ beforeHandle(ctx) {
349
+ const req = ctx.request;
350
+ if (customAllow && customAllow(req))
351
+ return;
352
+ const site = req.headers.get("sec-fetch-site");
353
+ // Browser did not send Sec-Fetch-* (legacy UA, server-to-server,
354
+ // curl, ...). Other layers (CORS, CSRF, auth) cover those callers.
355
+ if (!site)
356
+ return;
357
+ if (site === "same-origin" || site === "same-site" || site === "none")
358
+ return;
359
+ // Cross-site from here on (`cross-site` or any future value).
360
+ const origin = req.headers.get("origin");
361
+ if (origin && allowedOrigins.has(origin))
362
+ return;
363
+ const mode = req.headers.get("sec-fetch-mode");
364
+ const method = req.method.toUpperCase();
365
+ if (mode === "navigate" && allowedNavMethods.has(method))
366
+ return;
367
+ const dest = req.headers.get("sec-fetch-dest");
368
+ if (dest && allowedDests.has(dest))
369
+ return;
370
+ throw new ForbiddenError("Cross-site request rejected by fetch-metadata Resource Isolation Policy.");
371
+ },
372
+ };
373
+ }
284
374
  // `cors()` default `allowMethods` narrowed to the read-only set.
285
375
  // State-changing methods (PUT / PATCH / DELETE) require explicit opt-in.
286
376
  // POST stays because it remains a non-CORS-preflighted "simple method" with
@@ -288,6 +378,30 @@ export function secureHeaders(opts = {}) {
288
378
  // preflight method itself and is always accepted, no need to advertise it.
289
379
  const DEFAULT_CORS_METHODS = ["GET", "HEAD", "POST"];
290
380
  const DEFAULT_CORS_ALLOWED_HEADERS = ["content-type", "authorization"];
381
+ /**
382
+ * Append `token` to an existing `Vary` header (or create one) without
383
+ * clobbering values written by other middleware (e.g. `Vary:
384
+ * Accept-Encoding` from `compression()`). `Vary: *` short-circuits to a
385
+ * no-op because the spec treats `*` as "varies on everything".
386
+ *
387
+ * @internal
388
+ */
389
+ function appendVary(headers, token) {
390
+ const existing = headers.get("vary");
391
+ if (!existing) {
392
+ headers.set("vary", token);
393
+ return;
394
+ }
395
+ const lowerToken = token.toLowerCase();
396
+ for (const raw of existing.split(",")) {
397
+ const trimmed = raw.trim();
398
+ if (trimmed === "*")
399
+ return;
400
+ if (trimmed.toLowerCase() === lowerToken)
401
+ return;
402
+ }
403
+ headers.set("vary", `${existing}, ${token}`);
404
+ }
291
405
  /**
292
406
  * Marker stamped on the `Hooks` object returned by {@link cors} so the
293
407
  * framework can detect that a CORS policy has been installed. Used by the
@@ -404,9 +518,17 @@ export function cors(opts) {
404
518
  beforeHandle(ctx) {
405
519
  const origin = ctx.request.headers.get("origin");
406
520
  const allowed = allow(origin);
521
+ // Always advertise that the response depends on `Origin` when the
522
+ // request carried one, even if we decided not to allow it. Otherwise
523
+ // an HTTP cache (CDN, reverse proxy) that fronts the API can serve a
524
+ // response generated for an allowed origin to a different,
525
+ // disallowed origin — the classic CORS cache-poisoning footgun
526
+ // called out in Aikido's "CORS Security: Beyond Basic Configuration"
527
+ // (section 6: Vary: Origin on cached preflight responses).
528
+ if (origin !== null)
529
+ appendVary(ctx.set.headers, "Origin");
407
530
  if (allowed) {
408
531
  ctx.set.headers.set("access-control-allow-origin", allowed);
409
- ctx.set.headers.set("vary", "Origin");
410
532
  if (opts.credentials)
411
533
  ctx.set.headers.set("access-control-allow-credentials", "true");
412
534
  if (exposed)
@@ -414,15 +536,26 @@ export function cors(opts) {
414
536
  }
415
537
  if (ctx.request.method === "OPTIONS") {
416
538
  const h = new Headers();
539
+ // Preflights are themselves cacheable by both browsers and shared
540
+ // caches; vary on Origin and on the preflight-specific request
541
+ // headers so a cache cannot serve one origin's preflight result
542
+ // to another. (See `Vary` discussion in MDN's CORS guide.)
543
+ appendVary(h, "Origin");
544
+ appendVary(h, "Access-Control-Request-Method");
545
+ appendVary(h, "Access-Control-Request-Headers");
417
546
  if (allowed) {
418
547
  h.set("access-control-allow-origin", allowed);
419
- h.set("vary", "Origin");
420
548
  if (opts.credentials)
421
549
  h.set("access-control-allow-credentials", "true");
550
+ // Only advertise the configured method/header allowlist to
551
+ // origins we actually trust. Echoing the policy back to a
552
+ // disallowed origin leaks our CORS configuration to anyone who
553
+ // can send an OPTIONS request and lets attackers map the API's
554
+ // accepted surface area without ever being approved.
555
+ h.set("access-control-allow-methods", methods);
556
+ h.set("access-control-allow-headers", allowedHeaders);
557
+ h.set("access-control-max-age", maxAge);
422
558
  }
423
- h.set("access-control-allow-methods", methods);
424
- h.set("access-control-allow-headers", allowedHeaders);
425
- h.set("access-control-max-age", maxAge);
426
559
  return new Response(null, { status: 204, headers: h });
427
560
  }
428
561
  return undefined;
@@ -1119,4 +1252,3 @@ export function requireScopes(scopes) {
1119
1252
  return hooks;
1120
1253
  }
1121
1254
  export { timingSafeEqual };
1122
- //# sourceMappingURL=middleware.js.map
@@ -166,4 +166,3 @@ export declare function getMultipartShape(s: unknown): {
166
166
  /** Internal: read the file-field options used for OpenAPI documentation. */
167
167
  export declare function getFileFieldOptions(s: unknown): (Required<Pick<FileFieldOptions, "format">> & FileFieldOptions) | undefined;
168
168
  export {};
169
- //# sourceMappingURL=multipart.d.ts.map
package/dist/multipart.js CHANGED
@@ -384,4 +384,3 @@ export function getFileFieldOptions(s) {
384
384
  return undefined;
385
385
  return s[FILE_FIELD_MARKER];
386
386
  }
387
- //# sourceMappingURL=multipart.js.map
package/dist/openapi.d.ts CHANGED
@@ -125,4 +125,3 @@ export declare function generateOpenAPI(app: App, options: OpenAPIOptions): Reco
125
125
  * artifacts. Reads/writes nothing; pure transform.
126
126
  */
127
127
  export declare function openapiToYAML(doc: Record<string, unknown>): string;
128
- //# sourceMappingURL=openapi.d.ts.map
package/dist/openapi.js CHANGED
@@ -498,4 +498,3 @@ export function openapiToYAML(doc) {
498
498
  const result = yamlEmit(doc, "");
499
499
  return result.startsWith("\n") ? result.slice(1) : result;
500
500
  }
501
- //# sourceMappingURL=openapi.js.map
@@ -98,4 +98,3 @@ export interface NodeRedisLike {
98
98
  }
99
99
  /** Wrap a [`node-redis`](https://github.com/redis/node-redis) v4+ client. */
100
100
  export declare function nodeRedisAdapter(client: NodeRedisLike): RedisCommands;
101
- //# sourceMappingURL=rate-limit-redis.d.ts.map
@@ -114,4 +114,3 @@ export function nodeRedisAdapter(client) {
114
114
  },
115
115
  };
116
116
  }
117
- //# sourceMappingURL=rate-limit-redis.js.map
package/dist/router.d.ts CHANGED
@@ -36,4 +36,3 @@ export declare class Router<T> {
36
36
  allowedMethods(path: string): HttpMethod[];
37
37
  private walk;
38
38
  }
39
- //# sourceMappingURL=router.d.ts.map
package/dist/router.js CHANGED
@@ -136,4 +136,3 @@ function splitPath(path) {
136
136
  return [];
137
137
  return clean.replace(/^\//, "").split("/");
138
138
  }
139
- //# sourceMappingURL=router.js.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * First-party `safeRedirect()` helper that refuses open-redirect inputs.
3
+ *
4
+ * Open redirects (Aikido "Top 10 app security problems" #10, OWASP
5
+ * "Unvalidated Redirects and Forwards") happen when an app blindly trusts
6
+ * a `?next=...` / `?returnTo=...` query parameter and emits a `Location`
7
+ * header pointing wherever the attacker wants. The fix the industry has
8
+ * settled on is the same one Daloy enforces here: validate every
9
+ * candidate URL against an explicit allowlist of internal paths and
10
+ * external origins **before** building the redirect response.
11
+ *
12
+ * Defaults are deliberately strict:
13
+ *
14
+ * - Same-origin paths must start with `/` and must not start with `//`
15
+ * or `/\` (which browsers interpret as protocol-relative URLs that
16
+ * escape your origin).
17
+ * - Backslashes, control characters, and `CR`/`LF` are rejected to
18
+ * stop response-splitting and homograph tricks.
19
+ * - Absolute URLs are only allowed when their `origin` exactly matches
20
+ * one of the entries in `allowedOrigins`.
21
+ * - `javascript:`, `data:`, `vbscript:`, and `file:` schemes are always
22
+ * refused, even if the caller wrote a bug into the allowlist.
23
+ * - The default status is `303 See Other`, which is the
24
+ * POST-redirect-GET-safe choice. Override with `status` when you
25
+ * genuinely need `301`/`302`/`307`/`308`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { safeRedirect } from "@daloyjs/core";
30
+ *
31
+ * app.get("/login/callback", (ctx) => {
32
+ * const next = new URL(ctx.request.url).searchParams.get("next") ?? "/";
33
+ * return safeRedirect(next, {
34
+ * allowedPaths: ["/", "/dashboard", "/account"],
35
+ * allowedOrigins: ["https://app.example.com"],
36
+ * fallback: "/",
37
+ * });
38
+ * });
39
+ * ```
40
+ *
41
+ * @since 0.35.1
42
+ */
43
+ /** Reason an open-redirect candidate was refused. */
44
+ export type SafeRedirectBlockReason = "empty-target" | "invalid-control-characters" | "protocol-relative" | "backslash-path" | "path-not-allowed" | "origin-not-allowed" | "scheme-not-allowed" | "parse-failed";
45
+ /** Thrown when {@link safeRedirect} refuses a candidate URL and no `fallback` is configured. */
46
+ export declare class OpenRedirectBlockedError extends Error {
47
+ readonly reason: SafeRedirectBlockReason;
48
+ readonly target: string;
49
+ constructor(reason: SafeRedirectBlockReason, target: string);
50
+ }
51
+ /** HTTP redirect status codes accepted by {@link safeRedirect}. */
52
+ export type SafeRedirectStatus = 301 | 302 | 303 | 307 | 308;
53
+ /** Options for {@link safeRedirect}. */
54
+ export interface SafeRedirectOptions {
55
+ /**
56
+ * Internal paths that may be used as redirect targets. Each entry MUST
57
+ * begin with `/`. Matching is exact on `pathname` — query strings and
58
+ * fragments on the candidate are preserved but ignored for matching.
59
+ *
60
+ * Use `"/*"` as a wildcard only when you really mean "any same-origin
61
+ * path is fine" — it disables path-level allowlisting.
62
+ */
63
+ allowedPaths?: readonly string[];
64
+ /**
65
+ * External origins (scheme + host + optional port, e.g.
66
+ * `https://app.example.com`) that may be used as redirect targets.
67
+ * Compared with strict equality against `new URL(target).origin`.
68
+ */
69
+ allowedOrigins?: readonly string[];
70
+ /**
71
+ * Path to redirect to when the candidate is rejected. MUST begin with
72
+ * `/`. When omitted, rejected candidates throw
73
+ * {@link OpenRedirectBlockedError} instead.
74
+ */
75
+ fallback?: string;
76
+ /** HTTP status code. Defaults to `303` (See Other). */
77
+ status?: SafeRedirectStatus;
78
+ /** Extra response headers to merge in. `Location` is always overwritten. */
79
+ headers?: HeadersInit;
80
+ }
81
+ /**
82
+ * Build a redirect `Response` after validating the target against an
83
+ * explicit allowlist. Throws {@link OpenRedirectBlockedError} when the
84
+ * candidate fails validation and no `fallback` is configured.
85
+ *
86
+ * @param target - User-supplied URL candidate (path or absolute URL).
87
+ * @param options - Allowlist + response configuration.
88
+ *
89
+ * @since 0.35.1
90
+ */
91
+ export declare function safeRedirect(target: string, options?: SafeRedirectOptions): Response;