@daloyjs/core 1.0.0-rc.5 → 1.0.0-rc.7
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 +25 -14
- 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.d.ts +16 -0
- package/dist/auto-ban.js +20 -12
- package/dist/bot-guard.d.ts +14 -0
- package/dist/bot-guard.js +12 -12
- package/dist/cli.js +9 -6
- package/dist/concurrency-limit.d.ts +14 -0
- package/dist/concurrency-limit.js +18 -9
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +65 -0
- package/dist/conn-info.js +99 -4
- package/dist/errors.js +2 -5
- package/dist/etag.js +12 -2
- package/dist/geo-block.d.ts +15 -0
- package/dist/geo-block.js +14 -19
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.js +3 -8
- package/dist/index.d.ts +5 -5
- package/dist/index.js +4 -4
- package/dist/ip-reputation.d.ts +14 -0
- package/dist/ip-reputation.js +11 -11
- package/dist/ip-restriction.d.ts +14 -0
- package/dist/ip-restriction.js +7 -18
- package/dist/jwt.js +12 -14
- package/dist/logger.js +1 -3
- package/dist/mcp.d.ts +305 -34
- package/dist/mcp.js +554 -49
- package/dist/middleware.d.ts +31 -1
- package/dist/middleware.js +21 -19
- 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/middleware.d.ts
CHANGED
|
@@ -470,8 +470,22 @@ export interface RateLimitOptions {
|
|
|
470
470
|
* Trust x-forwarded-for / x-real-ip when deriving the default key.
|
|
471
471
|
* Off by default because those headers are client-spoofable unless your
|
|
472
472
|
* reverse proxy strips and rewrites them.
|
|
473
|
+
*
|
|
474
|
+
* When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
|
|
475
|
+
* one your immediate proxy appended — never the attacker-influenceable
|
|
476
|
+
* leftmost one, so rotating spoofed left entries cannot evade the limit.
|
|
477
|
+
* Behind more than one proxy hop, set {@link trustedHops} instead.
|
|
473
478
|
*/
|
|
474
479
|
trustProxyHeaders?: boolean;
|
|
480
|
+
/**
|
|
481
|
+
* Declare exactly how many proxy hops sit between Daloy and the public
|
|
482
|
+
* internet. Implies proxy-header trust and derives the default key that
|
|
483
|
+
* many entries from the right of `X-Forwarded-For` via
|
|
484
|
+
* {@link "./conn-info.js".resolveForwardedClientIp}. Must be an integer in
|
|
485
|
+
* [1, 64]; validated at construction. Ignored when a custom `keyGenerator`
|
|
486
|
+
* is supplied.
|
|
487
|
+
*/
|
|
488
|
+
trustedHops?: number;
|
|
475
489
|
/** When true, set Retry-After header on 429. Default: true. */
|
|
476
490
|
retryAfter?: boolean;
|
|
477
491
|
/**
|
|
@@ -546,8 +560,24 @@ export interface LoginThrottleOptions {
|
|
|
546
560
|
keyGenerator?: (ctx: RateLimitContext) => string;
|
|
547
561
|
/** Shared store for the hard limit. Uses rateLimit()'s in-memory group bucket by default. */
|
|
548
562
|
store?: RateLimitStore;
|
|
549
|
-
/** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false.
|
|
563
|
+
/** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false.
|
|
564
|
+
*
|
|
565
|
+
* When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
|
|
566
|
+
* one your immediate proxy appended — never the attacker-influenceable
|
|
567
|
+
* leftmost one. Behind more than one proxy hop, set {@link trustedHops}
|
|
568
|
+
* instead.
|
|
569
|
+
*/
|
|
550
570
|
trustProxyHeaders?: boolean;
|
|
571
|
+
/**
|
|
572
|
+
* Declare exactly how many proxy hops sit between Daloy and the public
|
|
573
|
+
* internet. Implies proxy-header trust and derives the default key that
|
|
574
|
+
* many entries from the right of `X-Forwarded-For` via
|
|
575
|
+
* {@link "./conn-info.js".resolveForwardedClientIp}, so rotating spoofed
|
|
576
|
+
* left entries cannot evade the throttle. Must be an integer in [1, 64];
|
|
577
|
+
* validated at construction. Ignored when a custom `keyGenerator` is
|
|
578
|
+
* supplied.
|
|
579
|
+
*/
|
|
580
|
+
trustedHops?: number;
|
|
551
581
|
/** When true, set Retry-After header on 429. Default: true. */
|
|
552
582
|
retryAfter?: boolean;
|
|
553
583
|
/** Start slowing responses after this many attempts in the same window. Default: 2. */
|
package/dist/middleware.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { assertCookieAttributes, readRequestCookie, serializeCookie } from "./cookie.js";
|
|
8
8
|
import { TooManyRequestsError, ForbiddenError } from "./errors.js";
|
|
9
9
|
import { randomId, sanitizeHeaderName, timingSafeEqual } from "./security.js";
|
|
10
|
+
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
10
11
|
/**
|
|
11
12
|
* Generate or accept a stable `X-Request-ID` for every request. The id is
|
|
12
13
|
* stamped on `ctx.state.requestId`, mirrored on outgoing response headers,
|
|
@@ -775,15 +776,8 @@ export function rateLimit(opts) {
|
|
|
775
776
|
store = new MemoryStore();
|
|
776
777
|
}
|
|
777
778
|
const groupPrefix = opts.groupId ? `${opts.groupId}:` : "";
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
if (opts.trustProxyHeaders) {
|
|
781
|
-
const xff = ctx.request.headers.get("x-forwarded-for");
|
|
782
|
-
const first = xff ? xff.split(",")[0].trim() : "";
|
|
783
|
-
return first || ctx.request.headers.get("x-real-ip") || "global";
|
|
784
|
-
}
|
|
785
|
-
return "global";
|
|
786
|
-
});
|
|
779
|
+
const hops = resolveForwardedTrust("rateLimit()", opts);
|
|
780
|
+
const keyOf = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops);
|
|
787
781
|
const enforce = async (ctx) => {
|
|
788
782
|
const key = `${groupPrefix}${keyOf(ctx)}`;
|
|
789
783
|
const { count, resetMs } = await store.hit(key, opts.windowMs);
|
|
@@ -811,15 +805,22 @@ function assertPositiveInteger(name, value) {
|
|
|
811
805
|
throw new Error(`loginThrottle(): ${name} must be a positive integer.`);
|
|
812
806
|
}
|
|
813
807
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
808
|
+
/**
|
|
809
|
+
* Default rate-limit / login-throttle key: the spoof-resistant forwarded client
|
|
810
|
+
* IP, or the shared `"global"` bucket when proxy-header trust is off or the
|
|
811
|
+
* request carries no trustworthy forwarded identity.
|
|
812
|
+
*
|
|
813
|
+
* @param hops - Trusted proxy hop count from
|
|
814
|
+
* {@link "./conn-info.js".resolveForwardedTrust}, or `undefined` when
|
|
815
|
+
* forwarded-header trust is disabled.
|
|
816
|
+
* @returns A key generator suitable for {@link rateLimit} and
|
|
817
|
+
* {@link loginThrottle}.
|
|
818
|
+
* @internal
|
|
819
|
+
*/
|
|
820
|
+
function defaultForwardedRateLimitKey(hops) {
|
|
821
|
+
if (hops === undefined)
|
|
822
|
+
return () => "global";
|
|
823
|
+
return (ctx) => resolveForwardedClientIp(ctx.request, hops) ?? "global";
|
|
823
824
|
}
|
|
824
825
|
function wait(ms) {
|
|
825
826
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -852,7 +853,8 @@ export function loginThrottle(opts = {}) {
|
|
|
852
853
|
assertNonNegativeInteger("delayMs", delayMs);
|
|
853
854
|
assertNonNegativeInteger("maxDelayMs", maxDelayMs);
|
|
854
855
|
const groupId = opts.groupId ?? "login";
|
|
855
|
-
const
|
|
856
|
+
const hops = resolveForwardedTrust("loginThrottle()", opts);
|
|
857
|
+
const keyGenerator = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops);
|
|
856
858
|
const limiter = rateLimit({
|
|
857
859
|
windowMs,
|
|
858
860
|
max,
|
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";
|