@daloyjs/core 1.0.0-rc.5 → 1.0.0-rc.6
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 +22 -12
- package/dist/adapters/bun.js +1 -2
- package/dist/adapters/node.js +16 -30
- package/dist/app.d.ts +5 -1
- package/dist/app.js +74 -6
- package/dist/auto-ban.js +1 -3
- package/dist/cli.js +9 -6
- package/dist/config.js +1 -3
- package/dist/errors.js +2 -5
- package/dist/etag.js +12 -2
- package/dist/geo-block.js +4 -9
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.js +3 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/ip-reputation.js +1 -1
- package/dist/ip-restriction.js +3 -12
- package/dist/jwt.js +12 -14
- package/dist/logger.js +1 -3
- package/dist/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/safe-redirect.js +3 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/waf.js +40 -8
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- package/package.json +2 -2
package/dist/ip-restriction.js
CHANGED
|
@@ -40,8 +40,7 @@ export function ipRestriction(opts) {
|
|
|
40
40
|
}
|
|
41
41
|
const allow = (opts.allow ?? []).map(compileCidrMatcher);
|
|
42
42
|
const deny = (opts.deny ?? []).map(compileCidrMatcher);
|
|
43
|
-
const resolveIp = opts.resolveIp ??
|
|
44
|
-
(opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
|
|
43
|
+
const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
|
|
45
44
|
const message = opts.message ?? "IP address not permitted";
|
|
46
45
|
return {
|
|
47
46
|
beforeHandle(ctx) {
|
|
@@ -200,11 +199,7 @@ function parseIPv6(input) {
|
|
|
200
199
|
return undefined;
|
|
201
200
|
const hi = (v4.bytes[0] << 8) | v4.bytes[1];
|
|
202
201
|
const lo = (v4.bytes[2] << 8) | v4.bytes[3];
|
|
203
|
-
working =
|
|
204
|
-
working.slice(0, lastColon + 1) +
|
|
205
|
-
hi.toString(16) +
|
|
206
|
-
":" +
|
|
207
|
-
lo.toString(16);
|
|
202
|
+
working = working.slice(0, lastColon + 1) + hi.toString(16) + ":" + lo.toString(16);
|
|
208
203
|
}
|
|
209
204
|
const parts = working.split("::");
|
|
210
205
|
if (parts.length > 2)
|
|
@@ -217,11 +212,7 @@ function parseIPv6(input) {
|
|
|
217
212
|
if (parts.length === 1 && explicit !== 8)
|
|
218
213
|
return undefined;
|
|
219
214
|
const missing = parts.length === 2 ? 8 - explicit : 0;
|
|
220
|
-
const groups = [
|
|
221
|
-
...headGroups,
|
|
222
|
-
...Array.from({ length: missing }, () => "0"),
|
|
223
|
-
...tailGroups,
|
|
224
|
-
];
|
|
215
|
+
const groups = [...headGroups, ...Array.from({ length: missing }, () => "0"), ...tailGroups];
|
|
225
216
|
if (groups.length !== 8)
|
|
226
217
|
return undefined;
|
|
227
218
|
const bytes = new Uint8Array(16);
|
package/dist/jwt.js
CHANGED
|
@@ -18,11 +18,7 @@
|
|
|
18
18
|
* @since 0.21.0
|
|
19
19
|
*/
|
|
20
20
|
import { assertTemporalClaims, TemporalClaimError } from "./time-claims.js";
|
|
21
|
-
const SYMMETRIC = new Set([
|
|
22
|
-
"HS256",
|
|
23
|
-
"HS384",
|
|
24
|
-
"HS512",
|
|
25
|
-
]);
|
|
21
|
+
const SYMMETRIC = new Set(["HS256", "HS384", "HS512"]);
|
|
26
22
|
const ASYMMETRIC = new Set([
|
|
27
23
|
"RS256",
|
|
28
24
|
"RS384",
|
|
@@ -254,10 +250,14 @@ export function createJwtSigner(opts) {
|
|
|
254
250
|
opts.key.byteLength < MIN_HS_KEY_BYTES) {
|
|
255
251
|
throw new JwtError("weak_hs_secret", `jwt(): ${alg} secret must be at least ${MIN_HS_KEY_BYTES} bytes (RFC 7518 §3.2); got ${opts.key.byteLength}.`);
|
|
256
252
|
}
|
|
257
|
-
if (typeof opts.maxLifetimeSeconds !== "number" ||
|
|
253
|
+
if (typeof opts.maxLifetimeSeconds !== "number" ||
|
|
254
|
+
!Number.isFinite(opts.maxLifetimeSeconds) ||
|
|
255
|
+
opts.maxLifetimeSeconds <= 0) {
|
|
258
256
|
throw new JwtError("missing_max_lifetime", "jwt(): maxLifetimeSeconds is required and must be a positive number — a token that never expires is wrong in every threat model.");
|
|
259
257
|
}
|
|
260
|
-
if (opts.acknowledgeNoExp === true &&
|
|
258
|
+
if (opts.acknowledgeNoExp === true &&
|
|
259
|
+
isProductionEnv(opts.env) &&
|
|
260
|
+
opts.secureDefaults !== false) {
|
|
261
261
|
throw new JwtError("ack_no_exp_refused_in_production", "jwt(): acknowledgeNoExp: true is refused in production under secureDefaults — every issued JWT must carry an exp claim.");
|
|
262
262
|
}
|
|
263
263
|
const resolved = (async () => {
|
|
@@ -359,18 +359,16 @@ export function createJwtVerifier(opts) {
|
|
|
359
359
|
allow.add(alg);
|
|
360
360
|
}
|
|
361
361
|
const hasSym = [...allow].some((a) => SYMMETRIC.has(a));
|
|
362
|
-
if (hasSym &&
|
|
363
|
-
opts.key instanceof Uint8Array &&
|
|
364
|
-
opts.key.byteLength < MIN_HS_KEY_BYTES) {
|
|
362
|
+
if (hasSym && opts.key instanceof Uint8Array && opts.key.byteLength < MIN_HS_KEY_BYTES) {
|
|
365
363
|
throw new JwtError("weak_hs_secret", `jwt(): HS* secret must be at least ${MIN_HS_KEY_BYTES} bytes (RFC 7518 §3.2); got ${opts.key.byteLength}.`);
|
|
366
364
|
}
|
|
367
|
-
if (hasSym &&
|
|
368
|
-
opts.refuseSymmetricWithJwk !== false &&
|
|
369
|
-
looksLikeJwkSource(opts.key)) {
|
|
365
|
+
if (hasSym && opts.refuseSymmetricWithJwk !== false && looksLikeJwkSource(opts.key)) {
|
|
370
366
|
throw new JwtError("sym_with_jwk_refused", "jwt(): symmetric algorithms (HS*) mixed with a JWK / JWKS key source are refused — this is the documented JWKS confused-deputy attack. Use asymmetric algorithms (RS/PS/ES/EdDSA), or pass refuseSymmetricWithJwk: false to override (not recommended).");
|
|
371
367
|
}
|
|
372
368
|
if (opts.clockSkewSeconds !== undefined) {
|
|
373
|
-
if (typeof opts.clockSkewSeconds !== "number" ||
|
|
369
|
+
if (typeof opts.clockSkewSeconds !== "number" ||
|
|
370
|
+
!Number.isFinite(opts.clockSkewSeconds) ||
|
|
371
|
+
opts.clockSkewSeconds < 0) {
|
|
374
372
|
throw new JwtError("invalid_clock_skew", "jwt(): clockSkewSeconds must be a non-negative finite number.");
|
|
375
373
|
}
|
|
376
374
|
}
|
package/dist/logger.js
CHANGED
|
@@ -377,9 +377,7 @@ function isSensitiveUrlQueryKey(lowerKey) {
|
|
|
377
377
|
* @since 1.0.0
|
|
378
378
|
*/
|
|
379
379
|
export function sanitizeUrlForLog(url) {
|
|
380
|
-
if (url.indexOf("?") === -1 &&
|
|
381
|
-
url.indexOf("#") === -1 &&
|
|
382
|
-
url.indexOf("@") === -1) {
|
|
380
|
+
if (url.indexOf("?") === -1 && url.indexOf("#") === -1 && url.indexOf("@") === -1) {
|
|
383
381
|
return url;
|
|
384
382
|
}
|
|
385
383
|
try {
|
package/dist/multipart.js
CHANGED
|
@@ -55,9 +55,7 @@ function isBlobLike(v) {
|
|
|
55
55
|
if (v == null || typeof v !== "object")
|
|
56
56
|
return false;
|
|
57
57
|
const b = v;
|
|
58
|
-
return (typeof b.size === "number" &&
|
|
59
|
-
typeof b.type === "string" &&
|
|
60
|
-
typeof b.arrayBuffer === "function");
|
|
58
|
+
return (typeof b.size === "number" && typeof b.type === "string" && typeof b.arrayBuffer === "function");
|
|
61
59
|
}
|
|
62
60
|
function mimeMatches(actual, pattern) {
|
|
63
61
|
const a = actual.toLowerCase();
|
|
@@ -137,11 +135,7 @@ function normalizeCustomMagicSignature(value) {
|
|
|
137
135
|
throw new Error("fileField(): magicBytes.bytes entries must be integers in [0, 255].");
|
|
138
136
|
}
|
|
139
137
|
}
|
|
140
|
-
const mimes = value.mime === undefined
|
|
141
|
-
? []
|
|
142
|
-
: typeof value.mime === "string"
|
|
143
|
-
? [value.mime]
|
|
144
|
-
: [...value.mime];
|
|
138
|
+
const mimes = value.mime === undefined ? [] : typeof value.mime === "string" ? [value.mime] : [...value.mime];
|
|
145
139
|
return {
|
|
146
140
|
label: value.label ?? bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(" "),
|
|
147
141
|
mimes,
|
|
@@ -186,9 +180,10 @@ function asciiPrefix(bytes) {
|
|
|
186
180
|
const byte = bytes[i];
|
|
187
181
|
// Keep printable ASCII + common whitespace; replace everything else with
|
|
188
182
|
// a space so keyword searches still work across NULs / UTF-16 padding.
|
|
189
|
-
out +=
|
|
190
|
-
|
|
191
|
-
|
|
183
|
+
out +=
|
|
184
|
+
byte === 0x09 || byte === 0x0a || byte === 0x0d || (byte >= 0x20 && byte <= 0x7e)
|
|
185
|
+
? String.fromCharCode(byte)
|
|
186
|
+
: " ";
|
|
192
187
|
}
|
|
193
188
|
return out.toLowerCase();
|
|
194
189
|
}
|
|
@@ -200,7 +195,9 @@ function detectScriptableImagePayload(bytes) {
|
|
|
200
195
|
}
|
|
201
196
|
// ImageMagick MVG / MSL — vector / scripting formats that can shell out
|
|
202
197
|
// through the `url:`, `ephemeral:`, `msl:` coders (ImageTragick).
|
|
203
|
-
if (prefix.includes("push graphic-context") ||
|
|
198
|
+
if (prefix.includes("push graphic-context") ||
|
|
199
|
+
prefix.startsWith("<msl>") ||
|
|
200
|
+
prefix.includes("<image ")) {
|
|
204
201
|
return "mvg-or-msl";
|
|
205
202
|
}
|
|
206
203
|
// SVG — XML-based and routinely carries `<script>` / external references.
|
package/dist/openapi.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export { httpBearerScheme, httpBasicScheme, apiKeyScheme, oauth2Scheme, openIdCo
|
|
|
14
14
|
export type { ApiKeyLocation, ApiKeyScheme, ApiKeySchemeOptions, HttpBasicScheme, HttpBasicSchemeOptions, HttpBearerScheme, HttpBearerSchemeOptions, OAuth2AuthorizationCodeFlow, OAuth2ClientCredentialsFlow, OAuth2Flows, OAuth2ImplicitFlow, OAuth2PasswordFlow, OAuth2Scheme, OAuth2SchemeOptions, OpenIdConnectScheme, OpenIdConnectSchemeOptions, SecurityScheme, } from "./security-schemes.js";
|
|
15
15
|
export { discriminator, discriminatedUnion } from "./discriminator.js";
|
|
16
16
|
export type { DiscriminatorObject, DiscriminatedUnion, DiscriminatedUnionOptions, } from "./discriminator.js";
|
|
17
|
-
export type { CallbackDefinition, CallbackMap, CallbackOperation
|
|
17
|
+
export type { CallbackDefinition, CallbackMap, CallbackOperation } from "./types.js";
|
|
18
18
|
/** OpenAPI [Info Object](https://spec.openapis.org/oas/v3.1.0#info-object) header fields. */
|
|
19
19
|
export interface OpenAPIInfo {
|
|
20
20
|
/** Human-readable API title shown by Swagger UI / Scalar. */
|
package/dist/openapi.js
CHANGED
|
@@ -102,8 +102,8 @@ function buildOperation(route, path) {
|
|
|
102
102
|
const mergedTags = mergeTags(route.tags, meta?.tags);
|
|
103
103
|
const op = {
|
|
104
104
|
...(route.operationId ? { operationId: route.operationId } : {}),
|
|
105
|
-
...(route.summary ?? meta?.summary ? { summary: route.summary ?? meta?.summary } : {}),
|
|
106
|
-
...(route.description ?? meta?.description
|
|
105
|
+
...((route.summary ?? meta?.summary) ? { summary: route.summary ?? meta?.summary } : {}),
|
|
106
|
+
...((route.description ?? meta?.description)
|
|
107
107
|
? { description: route.description ?? meta?.description }
|
|
108
108
|
: {}),
|
|
109
109
|
...(mergedTags.length ? { tags: mergedTags } : {}),
|
|
@@ -64,10 +64,10 @@ export interface RedisRateLimitStoreOptions {
|
|
|
64
64
|
*/
|
|
65
65
|
prefix?: string;
|
|
66
66
|
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
* Called when the underlying Redis call throws. The default behavior is
|
|
68
|
+
* fail-open, which allows the request and reports it as the first hit in a
|
|
69
|
+
* fresh local window. Override to fail-closed or to wire into your
|
|
70
|
+
* structured logger.
|
|
71
71
|
*/
|
|
72
72
|
onError?: (err: unknown) => "fail-open" | "fail-closed";
|
|
73
73
|
}
|
package/dist/response-cache.d.ts
CHANGED
|
@@ -32,6 +32,31 @@
|
|
|
32
32
|
* the rate-limit store, with an in-memory {@link MemoryResponseCacheStore}
|
|
33
33
|
* default; supply a shared backend (e.g. Redis) for multi-instance fleets.
|
|
34
34
|
*
|
|
35
|
+
* ## Cross-principal isolation (CWE-524)
|
|
36
|
+
*
|
|
37
|
+
* A shared response cache is only as safe as its key. Anything that varies the
|
|
38
|
+
* response but not the key becomes a cross-principal disclosure: the next caller
|
|
39
|
+
* of the same URL receives the previous caller's private body. This module is
|
|
40
|
+
* fail-closed on every principal dimension the framework can see:
|
|
41
|
+
*
|
|
42
|
+
* - **Authority.** The key is built from the *effective request URI* (scheme +
|
|
43
|
+
* authority + path + query) per RFC 9111 §4, so one process serving several
|
|
44
|
+
* hostnames (vanity domains, subdomain-per-customer) never shares an entry
|
|
45
|
+
* across them.
|
|
46
|
+
* - **Credentials.** Requests carrying `Authorization` **or** `Cookie` bypass
|
|
47
|
+
* the shared cache entirely unless the caller is identified (see
|
|
48
|
+
* {@link ResponseCacheOptions.principal}) or the header is explicitly declared
|
|
49
|
+
* shareable (see {@link ResponseCacheOptions.cacheAuthenticatedRequests}).
|
|
50
|
+
* - **Tenant.** When `tenancy()` has resolved a tenant for the request, that
|
|
51
|
+
* tenant is folded into the key automatically — no `keyGenerator` wiring
|
|
52
|
+
* required, and it applies to a custom `keyGenerator` too.
|
|
53
|
+
* - **Declared variants.** A response's own `Vary` header is honoured as a
|
|
54
|
+
* secondary key (RFC 9111 §4.1): an entry is replayed only to a request whose
|
|
55
|
+
* values for those fields match the ones it was stored with. `cors()` emits
|
|
56
|
+
* `Vary: Origin` and `compression()` emits `Vary: Accept-Encoding`, so
|
|
57
|
+
* without this one caller's `Access-Control-Allow-Origin` — or their gzipped
|
|
58
|
+
* body — would be served to the next. `Vary: *` is never stored.
|
|
59
|
+
*
|
|
35
60
|
* This module is dependency-free and uses only Web Standard
|
|
36
61
|
* `Request`/`Response` + `Headers`, so it runs unchanged on Node, Bun, Deno,
|
|
37
62
|
* Cloudflare Workers, and Vercel.
|
|
@@ -40,6 +65,15 @@
|
|
|
40
65
|
* @since 0.37.0
|
|
41
66
|
*/
|
|
42
67
|
import type { BaseContext, Hooks } from "./types.js";
|
|
68
|
+
/**
|
|
69
|
+
* Marker stamped on the `Hooks` object returned by {@link responseCache}, so the
|
|
70
|
+
* `App` boot guard can detect a cache mounted *ahead of* `tenancy()` — an order
|
|
71
|
+
* in which the tenant is not yet in `ctx.state` when the cache key is built, and
|
|
72
|
+
* automatic tenant partitioning therefore cannot protect the entry.
|
|
73
|
+
*
|
|
74
|
+
* @since 1.0.0
|
|
75
|
+
*/
|
|
76
|
+
export declare const RESPONSE_CACHE_HOOK_MARKER: unique symbol;
|
|
43
77
|
/**
|
|
44
78
|
* Test-only helper that clears the process-wide shared stores used by
|
|
45
79
|
* `responseCache({ groupId })`. Not part of the documented public API.
|
|
@@ -64,6 +98,22 @@ export interface CachedResponse {
|
|
|
64
98
|
freshUntil: number;
|
|
65
99
|
/** End of the stale-while-revalidate window as ms since epoch. */
|
|
66
100
|
staleUntil: number;
|
|
101
|
+
/**
|
|
102
|
+
* Lower-cased request-header names from the stored response's `Vary` header —
|
|
103
|
+
* the secondary cache key per RFC 9111 §4.1. Absent when the response
|
|
104
|
+
* declared no `Vary`.
|
|
105
|
+
*
|
|
106
|
+
* @since 1.0.0
|
|
107
|
+
*/
|
|
108
|
+
vary?: string[];
|
|
109
|
+
/**
|
|
110
|
+
* The values {@link vary}'s fields had on the request that produced this
|
|
111
|
+
* entry, length-prefix encoded. A stored entry is only reusable for a request
|
|
112
|
+
* whose values re-encode identically.
|
|
113
|
+
*
|
|
114
|
+
* @since 1.0.0
|
|
115
|
+
*/
|
|
116
|
+
varyKey?: string;
|
|
67
117
|
}
|
|
68
118
|
/**
|
|
69
119
|
* Pluggable persistence backend for {@link responseCache}. All methods may be
|
|
@@ -118,13 +168,62 @@ export interface ResponseCacheOptions {
|
|
|
118
168
|
* Request header names whose values partition the cache (e.g.
|
|
119
169
|
* `["accept-language"]`). Their values are folded into the cache key.
|
|
120
170
|
* Default: none.
|
|
171
|
+
*
|
|
172
|
+
* @remarks This is the *proactive* dimension list, applied to every request
|
|
173
|
+
* before the handler runs. It is independent of — and additive to — the
|
|
174
|
+
* `Vary` header a response declares for itself, which the cache always
|
|
175
|
+
* honours as a secondary key (see {@link responseCache}).
|
|
121
176
|
*/
|
|
122
177
|
varyHeaders?: string[];
|
|
123
178
|
/**
|
|
124
|
-
*
|
|
179
|
+
* Extra response headers to drop before an entry is stored, on top of the
|
|
180
|
+
* built-in hop-by-hop / per-request set (`Age`, `Connection`,
|
|
181
|
+
* `Transfer-Encoding`, `X-Request-Id`, …).
|
|
182
|
+
*
|
|
183
|
+
* Supply the name of a custom correlation or tracing header so it is not
|
|
184
|
+
* frozen into the entry and replayed to every later caller — for example
|
|
185
|
+
* `requestId({ header: "x-correlation-id" })` pairs with
|
|
186
|
+
* `excludeHeaders: ["x-correlation-id"]`.
|
|
187
|
+
*
|
|
188
|
+
* @since 1.0.0
|
|
189
|
+
*/
|
|
190
|
+
excludeHeaders?: readonly string[];
|
|
191
|
+
/**
|
|
192
|
+
* Derive the cache key **body** from the request. Default: method + the
|
|
193
|
+
* effective request URI (scheme + authority + path + query) +
|
|
125
194
|
* {@link varyHeaders} values. Return `null` to skip caching for this request.
|
|
195
|
+
*
|
|
196
|
+
* The resolved tenant and {@link principal} partition is applied *around*
|
|
197
|
+
* whatever this returns, so a custom generator does not have to (and should
|
|
198
|
+
* not bother to) fold them in itself — it cannot accidentally widen the
|
|
199
|
+
* partition below what the framework knows about the caller.
|
|
126
200
|
*/
|
|
127
201
|
keyGenerator?: (ctx: BaseContext<any, any>) => string | null;
|
|
202
|
+
/**
|
|
203
|
+
* Identify the caller, so responses to credentialed requests can be cached
|
|
204
|
+
* *per principal* instead of bypassing the cache.
|
|
205
|
+
*
|
|
206
|
+
* Return a stable id for the calling principal (user id, tenant id, API-key
|
|
207
|
+
* fingerprint — never the raw credential), or `null` / `undefined` when the
|
|
208
|
+
* request is anonymous. The returned id is folded into the cache key.
|
|
209
|
+
*
|
|
210
|
+
* This is what makes cookie-authenticated caching safe: a session cookie
|
|
211
|
+
* identifies a user that the cache key would otherwise ignore, so without a
|
|
212
|
+
* `principal` such a request is not cached at all.
|
|
213
|
+
*
|
|
214
|
+
* ```ts
|
|
215
|
+
* responseCache({
|
|
216
|
+
* ttlSeconds: 30,
|
|
217
|
+
* principal: (ctx) => ctx.state.session?.get<string>("userId") ?? null,
|
|
218
|
+
* });
|
|
219
|
+
* ```
|
|
220
|
+
*
|
|
221
|
+
* @remarks Returning `null` for a request that *does* carry credentials is
|
|
222
|
+
* treated as "cannot identify this caller", and the request bypasses the cache
|
|
223
|
+
* rather than sharing an anonymous entry.
|
|
224
|
+
* @since 1.0.0
|
|
225
|
+
*/
|
|
226
|
+
principal?: (ctx: BaseContext<any, any>) => string | null | undefined;
|
|
128
227
|
/**
|
|
129
228
|
* Maximum response body size (bytes) the middleware will buffer and store.
|
|
130
229
|
* Larger responses pass through uncached. Default: `1048576` (1 MiB).
|
|
@@ -142,33 +241,79 @@ export interface ResponseCacheOptions {
|
|
|
142
241
|
*/
|
|
143
242
|
groupId?: string;
|
|
144
243
|
/**
|
|
145
|
-
* Whether to cache responses to requests that carry an
|
|
146
|
-
* header. Default: `false
|
|
244
|
+
* Whether to cache responses to requests that carry credentials — an
|
|
245
|
+
* `Authorization` header or a `Cookie` header. Default: `false` for both.
|
|
246
|
+
*
|
|
247
|
+
* A shared response cache keyed on the request URI does not include the
|
|
248
|
+
* credential, so caching a credentialed response would serve one user's
|
|
249
|
+
* private data to the next caller of the same URL (CWE-524 — cross-principal
|
|
250
|
+
* cached-response disclosure). Per RFC 9111 §3.5 a shared cache MUST NOT
|
|
251
|
+
* reuse a response to an `Authorization`-bearing request unless explicitly
|
|
252
|
+
* permitted; `Cookie` is treated the same way because a session cookie is the
|
|
253
|
+
* single most common way a response is made private.
|
|
147
254
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* (CWE-524 — cross-tenant cached-response disclosure). For that reason, and
|
|
152
|
-
* per RFC 9111 §3.5 (a shared cache MUST NOT reuse a response to an
|
|
153
|
-
* `Authorization`-bearing request unless explicitly permitted), such
|
|
154
|
-
* requests bypass the cache entirely by default.
|
|
255
|
+
* Pass a boolean to set both, or an object to control them independently —
|
|
256
|
+
* useful when a public endpoint receives unrelated analytics cookies but must
|
|
257
|
+
* never cache bearer-authenticated responses:
|
|
155
258
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* {@link keyGenerator} so distinct callers cannot collide.
|
|
259
|
+
* ```ts
|
|
260
|
+
* responseCache({ cacheAuthenticatedRequests: { cookie: true } });
|
|
261
|
+
* ```
|
|
160
262
|
*
|
|
161
|
-
*
|
|
263
|
+
* Enable a dimension only when the response is genuinely shareable across
|
|
264
|
+
* principals (e.g. public reference data behind a bearer gate). Otherwise
|
|
265
|
+
* prefer {@link principal}, which keeps caching *and* keeps callers apart.
|
|
266
|
+
*
|
|
267
|
+
* @remarks Declaring the credential header in {@link varyHeaders} also counts
|
|
268
|
+
* as handling it, since its value then partitions the key.
|
|
269
|
+
* @since 0.40.0 — extended to `Cookie` and per-header control in 1.0.0.
|
|
162
270
|
*/
|
|
163
|
-
cacheAuthenticatedRequests?: boolean
|
|
271
|
+
cacheAuthenticatedRequests?: boolean | {
|
|
272
|
+
authorization?: boolean;
|
|
273
|
+
cookie?: boolean;
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** Options for {@link MemoryResponseCacheStore}. */
|
|
277
|
+
export interface MemoryResponseCacheStoreOptions {
|
|
278
|
+
/**
|
|
279
|
+
* Hard ceiling on retained entries. Default: `10_000`. Once reached, the
|
|
280
|
+
* oldest-inserted entries are evicted — expired ones first.
|
|
281
|
+
*/
|
|
282
|
+
maxEntries?: number;
|
|
283
|
+
/**
|
|
284
|
+
* Hard ceiling on retained body bytes. Default: `64 * 1024 * 1024` (64 MiB).
|
|
285
|
+
*
|
|
286
|
+
* An entry count alone does not bound memory: with the module's default
|
|
287
|
+
* `maxBodyBytes` of 1 MiB, ten thousand entries is ten gigabytes. This is the
|
|
288
|
+
* limit that actually caps the store's footprint.
|
|
289
|
+
*/
|
|
290
|
+
maxBytes?: number;
|
|
164
291
|
}
|
|
165
292
|
/**
|
|
166
293
|
* In-memory {@link ResponseCacheStore}. Suitable for tests and single-process
|
|
167
|
-
* deployments.
|
|
168
|
-
*
|
|
294
|
+
* deployments.
|
|
295
|
+
*
|
|
296
|
+
* Expired entries are dropped on access. The map is bounded on **both** entry
|
|
297
|
+
* count and retained body bytes ({@link MemoryResponseCacheStoreOptions}):
|
|
298
|
+
* pruning expired entries alone cannot bound it, because every entry in a burst
|
|
299
|
+
* of requests for distinct URLs is unexpired for the whole TTL. An attacker
|
|
300
|
+
* rotating a query string would otherwise grow the map without limit until the
|
|
301
|
+
* process runs out of memory.
|
|
302
|
+
*
|
|
303
|
+
* Eviction is FIFO over insertion order (expired entries first), which `Map`
|
|
304
|
+
* gives in O(1) per eviction.
|
|
169
305
|
*/
|
|
170
306
|
export declare class MemoryResponseCacheStore implements ResponseCacheStore {
|
|
171
307
|
private readonly map;
|
|
308
|
+
private readonly maxEntries;
|
|
309
|
+
private readonly maxBytes;
|
|
310
|
+
/** Running sum of `entry.body.length` over `map`, kept in step with writes. */
|
|
311
|
+
private bytes;
|
|
312
|
+
/**
|
|
313
|
+
* @param opts - Capacity limits; see {@link MemoryResponseCacheStoreOptions}.
|
|
314
|
+
* @throws TypeError if either limit is not a positive integer.
|
|
315
|
+
*/
|
|
316
|
+
constructor(opts?: MemoryResponseCacheStoreOptions);
|
|
172
317
|
/** @inheritDoc */
|
|
173
318
|
get(key: string): CachedResponse | null;
|
|
174
319
|
/**
|
|
@@ -179,7 +324,13 @@ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
|
|
|
179
324
|
set(key: string, entry: CachedResponse, _ttlMs?: number): void;
|
|
180
325
|
/** @inheritDoc */
|
|
181
326
|
delete(key: string): void;
|
|
182
|
-
|
|
327
|
+
/** Remove one entry, keeping the byte counter in step. */
|
|
328
|
+
private drop;
|
|
329
|
+
/**
|
|
330
|
+
* Bring the map back under both limits: expired entries first, then
|
|
331
|
+
* oldest-inserted, since `Map` iterates in insertion order.
|
|
332
|
+
*/
|
|
333
|
+
private evict;
|
|
183
334
|
/** Test helper. Remove every entry. */
|
|
184
335
|
clear(): void;
|
|
185
336
|
/** Test helper. Number of stored entries (including expired). */
|
|
@@ -201,10 +352,17 @@ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
|
|
|
201
352
|
*
|
|
202
353
|
* Request `Cache-Control: no-store` bypasses the cache entirely; `no-cache`
|
|
203
354
|
* bypasses the read but still refreshes the stored entry. Responses marked
|
|
204
|
-
* `no-store` / `private` / `no-cache`, carrying `Set-Cookie
|
|
205
|
-
* {@link ResponseCacheOptions.cacheableStatus}, or larger than
|
|
355
|
+
* `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
|
|
356
|
+
* failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
|
|
206
357
|
* {@link ResponseCacheOptions.maxBodyBytes} are never cached.
|
|
207
358
|
*
|
|
359
|
+
* A response that declares `Vary` is stored as a **variant**: the request's
|
|
360
|
+
* values for those fields are recorded alongside it, and the entry is replayed
|
|
361
|
+
* only to a request whose values match. A mismatch is a miss, so the handler
|
|
362
|
+
* runs and the entry is re-stored for that variant. This applies to `Vary`
|
|
363
|
+
* written by any middleware in the chain — notably `cors()` (`Origin`) and
|
|
364
|
+
* `compression()` (`Accept-Encoding`) — with no configuration.
|
|
365
|
+
*
|
|
208
366
|
* @example
|
|
209
367
|
* ```ts
|
|
210
368
|
* import { App, responseCache } from "@daloyjs/core";
|