@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/response-cache.js
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.
|
|
@@ -42,6 +67,30 @@
|
|
|
42
67
|
import { markSchemaValidatedResponse } from "./internal-response.js";
|
|
43
68
|
/** Internal `ctx.state` key carrying the pending cache key between hooks. */
|
|
44
69
|
const PENDING_STATE_KEY = "__responseCachePending";
|
|
70
|
+
/**
|
|
71
|
+
* Marker stamped on the `Hooks` object returned by {@link responseCache}, so the
|
|
72
|
+
* `App` boot guard can detect a cache mounted *ahead of* `tenancy()` — an order
|
|
73
|
+
* in which the tenant is not yet in `ctx.state` when the cache key is built, and
|
|
74
|
+
* automatic tenant partitioning therefore cannot protect the entry.
|
|
75
|
+
*
|
|
76
|
+
* @since 1.0.0
|
|
77
|
+
*/
|
|
78
|
+
export const RESPONSE_CACHE_HOOK_MARKER = Symbol.for("daloyjs.response-cache.hook");
|
|
79
|
+
/**
|
|
80
|
+
* `ctx.state` symbol under which `tenancy()` records the tenant it resolved for
|
|
81
|
+
* the request, or its `TENANT_UNRESOLVED` sentinel when it ran and resolved
|
|
82
|
+
* nothing. Either way the value is a string, so it partitions the cache key —
|
|
83
|
+
* which keeps tenant-less traffic out of the resolved tenants' entries without
|
|
84
|
+
* this module needing to know the sentinel's value.
|
|
85
|
+
*
|
|
86
|
+
* Re-derived from the global symbol registry rather than imported from
|
|
87
|
+
* `tenancy.js` so that using `responseCache()` never pulls the tenancy module
|
|
88
|
+
* into the bundle — the same technique `app.ts` uses for the MCP route marker.
|
|
89
|
+
* Must match the string in `tenancy.ts`.
|
|
90
|
+
*
|
|
91
|
+
* @internal
|
|
92
|
+
*/
|
|
93
|
+
const TENANCY_RESOLVED_MARKER = Symbol.for("daloyjs.tenancy.resolved");
|
|
45
94
|
/**
|
|
46
95
|
* Process-wide registry of in-memory stores shared by
|
|
47
96
|
* {@link ResponseCacheOptions.groupId}.
|
|
@@ -58,21 +107,49 @@ const SHARED_RESPONSE_CACHE_STORES = new Map();
|
|
|
58
107
|
export function _resetSharedResponseCacheStoresForTests() {
|
|
59
108
|
SHARED_RESPONSE_CACHE_STORES.clear();
|
|
60
109
|
}
|
|
61
|
-
// ---------- Default store ----------
|
|
62
110
|
/**
|
|
63
111
|
* In-memory {@link ResponseCacheStore}. Suitable for tests and single-process
|
|
64
|
-
* deployments.
|
|
65
|
-
*
|
|
112
|
+
* deployments.
|
|
113
|
+
*
|
|
114
|
+
* Expired entries are dropped on access. The map is bounded on **both** entry
|
|
115
|
+
* count and retained body bytes ({@link MemoryResponseCacheStoreOptions}):
|
|
116
|
+
* pruning expired entries alone cannot bound it, because every entry in a burst
|
|
117
|
+
* of requests for distinct URLs is unexpired for the whole TTL. An attacker
|
|
118
|
+
* rotating a query string would otherwise grow the map without limit until the
|
|
119
|
+
* process runs out of memory.
|
|
120
|
+
*
|
|
121
|
+
* Eviction is FIFO over insertion order (expired entries first), which `Map`
|
|
122
|
+
* gives in O(1) per eviction.
|
|
66
123
|
*/
|
|
67
124
|
export class MemoryResponseCacheStore {
|
|
68
125
|
map = new Map();
|
|
126
|
+
maxEntries;
|
|
127
|
+
maxBytes;
|
|
128
|
+
/** Running sum of `entry.body.length` over `map`, kept in step with writes. */
|
|
129
|
+
bytes = 0;
|
|
130
|
+
/**
|
|
131
|
+
* @param opts - Capacity limits; see {@link MemoryResponseCacheStoreOptions}.
|
|
132
|
+
* @throws TypeError if either limit is not a positive integer.
|
|
133
|
+
*/
|
|
134
|
+
constructor(opts = {}) {
|
|
135
|
+
const maxEntries = opts.maxEntries ?? 10_000;
|
|
136
|
+
const maxBytes = opts.maxBytes ?? 64 * 1024 * 1024;
|
|
137
|
+
if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
|
|
138
|
+
throw new TypeError("MemoryResponseCacheStore: maxEntries must be a positive integer.");
|
|
139
|
+
}
|
|
140
|
+
if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
|
|
141
|
+
throw new TypeError("MemoryResponseCacheStore: maxBytes must be a positive integer.");
|
|
142
|
+
}
|
|
143
|
+
this.maxEntries = maxEntries;
|
|
144
|
+
this.maxBytes = maxBytes;
|
|
145
|
+
}
|
|
69
146
|
/** @inheritDoc */
|
|
70
147
|
get(key) {
|
|
71
148
|
const entry = this.map.get(key);
|
|
72
149
|
if (!entry)
|
|
73
150
|
return null;
|
|
74
151
|
if (entry.staleUntil <= Date.now()) {
|
|
75
|
-
this.
|
|
152
|
+
this.drop(key, entry);
|
|
76
153
|
return null;
|
|
77
154
|
}
|
|
78
155
|
return entry;
|
|
@@ -83,24 +160,47 @@ export class MemoryResponseCacheStore {
|
|
|
83
160
|
* here: the in-memory store derives freshness from `entry.freshUntil`.
|
|
84
161
|
*/
|
|
85
162
|
set(key, entry, _ttlMs) {
|
|
163
|
+
const existing = this.map.get(key);
|
|
164
|
+
if (existing)
|
|
165
|
+
this.bytes -= existing.body.length;
|
|
86
166
|
this.map.set(key, entry);
|
|
87
|
-
|
|
88
|
-
|
|
167
|
+
this.bytes += entry.body.length;
|
|
168
|
+
if (this.map.size > this.maxEntries || this.bytes > this.maxBytes)
|
|
169
|
+
this.evict();
|
|
89
170
|
}
|
|
90
171
|
/** @inheritDoc */
|
|
91
172
|
delete(key) {
|
|
173
|
+
const entry = this.map.get(key);
|
|
174
|
+
if (entry)
|
|
175
|
+
this.drop(key, entry);
|
|
176
|
+
}
|
|
177
|
+
/** Remove one entry, keeping the byte counter in step. */
|
|
178
|
+
drop(key, entry) {
|
|
92
179
|
this.map.delete(key);
|
|
180
|
+
this.bytes -= entry.body.length;
|
|
93
181
|
}
|
|
94
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Bring the map back under both limits: expired entries first, then
|
|
184
|
+
* oldest-inserted, since `Map` iterates in insertion order.
|
|
185
|
+
*/
|
|
186
|
+
evict() {
|
|
95
187
|
const now = Date.now();
|
|
96
188
|
for (const [k, v] of this.map) {
|
|
189
|
+
if (this.map.size <= this.maxEntries && this.bytes <= this.maxBytes)
|
|
190
|
+
return;
|
|
97
191
|
if (v.staleUntil <= now)
|
|
98
|
-
this.
|
|
192
|
+
this.drop(k, v);
|
|
193
|
+
}
|
|
194
|
+
for (const [k, v] of this.map) {
|
|
195
|
+
if (this.map.size <= this.maxEntries && this.bytes <= this.maxBytes)
|
|
196
|
+
return;
|
|
197
|
+
this.drop(k, v);
|
|
99
198
|
}
|
|
100
199
|
}
|
|
101
200
|
/** Test helper. Remove every entry. */
|
|
102
201
|
clear() {
|
|
103
202
|
this.map.clear();
|
|
203
|
+
this.bytes = 0;
|
|
104
204
|
}
|
|
105
205
|
/** Test helper. Number of stored entries (including expired). */
|
|
106
206
|
size() {
|
|
@@ -167,14 +267,123 @@ function freshnessFromResponse(res) {
|
|
|
167
267
|
// No explicit directive: fall back to the configured ttl (undefined marker).
|
|
168
268
|
return undefined;
|
|
169
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Build the default cache-key body: the method plus the **effective request
|
|
272
|
+
* URI** (scheme + authority + path + query), plus any {@link
|
|
273
|
+
* ResponseCacheOptions.varyHeaders} values.
|
|
274
|
+
*
|
|
275
|
+
* Including the authority is what keeps one process serving several hostnames
|
|
276
|
+
* from sharing entries across them (RFC 9111 §4 keys a cache on the target URI,
|
|
277
|
+
* which includes the authority). `Request.url` is already an absolute,
|
|
278
|
+
* normalized serialization (host lower-cased, default port elided), so it is
|
|
279
|
+
* used directly — no `URL` object is allocated on this hot path. Only the
|
|
280
|
+
* fragment, which is meaningless to a cache and never sent by HTTP clients, is
|
|
281
|
+
* trimmed.
|
|
282
|
+
*/
|
|
170
283
|
function defaultKey(ctx, varyHeaders) {
|
|
171
|
-
const url =
|
|
172
|
-
|
|
284
|
+
const url = ctx.request.url;
|
|
285
|
+
const hash = url.indexOf("#");
|
|
286
|
+
let key = `${ctx.request.method} ${hash === -1 ? url : url.slice(0, hash)}`;
|
|
173
287
|
for (const name of varyHeaders) {
|
|
174
288
|
key += `\n${name}: ${ctx.request.headers.get(name) ?? ""}`;
|
|
175
289
|
}
|
|
176
290
|
return key;
|
|
177
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Append a length-prefixed `name=value` component to a cache-key partition.
|
|
294
|
+
*
|
|
295
|
+
* The length prefix makes the component unambiguous, so a principal id
|
|
296
|
+
* containing the delimiter (or a whole forged key fragment) cannot be crafted to
|
|
297
|
+
* collide with a different partition — cache-key injection.
|
|
298
|
+
*/
|
|
299
|
+
function appendPartition(partition, name, value) {
|
|
300
|
+
return `${partition}${name}=${value.length}:${value}\n`;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Response headers that must never be persisted in a cache entry, because they
|
|
304
|
+
* describe *this hop* or *this request* rather than the stored representation.
|
|
305
|
+
*
|
|
306
|
+
* - The RFC 9111 §3.1 / RFC 9110 §7.6.1 hop-by-hop set. Replaying a stored
|
|
307
|
+
* `Transfer-Encoding: chunked` onto a fixed-length cached body, or a stored
|
|
308
|
+
* `Connection` token, corrupts message framing for every later caller.
|
|
309
|
+
* - `Age`, which is recomputed from `storedAt` on every serve.
|
|
310
|
+
* - `X-Request-Id`, the default correlation id written by `requestId()`. It
|
|
311
|
+
* identifies the *one* request that populated the entry; replaying it makes
|
|
312
|
+
* every subsequent caller report a trace id belonging to someone else's
|
|
313
|
+
* request, and tells an attacker whether their own seed is still being
|
|
314
|
+
* served (a cache-state oracle).
|
|
315
|
+
*
|
|
316
|
+
* Extend for a custom correlation header via
|
|
317
|
+
* {@link ResponseCacheOptions.excludeHeaders}.
|
|
318
|
+
*/
|
|
319
|
+
const NEVER_CACHED_HEADERS = new Set([
|
|
320
|
+
"age",
|
|
321
|
+
"connection",
|
|
322
|
+
"keep-alive",
|
|
323
|
+
"proxy-authenticate",
|
|
324
|
+
"proxy-authorization",
|
|
325
|
+
"te",
|
|
326
|
+
"trailer",
|
|
327
|
+
"transfer-encoding",
|
|
328
|
+
"upgrade",
|
|
329
|
+
"x-request-id",
|
|
330
|
+
]);
|
|
331
|
+
/**
|
|
332
|
+
* Split a response's `Vary` header into normalized field names.
|
|
333
|
+
*
|
|
334
|
+
* @param raw - Raw `Vary` header value, or `null` when absent.
|
|
335
|
+
* @returns `"*"` when the response is declared unreusable by any secondary key,
|
|
336
|
+
* a sorted, de-duplicated, lower-cased field list otherwise (empty when the
|
|
337
|
+
* header is absent or lists nothing usable). Sorting makes the derived key
|
|
338
|
+
* independent of the order the emitting middleware happened to append in.
|
|
339
|
+
*/
|
|
340
|
+
function parseVary(raw) {
|
|
341
|
+
if (!raw)
|
|
342
|
+
return [];
|
|
343
|
+
const fields = new Set();
|
|
344
|
+
for (const part of raw.split(",")) {
|
|
345
|
+
const name = part.trim().toLowerCase();
|
|
346
|
+
if (!name)
|
|
347
|
+
continue;
|
|
348
|
+
if (name === "*")
|
|
349
|
+
return "*";
|
|
350
|
+
fields.add(name);
|
|
351
|
+
}
|
|
352
|
+
return [...fields].sort();
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Derive the secondary cache key for a request: the values it carries for each
|
|
356
|
+
* field the stored response varies on.
|
|
357
|
+
*
|
|
358
|
+
* Uses the same length-prefixed encoding as {@link appendPartition}, so a header
|
|
359
|
+
* value containing the delimiter cannot be crafted to collide with a different
|
|
360
|
+
* variant.
|
|
361
|
+
*
|
|
362
|
+
* @param headers - The request's headers.
|
|
363
|
+
* @param fields - Lower-cased field names from {@link parseVary}.
|
|
364
|
+
* @returns An unambiguous encoding of those fields' values.
|
|
365
|
+
*/
|
|
366
|
+
function varyKeyFor(headers, fields) {
|
|
367
|
+
let key = "";
|
|
368
|
+
for (const name of fields)
|
|
369
|
+
key = appendPartition(key, name, headers.get(name) ?? "");
|
|
370
|
+
return key;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Store key for one variant of a primary key.
|
|
374
|
+
*
|
|
375
|
+
* Variants live under their own keys so they coexist instead of evicting each
|
|
376
|
+
* other. A single slot per primary key would make every alternation between
|
|
377
|
+
* (say) a gzip client and an identity client a miss — and hand an attacker a
|
|
378
|
+
* cache-defeat DoS: rotate `Origin` or `Accept-Encoding` and every request runs
|
|
379
|
+
* the handler.
|
|
380
|
+
*
|
|
381
|
+
* The separator is a NUL byte, which cannot appear in a header value, so a
|
|
382
|
+
* variant key can never collide with a primary key.
|
|
383
|
+
*/
|
|
384
|
+
function variantKey(primary, varyKey) {
|
|
385
|
+
return `${primary}\u0000v\u0000${varyKey}`;
|
|
386
|
+
}
|
|
178
387
|
function buildResponseFromCache(entry, outcome, statusHeaderName, isHead) {
|
|
179
388
|
const headers = new Headers();
|
|
180
389
|
for (const [name, value] of entry.headers)
|
|
@@ -208,10 +417,17 @@ function isPromiseLike(value) {
|
|
|
208
417
|
*
|
|
209
418
|
* Request `Cache-Control: no-store` bypasses the cache entirely; `no-cache`
|
|
210
419
|
* bypasses the read but still refreshes the stored entry. Responses marked
|
|
211
|
-
* `no-store` / `private` / `no-cache`, carrying `Set-Cookie
|
|
212
|
-
* {@link ResponseCacheOptions.cacheableStatus}, or larger than
|
|
420
|
+
* `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
|
|
421
|
+
* failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
|
|
213
422
|
* {@link ResponseCacheOptions.maxBodyBytes} are never cached.
|
|
214
423
|
*
|
|
424
|
+
* A response that declares `Vary` is stored as a **variant**: the request's
|
|
425
|
+
* values for those fields are recorded alongside it, and the entry is replayed
|
|
426
|
+
* only to a request whose values match. A mismatch is a miss, so the handler
|
|
427
|
+
* runs and the entry is re-stored for that variant. This applies to `Vary`
|
|
428
|
+
* written by any middleware in the chain — notably `cors()` (`Origin`) and
|
|
429
|
+
* `compression()` (`Accept-Encoding`) — with no configuration.
|
|
430
|
+
*
|
|
215
431
|
* @example
|
|
216
432
|
* ```ts
|
|
217
433
|
* import { App, responseCache } from "@daloyjs/core";
|
|
@@ -250,9 +466,23 @@ export function responseCache(opts = {}) {
|
|
|
250
466
|
throw new Error("responseCache(): maxBodyBytes must be a positive integer.");
|
|
251
467
|
}
|
|
252
468
|
const methods = new Set((opts.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()));
|
|
253
|
-
const cacheAuthenticatedRequests = opts.cacheAuthenticatedRequests === true;
|
|
254
469
|
const cacheableStatus = opts.cacheableStatus ?? ((status) => status === 200);
|
|
255
470
|
const varyHeaders = (opts.varyHeaders ?? []).map((h) => h.toLowerCase());
|
|
471
|
+
const principal = opts.principal;
|
|
472
|
+
const excludedHeaders = opts.excludeHeaders?.length
|
|
473
|
+
? new Set([...NEVER_CACHED_HEADERS, ...opts.excludeHeaders.map((h) => h.toLowerCase())])
|
|
474
|
+
: NEVER_CACHED_HEADERS;
|
|
475
|
+
// Resolve the per-header credential policy once, at construction. A credential
|
|
476
|
+
// header counts as "handled" when the caller declared it shareable, or when it
|
|
477
|
+
// is in `varyHeaders` (its value then partitions the key by itself).
|
|
478
|
+
const credentialOptIn = opts.cacheAuthenticatedRequests;
|
|
479
|
+
const optInAll = credentialOptIn === true;
|
|
480
|
+
const authorizationHandled = optInAll ||
|
|
481
|
+
(typeof credentialOptIn === "object" && credentialOptIn?.authorization === true) ||
|
|
482
|
+
varyHeaders.includes("authorization");
|
|
483
|
+
const cookieHandled = optInAll ||
|
|
484
|
+
(typeof credentialOptIn === "object" && credentialOptIn?.cookie === true) ||
|
|
485
|
+
varyHeaders.includes("cookie");
|
|
256
486
|
const statusHeaderName = opts.statusHeaderName === null ? null : (opts.statusHeaderName ?? "x-cache").toLowerCase();
|
|
257
487
|
const ttlMs = ttlSeconds * 1_000;
|
|
258
488
|
const swrMs = swrSeconds * 1_000;
|
|
@@ -290,37 +520,88 @@ export function responseCache(opts = {}) {
|
|
|
290
520
|
.catch(() => undefined)
|
|
291
521
|
.finally(() => refreshing.delete(key));
|
|
292
522
|
}
|
|
293
|
-
|
|
523
|
+
const hooks = {
|
|
294
524
|
async beforeHandle(ctx) {
|
|
295
525
|
const method = ctx.request.method.toUpperCase();
|
|
296
526
|
if (!methods.has(method))
|
|
297
527
|
return undefined;
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
if (!cacheAuthenticatedRequests && ctx.request.headers.has("authorization")) {
|
|
303
|
-
return undefined;
|
|
304
|
-
}
|
|
305
|
-
const reqCc = parseCacheControl(ctx.request.headers.get("cache-control"));
|
|
528
|
+
const headers = ctx.request.headers;
|
|
529
|
+
// Checked before `principal` runs so a fully bypassed request never pays
|
|
530
|
+
// for the caller's callback.
|
|
531
|
+
const reqCc = parseCacheControl(headers.get("cache-control"));
|
|
306
532
|
if (reqCc.has("no-store"))
|
|
307
533
|
return undefined;
|
|
534
|
+
// Identify the caller, if the app can. A non-empty principal is folded
|
|
535
|
+
// into the key below, which is what makes caching a credentialed response
|
|
536
|
+
// safe: the entry belongs to that principal alone. Not wrapped in a
|
|
537
|
+
// try/catch, matching `keyGenerator`: a throwing option is a bug in the
|
|
538
|
+
// app, and swallowing it would hide the misconfiguration.
|
|
539
|
+
const principalId = principal ? (principal(ctx) ?? null) : null;
|
|
540
|
+
// RFC 9111 §3.5 / CWE-524: a shared cache keyed on the request URI must not
|
|
541
|
+
// store or reuse a response to a credentialed request, or it would serve
|
|
542
|
+
// one principal's private data to the next caller. `Cookie` counts as a
|
|
543
|
+
// credential alongside `Authorization` — a session cookie is the most
|
|
544
|
+
// common way a response becomes private. An unhandled credential is only
|
|
545
|
+
// safe once `principal` has named the caller.
|
|
546
|
+
if (!principalId &&
|
|
547
|
+
((!authorizationHandled && headers.has("authorization")) ||
|
|
548
|
+
(!cookieHandled && headers.has("cookie")))) {
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
308
551
|
const rawKey = opts.keyGenerator ? opts.keyGenerator(ctx) : defaultKey(ctx, varyHeaders);
|
|
309
552
|
if (rawKey === null)
|
|
310
553
|
return undefined;
|
|
311
|
-
|
|
554
|
+
// Partition the key by everything the framework knows about the caller
|
|
555
|
+
// that the URI does not already express. Applied around `keyGenerator`
|
|
556
|
+
// output too, so a custom generator cannot widen the partition. Skipped
|
|
557
|
+
// entirely — no allocation — for the common unpartitioned public request.
|
|
558
|
+
let partition = "";
|
|
559
|
+
const tenant = ctx.state[TENANCY_RESOLVED_MARKER];
|
|
560
|
+
if (typeof tenant === "string") {
|
|
561
|
+
partition = appendPartition(partition, "tenant", tenant);
|
|
562
|
+
}
|
|
563
|
+
if (principalId) {
|
|
564
|
+
partition = appendPartition(partition, "principal", principalId);
|
|
565
|
+
}
|
|
566
|
+
const key = `${keyPrefix}${partition}${rawKey}`;
|
|
312
567
|
// `no-cache` bypasses the read but still allows a fresh write below.
|
|
313
568
|
const bypassRead = reqCc.has("no-cache");
|
|
314
569
|
if (!bypassRead) {
|
|
315
570
|
const getResult = store.get(key);
|
|
316
|
-
|
|
571
|
+
let entry = isPromiseLike(getResult) ? await getResult : getResult;
|
|
572
|
+
let servedKey = key;
|
|
573
|
+
// RFC 9111 §4.1: an entry stored for a response that declared `Vary`
|
|
574
|
+
// is reusable only for a request whose values for those fields match
|
|
575
|
+
// the ones it was stored with. Without this, `cors()`'s `Vary: Origin`
|
|
576
|
+
// and `compression()`'s `Vary: Accept-Encoding` are silently ignored
|
|
577
|
+
// and one caller's variant — their `Access-Control-Allow-Origin`, their
|
|
578
|
+
// content-coding, their negotiated language — is served to the next.
|
|
579
|
+
//
|
|
580
|
+
// The entry at the primary key doubles as the hint that tells us *which*
|
|
581
|
+
// fields matter, so the common single-variant case costs one `get`. On a
|
|
582
|
+
// mismatch we know the field list and can look the right variant up
|
|
583
|
+
// directly, which is what keeps several variants of one URL alive at
|
|
584
|
+
// once instead of each evicting the last.
|
|
585
|
+
if (entry?.vary?.length) {
|
|
586
|
+
const wanted = varyKeyFor(headers, entry.vary);
|
|
587
|
+
if (entry.varyKey !== wanted) {
|
|
588
|
+
servedKey = variantKey(key, wanted);
|
|
589
|
+
const variantResult = store.get(servedKey);
|
|
590
|
+
entry = isPromiseLike(variantResult) ? await variantResult : variantResult;
|
|
591
|
+
// A stored variant records the field list it was keyed on; if that
|
|
592
|
+
// has since changed (the handler now varies on something else), the
|
|
593
|
+
// recorded key no longer means what we just computed.
|
|
594
|
+
if (entry && varyKeyFor(headers, entry.vary ?? []) !== entry.varyKey)
|
|
595
|
+
entry = null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
317
598
|
if (entry) {
|
|
318
599
|
const now = Date.now();
|
|
319
600
|
if (now < entry.freshUntil) {
|
|
320
601
|
return buildResponseFromCache(entry, "HIT", statusHeaderName, method === "HEAD");
|
|
321
602
|
}
|
|
322
603
|
if (revalidate && now < entry.staleUntil) {
|
|
323
|
-
backgroundRefresh(
|
|
604
|
+
backgroundRefresh(servedKey, ctx.request);
|
|
324
605
|
return buildResponseFromCache(entry, "STALE", statusHeaderName, method === "HEAD");
|
|
325
606
|
}
|
|
326
607
|
}
|
|
@@ -351,6 +632,15 @@ export function responseCache(opts = {}) {
|
|
|
351
632
|
res.headers.set(statusHeaderName, "MISS");
|
|
352
633
|
return undefined;
|
|
353
634
|
}
|
|
635
|
+
// RFC 9111 §4.1: `Vary: *` declares the response unreusable for any other
|
|
636
|
+
// request, whatever its headers. There is no secondary key that can make
|
|
637
|
+
// it safe, so it is never stored.
|
|
638
|
+
const vary = parseVary(res.headers.get("vary"));
|
|
639
|
+
if (vary === "*") {
|
|
640
|
+
if (statusHeaderName)
|
|
641
|
+
res.headers.set(statusHeaderName, "MISS");
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
354
644
|
const buf = new Uint8Array(await res.clone().arrayBuffer());
|
|
355
645
|
if (buf.byteLength > maxBodyBytes) {
|
|
356
646
|
if (statusHeaderName)
|
|
@@ -359,22 +649,37 @@ export function responseCache(opts = {}) {
|
|
|
359
649
|
}
|
|
360
650
|
const headers = [];
|
|
361
651
|
res.headers.forEach((value, name) => {
|
|
362
|
-
//
|
|
363
|
-
|
|
652
|
+
// Hop-by-hop and per-request headers describe this exchange, not the
|
|
653
|
+
// stored representation; replaying them corrupts framing or leaks one
|
|
654
|
+
// caller's correlation id to the next.
|
|
655
|
+
if (excludedHeaders.has(name))
|
|
364
656
|
return;
|
|
365
657
|
headers.push([name, value]);
|
|
366
658
|
});
|
|
367
659
|
const now = Date.now();
|
|
368
660
|
const freshMs = freshness ?? ttlMs;
|
|
661
|
+
const ttl = freshMs + swrMs;
|
|
662
|
+
const varyKey = vary.length ? varyKeyFor(ctx.request.headers, vary) : undefined;
|
|
369
663
|
const entry = {
|
|
370
664
|
status: res.status,
|
|
371
665
|
headers,
|
|
372
666
|
body: buf.byteLength ? bytesToBase64(buf) : "",
|
|
373
667
|
storedAt: now,
|
|
374
668
|
freshUntil: now + freshMs,
|
|
375
|
-
staleUntil: now +
|
|
669
|
+
staleUntil: now + ttl,
|
|
670
|
+
...(varyKey === undefined ? {} : { vary, varyKey }),
|
|
376
671
|
};
|
|
377
|
-
|
|
672
|
+
// A varying response is written twice: once under its own variant key, so
|
|
673
|
+
// it survives other variants of the same URL being cached, and once at
|
|
674
|
+
// the primary key, where the next lookup reads it as the hint naming the
|
|
675
|
+
// fields that matter. The primary copy is the most recently stored
|
|
676
|
+
// variant, so that one is served in a single `get`.
|
|
677
|
+
if (varyKey !== undefined) {
|
|
678
|
+
const variantResult = store.set(variantKey(pending.key, varyKey), entry, ttl);
|
|
679
|
+
if (isPromiseLike(variantResult))
|
|
680
|
+
await variantResult;
|
|
681
|
+
}
|
|
682
|
+
const setResult = store.set(pending.key, entry, ttl);
|
|
378
683
|
if (isPromiseLike(setResult))
|
|
379
684
|
await setResult;
|
|
380
685
|
if (statusHeaderName)
|
|
@@ -382,4 +687,8 @@ export function responseCache(opts = {}) {
|
|
|
382
687
|
return undefined;
|
|
383
688
|
},
|
|
384
689
|
};
|
|
690
|
+
// Let the App boot guard see that a response cache is in this hook chain, and
|
|
691
|
+
// where in the order it sits relative to `tenancy()`.
|
|
692
|
+
hooks[RESPONSE_CACHE_HOOK_MARKER] = true;
|
|
693
|
+
return hooks;
|
|
385
694
|
}
|
package/dist/safe-redirect.js
CHANGED
|
@@ -213,7 +213,9 @@ export function safeRedirect(target, options = {}) {
|
|
|
213
213
|
// target (no protocol-relative, no backslash confusion, no controls).
|
|
214
214
|
// Do not widen Location emission beyond what classify() would accept.
|
|
215
215
|
const fallbackResult = classify(options.fallback, ["/*"], []);
|
|
216
|
-
if (!fallbackResult.ok ||
|
|
216
|
+
if (!fallbackResult.ok ||
|
|
217
|
+
!options.fallback.startsWith("/") ||
|
|
218
|
+
options.fallback.startsWith("//")) {
|
|
217
219
|
throw new TypeError(`safeRedirect: fallback must be a safe same-origin path starting with "/"; got ${options.fallback}`);
|
|
218
220
|
}
|
|
219
221
|
return buildResponse(fallbackResult.location, status, options.headers);
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:01ee81d6-1bb6-55ca-b05e-3e30279d9f5c",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-07-
|
|
7
|
+
"timestamp": "2026-07-29T20:03:05.244Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.0.0-rc.
|
|
12
|
+
"version": "1.0.0-rc.7"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.7",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.0.0-rc.
|
|
24
|
+
"version": "1.0.0-rc.7",
|
|
25
25
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.7",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-1.0.0-rc.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.0.0-rc.7",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.0.0-rc.
|
|
51
|
+
"version": "1.0.0-rc.7",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.7",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-1.0.0-rc.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.
|
|
5
|
+
"name": "@daloyjs/core-1.0.0-rc.7",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.7-01ee81d6-1bb6-55ca-b05e-3e30279d9f5c",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-07-
|
|
8
|
+
"created": "2026-07-29T20:03:05.244Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "1.0.0-rc.
|
|
19
|
+
"versionInfo": "1.0.0-rc.7",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.7"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/dist/security-schemes.js
CHANGED
|
@@ -28,8 +28,7 @@ export function securitySchemeRequiresPayloadAuth(scheme) {
|
|
|
28
28
|
if (!scheme || typeof scheme !== "object")
|
|
29
29
|
return false;
|
|
30
30
|
const record = scheme;
|
|
31
|
-
return
|
|
32
|
-
record.requirePayloadAuth === true);
|
|
31
|
+
return record[REQUIRE_PAYLOAD_AUTH_EXTENSION] === true || record.requirePayloadAuth === true;
|
|
33
32
|
}
|
|
34
33
|
/**
|
|
35
34
|
* Normalize a builder output into a spec-compliant OpenAPI security scheme by
|
package/dist/subdomains.js
CHANGED
|
@@ -126,10 +126,7 @@ export function subdomains(hostname, opts = {}) {
|
|
|
126
126
|
}
|
|
127
127
|
return resultFor(host, base);
|
|
128
128
|
}
|
|
129
|
-
const suffixes = new Set([
|
|
130
|
-
...PSL_PUBLIC_SUFFIXES,
|
|
131
|
-
...(opts.extraSuffixes ?? []),
|
|
132
|
-
]);
|
|
129
|
+
const suffixes = new Set([...PSL_PUBLIC_SUFFIXES, ...(opts.extraSuffixes ?? [])]);
|
|
133
130
|
const labels = host.split(".");
|
|
134
131
|
// Walk from the longest suffix candidate down to the shortest so the
|
|
135
132
|
// longest match wins (`s3.amazonaws.com` beats `com`).
|