@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.
- package/README.md +151 -32
- package/dist/adapters/bun.d.ts +0 -1
- package/dist/adapters/bun.js +0 -1
- package/dist/adapters/cloudflare.d.ts +0 -1
- package/dist/adapters/cloudflare.js +0 -1
- package/dist/adapters/deno.d.ts +0 -1
- package/dist/adapters/deno.js +0 -1
- package/dist/adapters/fastly.d.ts +0 -1
- package/dist/adapters/fastly.js +0 -1
- package/dist/adapters/lambda.d.ts +0 -1
- package/dist/adapters/lambda.js +0 -1
- package/dist/adapters/node.d.ts +0 -1
- package/dist/adapters/node.js +175 -35
- package/dist/adapters/vercel.d.ts +0 -1
- package/dist/adapters/vercel.js +0 -1
- package/dist/app.d.ts +18 -1
- package/dist/app.js +396 -118
- package/dist/banner.d.ts +0 -1
- package/dist/banner.js +0 -1
- package/dist/cli.d.ts +32 -1
- package/dist/cli.js +62 -5
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/combine.d.ts +0 -1
- package/dist/combine.js +0 -1
- package/dist/compression.d.ts +0 -1
- package/dist/compression.js +0 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/conn-info.d.ts +0 -1
- package/dist/conn-info.js +0 -1
- package/dist/contract.d.ts +0 -1
- package/dist/contract.js +0 -1
- package/dist/cookie.d.ts +0 -1
- package/dist/cookie.js +0 -1
- package/dist/dependency.d.ts +0 -1
- package/dist/dependency.js +0 -1
- package/dist/discriminator.d.ts +0 -1
- package/dist/discriminator.js +0 -1
- package/dist/docs.d.ts +0 -1
- package/dist/docs.js +0 -1
- package/dist/errors.d.ts +0 -1
- package/dist/errors.js +0 -1
- package/dist/etag.d.ts +0 -1
- package/dist/etag.js +0 -1
- package/dist/fetch-guard.d.ts +50 -6
- package/dist/fetch-guard.js +83 -18
- package/dist/hashing.d.ts +0 -1
- package/dist/hashing.js +0 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/ip-restriction.d.ts +0 -1
- package/dist/ip-restriction.js +0 -1
- package/dist/jwk.d.ts +0 -1
- package/dist/jwk.js +0 -1
- package/dist/jwt.d.ts +15 -1
- package/dist/jwt.js +56 -3
- package/dist/load-shedding.d.ts +0 -1
- package/dist/load-shedding.js +0 -1
- package/dist/logger.d.ts +0 -1
- package/dist/logger.js +0 -1
- package/dist/middleware.d.ts +78 -1
- package/dist/middleware.js +150 -18
- package/dist/multipart.d.ts +0 -1
- package/dist/multipart.js +0 -1
- package/dist/openapi.d.ts +0 -1
- package/dist/openapi.js +0 -1
- package/dist/rate-limit-redis.d.ts +0 -1
- package/dist/rate-limit-redis.js +0 -1
- package/dist/router.d.ts +0 -1
- package/dist/router.js +0 -1
- package/dist/safe-redirect.d.ts +91 -0
- package/dist/safe-redirect.js +168 -0
- package/dist/sbom.cdx.json +13 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/schema.d.ts +0 -1
- package/dist/schema.js +0 -1
- package/dist/security-schemes.d.ts +0 -1
- package/dist/security-schemes.js +0 -1
- package/dist/security.d.ts +134 -3
- package/dist/security.js +265 -6
- package/dist/session.d.ts +0 -1
- package/dist/session.js +0 -1
- package/dist/streaming.d.ts +0 -1
- package/dist/streaming.js +0 -1
- package/dist/subdomains.d.ts +0 -1
- package/dist/subdomains.js +0 -1
- package/dist/time-claims.d.ts +0 -1
- package/dist/time-claims.js +0 -1
- package/dist/tracing.d.ts +0 -1
- package/dist/tracing.js +0 -1
- package/dist/types.d.ts +0 -1
- package/dist/types.js +0 -1
- package/dist/websocket.d.ts +0 -1
- package/dist/websocket.js +0 -1
- package/package.json +35 -15
- package/dist/adapters/bun.d.ts.map +0 -1
- package/dist/adapters/bun.js.map +0 -1
- package/dist/adapters/cloudflare.d.ts.map +0 -1
- package/dist/adapters/cloudflare.js.map +0 -1
- package/dist/adapters/deno.d.ts.map +0 -1
- package/dist/adapters/deno.js.map +0 -1
- package/dist/adapters/fastly.d.ts.map +0 -1
- package/dist/adapters/fastly.js.map +0 -1
- package/dist/adapters/lambda.d.ts.map +0 -1
- package/dist/adapters/lambda.js.map +0 -1
- package/dist/adapters/node.d.ts.map +0 -1
- package/dist/adapters/node.js.map +0 -1
- package/dist/adapters/vercel.d.ts.map +0 -1
- package/dist/adapters/vercel.js.map +0 -1
- package/dist/app.d.ts.map +0 -1
- package/dist/app.js.map +0 -1
- package/dist/banner.d.ts.map +0 -1
- package/dist/banner.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/combine.d.ts.map +0 -1
- package/dist/combine.js.map +0 -1
- package/dist/compression.d.ts.map +0 -1
- package/dist/compression.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/conn-info.d.ts.map +0 -1
- package/dist/conn-info.js.map +0 -1
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js.map +0 -1
- package/dist/cookie.d.ts.map +0 -1
- package/dist/cookie.js.map +0 -1
- package/dist/dependency.d.ts.map +0 -1
- package/dist/dependency.js.map +0 -1
- package/dist/discriminator.d.ts.map +0 -1
- package/dist/discriminator.js.map +0 -1
- package/dist/docs.d.ts.map +0 -1
- package/dist/docs.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/etag.d.ts.map +0 -1
- package/dist/etag.js.map +0 -1
- package/dist/fetch-guard.d.ts.map +0 -1
- package/dist/fetch-guard.js.map +0 -1
- package/dist/hashing.d.ts.map +0 -1
- package/dist/hashing.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/ip-restriction.d.ts.map +0 -1
- package/dist/ip-restriction.js.map +0 -1
- package/dist/jwk.d.ts.map +0 -1
- package/dist/jwk.js.map +0 -1
- package/dist/jwt.d.ts.map +0 -1
- package/dist/jwt.js.map +0 -1
- package/dist/load-shedding.d.ts.map +0 -1
- package/dist/load-shedding.js.map +0 -1
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/middleware.d.ts.map +0 -1
- package/dist/middleware.js.map +0 -1
- package/dist/multipart.d.ts.map +0 -1
- package/dist/multipart.js.map +0 -1
- package/dist/openapi.d.ts.map +0 -1
- package/dist/openapi.js.map +0 -1
- package/dist/rate-limit-redis.d.ts.map +0 -1
- package/dist/rate-limit-redis.js.map +0 -1
- package/dist/router.d.ts.map +0 -1
- package/dist/router.js.map +0 -1
- package/dist/schema.d.ts.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/security-schemes.d.ts.map +0 -1
- package/dist/security-schemes.js.map +0 -1
- package/dist/security.d.ts.map +0 -1
- package/dist/security.js.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js.map +0 -1
- package/dist/streaming.d.ts.map +0 -1
- package/dist/streaming.js.map +0 -1
- package/dist/subdomains.d.ts.map +0 -1
- package/dist/subdomains.js.map +0 -1
- package/dist/time-claims.d.ts.map +0 -1
- package/dist/time-claims.js.map +0 -1
- package/dist/tracing.d.ts.map +0 -1
- package/dist/tracing.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/websocket.d.ts.map +0 -1
- package/dist/websocket.js.map +0 -1
package/dist/ip-restriction.js
CHANGED
package/dist/jwk.d.ts
CHANGED
package/dist/jwk.js
CHANGED
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
|
-
|
|
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
|
package/dist/load-shedding.d.ts
CHANGED
package/dist/load-shedding.js
CHANGED
package/dist/logger.d.ts
CHANGED
package/dist/logger.js
CHANGED
package/dist/middleware.d.ts
CHANGED
|
@@ -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
|
package/dist/middleware.js
CHANGED
|
@@ -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
|
|
258
|
-
|
|
259
|
-
|
|
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 (
|
|
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
|
-
|
|
275
|
-
|
|
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
|
package/dist/multipart.d.ts
CHANGED
|
@@ -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
package/dist/openapi.d.ts
CHANGED
package/dist/openapi.js
CHANGED
package/dist/rate-limit-redis.js
CHANGED
package/dist/router.d.ts
CHANGED
package/dist/router.js
CHANGED
|
@@ -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;
|