@daloyjs/core 0.36.0 → 0.37.0
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 +21 -2
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +144 -1
- package/dist/app.js +208 -1
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +88 -4
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +27 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +18 -1
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +27 -7
- package/dist/security.js +27 -7
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +76 -2
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor pagination helpers for DaloyJS.
|
|
3
|
+
*
|
|
4
|
+
* Contract-first list endpoints need three things the framework did not yet
|
|
5
|
+
* ship: an **opaque cursor** the client can echo back without depending on its
|
|
6
|
+
* internals, an **RFC 8288 `Link` header** advertising the `next` / `prev` /
|
|
7
|
+
* `first` pages, and **OpenAPI parameter wiring** so the `cursor` / `limit`
|
|
8
|
+
* query parameters appear in the generated spec and typed client. This module
|
|
9
|
+
* provides all three with zero runtime dependencies:
|
|
10
|
+
*
|
|
11
|
+
* - {@link encodeCursor} / {@link decodeCursor} — base64url-encode an arbitrary
|
|
12
|
+
* JSON-serializable payload (typically the sort key of the last row) into an
|
|
13
|
+
* opaque, URL-safe token, and decode it back with prototype-pollution-safe
|
|
14
|
+
* parsing and a hard size cap.
|
|
15
|
+
* - {@link buildLinkHeader} / {@link buildPageLinks} — assemble a Web-standard
|
|
16
|
+
* `Link` header, with CRLF / angle-bracket header-injection guards baked in.
|
|
17
|
+
* - {@link paginationQuery} — a Standard Schema validator for the `cursor` +
|
|
18
|
+
* `limit` query parameters that both validates at runtime (clamping `limit`
|
|
19
|
+
* to a safe range) **and** advertises itself to the OpenAPI generator via a
|
|
20
|
+
* `toJSONSchema()` method, so `request: { query: paginationQuery() }` wires
|
|
21
|
+
* the parameters into the contract with no extra code.
|
|
22
|
+
*
|
|
23
|
+
* Everything here is built on Web-standard `URL` / `Request` and `btoa` /
|
|
24
|
+
* `atob`, so it runs unchanged on Node, Bun, Deno, Cloudflare Workers, and
|
|
25
|
+
* Vercel Edge.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
* @since 0.37.0
|
|
29
|
+
*/
|
|
30
|
+
import { BadRequestError } from "./errors.js";
|
|
31
|
+
import { isForbiddenObjectKey } from "./security.js";
|
|
32
|
+
/**
|
|
33
|
+
* Hard cap on the length of an encoded cursor string accepted by
|
|
34
|
+
* {@link decodeCursor}. Bounds the work an attacker can force by sending a
|
|
35
|
+
* giant `cursor` query parameter. 4 KiB is far larger than any legitimate
|
|
36
|
+
* sort-key payload.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_CURSOR_LENGTH = 4096;
|
|
39
|
+
// ---------- Opaque cursor codec ----------
|
|
40
|
+
/**
|
|
41
|
+
* Encode an arbitrary JSON-serializable value into an opaque, URL-safe cursor
|
|
42
|
+
* token (base64url, no padding).
|
|
43
|
+
*
|
|
44
|
+
* The token is **opaque, not secret**: it is encoded, not encrypted or signed.
|
|
45
|
+
* Never trust a decoded cursor for authorization — always re-scope the
|
|
46
|
+
* underlying query by the authenticated principal on the server. Put only the
|
|
47
|
+
* data you need to resume a scan (e.g. `{ id, createdAt }`) inside it.
|
|
48
|
+
*
|
|
49
|
+
* @param payload - Any JSON-serializable value (object, array, string, …).
|
|
50
|
+
* @returns A base64url cursor string safe to place in a URL or `Link` header.
|
|
51
|
+
* @throws {TypeError} If `payload` cannot be JSON-serialized (e.g. a `BigInt`
|
|
52
|
+
* or a circular structure).
|
|
53
|
+
* @since 0.37.0
|
|
54
|
+
*/
|
|
55
|
+
export function encodeCursor(payload) {
|
|
56
|
+
const json = JSON.stringify(payload);
|
|
57
|
+
if (json === undefined) {
|
|
58
|
+
throw new TypeError("encodeCursor(): payload is not JSON-serializable.");
|
|
59
|
+
}
|
|
60
|
+
return base64UrlEncode(json);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Decode an opaque cursor produced by {@link encodeCursor} back into its
|
|
64
|
+
* original value.
|
|
65
|
+
*
|
|
66
|
+
* Parsing is hardened: the input length is capped at {@link MAX_CURSOR_LENGTH},
|
|
67
|
+
* decoding rejects malformed base64url, and any `__proto__` / `constructor` /
|
|
68
|
+
* `prototype` keys in the decoded object graph are stripped (prototype-
|
|
69
|
+
* pollution defense, mirroring the core body parsers).
|
|
70
|
+
*
|
|
71
|
+
* @typeParam T - The expected shape of the decoded payload (caller-asserted).
|
|
72
|
+
* @param cursor - The opaque cursor string from the request.
|
|
73
|
+
* @returns The decoded payload.
|
|
74
|
+
* @throws {BadRequestError} If the cursor is missing, over-long, or malformed —
|
|
75
|
+
* a `400` so a tampered cursor surfaces as a client error, not a `500`.
|
|
76
|
+
* @since 0.37.0
|
|
77
|
+
*/
|
|
78
|
+
export function decodeCursor(cursor) {
|
|
79
|
+
if (typeof cursor !== "string" || cursor.length === 0) {
|
|
80
|
+
throw new BadRequestError("Invalid pagination cursor.");
|
|
81
|
+
}
|
|
82
|
+
if (cursor.length > MAX_CURSOR_LENGTH) {
|
|
83
|
+
throw new BadRequestError("Pagination cursor is too long.");
|
|
84
|
+
}
|
|
85
|
+
let json;
|
|
86
|
+
try {
|
|
87
|
+
json = base64UrlDecode(cursor);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new BadRequestError("Malformed pagination cursor.");
|
|
91
|
+
}
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(json);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new BadRequestError("Malformed pagination cursor.");
|
|
98
|
+
}
|
|
99
|
+
return stripForbiddenKeys(parsed);
|
|
100
|
+
}
|
|
101
|
+
// Reject control characters and the structural delimiters that would let a
|
|
102
|
+
// crafted URL or title break out of the header (CRLF injection, `<`/`>`).
|
|
103
|
+
const LINK_URL_FORBIDDEN = /[\u0000-\u001f\u007f<>]/;
|
|
104
|
+
const LINK_TOKEN_FORBIDDEN = /[\u0000-\u001f\u007f"\\]/;
|
|
105
|
+
/**
|
|
106
|
+
* Serialize a list of links into a single RFC 8288 `Link` header value.
|
|
107
|
+
*
|
|
108
|
+
* Each entry renders as `<url>; rel="rel"` (plus `; title="…"` when present).
|
|
109
|
+
* URLs containing control characters, `<`, or `>` and rel/title values
|
|
110
|
+
* containing control characters, `"`, or `\` are rejected — a structural
|
|
111
|
+
* defense against `Link`-header / response-splitting injection.
|
|
112
|
+
*
|
|
113
|
+
* @param links - The links to emit. An empty array yields an empty string.
|
|
114
|
+
* @returns The comma-joined `Link` header value.
|
|
115
|
+
* @throws {Error} If any URL or token contains forbidden characters.
|
|
116
|
+
* @since 0.37.0
|
|
117
|
+
*/
|
|
118
|
+
export function buildLinkHeader(links) {
|
|
119
|
+
const parts = [];
|
|
120
|
+
for (const link of links) {
|
|
121
|
+
if (LINK_URL_FORBIDDEN.test(link.url)) {
|
|
122
|
+
throw new Error("buildLinkHeader(): link URL contains forbidden characters.");
|
|
123
|
+
}
|
|
124
|
+
if (LINK_TOKEN_FORBIDDEN.test(link.rel)) {
|
|
125
|
+
throw new Error("buildLinkHeader(): link rel contains forbidden characters.");
|
|
126
|
+
}
|
|
127
|
+
let part = `<${link.url}>; rel="${link.rel}"`;
|
|
128
|
+
if (link.title !== undefined) {
|
|
129
|
+
if (LINK_TOKEN_FORBIDDEN.test(link.title)) {
|
|
130
|
+
throw new Error("buildLinkHeader(): link title contains forbidden characters.");
|
|
131
|
+
}
|
|
132
|
+
part += `; title="${link.title}"`;
|
|
133
|
+
}
|
|
134
|
+
parts.push(part);
|
|
135
|
+
}
|
|
136
|
+
return parts.join(", ");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build the `next` / `prev` / `first` page URLs for a list response by cloning
|
|
140
|
+
* the current request URL and swapping its cursor query parameter, then
|
|
141
|
+
* serialize them into an RFC 8288 `Link` header.
|
|
142
|
+
*
|
|
143
|
+
* All other query parameters (filters, `limit`, …) are preserved, so the
|
|
144
|
+
* generated links are drop-in "give me the same query, next page" URLs.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const { linkHeader } = buildPageLinks({
|
|
149
|
+
* url: ctx.request.url,
|
|
150
|
+
* next: nextCursor, // from encodeCursor(...)
|
|
151
|
+
* prev: prevCursor,
|
|
152
|
+
* first: true,
|
|
153
|
+
* });
|
|
154
|
+
* set.headers.set("Link", linkHeader);
|
|
155
|
+
* ```
|
|
156
|
+
*
|
|
157
|
+
* @param opts - Current URL plus the cursors to advertise.
|
|
158
|
+
* @returns The structured links, the `Link` header string, and the page URLs.
|
|
159
|
+
* @since 0.37.0
|
|
160
|
+
*/
|
|
161
|
+
export function buildPageLinks(opts) {
|
|
162
|
+
const cursorParam = opts.cursorParam ?? "cursor";
|
|
163
|
+
const base = new URL(typeof opts.url === "string" ? opts.url : opts.url.href);
|
|
164
|
+
const self = base.href;
|
|
165
|
+
const links = [];
|
|
166
|
+
const urls = { self };
|
|
167
|
+
if (opts.next !== undefined && opts.next !== null) {
|
|
168
|
+
const u = new URL(base.href);
|
|
169
|
+
u.searchParams.set(cursorParam, opts.next);
|
|
170
|
+
urls.next = u.href;
|
|
171
|
+
links.push({ url: u.href, rel: "next" });
|
|
172
|
+
}
|
|
173
|
+
if (opts.prev !== undefined && opts.prev !== null) {
|
|
174
|
+
const u = new URL(base.href);
|
|
175
|
+
u.searchParams.set(cursorParam, opts.prev);
|
|
176
|
+
urls.prev = u.href;
|
|
177
|
+
links.push({ url: u.href, rel: "prev" });
|
|
178
|
+
}
|
|
179
|
+
if (opts.first === true) {
|
|
180
|
+
const u = new URL(base.href);
|
|
181
|
+
u.searchParams.delete(cursorParam);
|
|
182
|
+
urls.first = u.href;
|
|
183
|
+
links.push({ url: u.href, rel: "first" });
|
|
184
|
+
}
|
|
185
|
+
if (opts.extraLinks)
|
|
186
|
+
links.push(...opts.extraLinks);
|
|
187
|
+
return { links, linkHeader: buildLinkHeader(links), urls };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build a Standard Schema validator for cursor-pagination query parameters.
|
|
191
|
+
*
|
|
192
|
+
* Use it as a route's `request.query`. At runtime it parses and validates
|
|
193
|
+
* `limit` (coerced from its string query value to an integer and clamped to
|
|
194
|
+
* `[minLimit, maxLimit]`, defaulting to `defaultLimit` when absent) and passes
|
|
195
|
+
* `cursor` through as an optional opaque string. Because it also exposes
|
|
196
|
+
* `toJSONSchema()`, the same call wires both parameters into the generated
|
|
197
|
+
* OpenAPI document and typed client — no duplicate parameter declarations.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* app.route({
|
|
202
|
+
* method: "GET",
|
|
203
|
+
* path: "/books",
|
|
204
|
+
* operationId: "listBooks",
|
|
205
|
+
* request: { query: paginationQuery({ defaultLimit: 25, maxLimit: 100 }) },
|
|
206
|
+
* responses: { 200: { description: "ok", body: pageSchema } },
|
|
207
|
+
* handler: async ({ query }) => {
|
|
208
|
+
* const { limit, cursor } = query; // fully typed + validated
|
|
209
|
+
* // ...
|
|
210
|
+
* },
|
|
211
|
+
* });
|
|
212
|
+
* ```
|
|
213
|
+
*
|
|
214
|
+
* @param opts - Parameter names and page-size bounds.
|
|
215
|
+
* @returns A Standard Schema usable as `request.query`.
|
|
216
|
+
* @throws {Error} If the configured bounds are not positive integers or are
|
|
217
|
+
* inconsistent (`minLimit > maxLimit`, `defaultLimit` out of range).
|
|
218
|
+
* @since 0.37.0
|
|
219
|
+
*/
|
|
220
|
+
export function paginationQuery(opts = {}) {
|
|
221
|
+
const cursorParam = opts.cursorParam ?? "cursor";
|
|
222
|
+
const limitParam = opts.limitParam ?? "limit";
|
|
223
|
+
const minLimit = opts.minLimit ?? 1;
|
|
224
|
+
const maxLimit = opts.maxLimit ?? 100;
|
|
225
|
+
const defaultLimit = opts.defaultLimit ?? Math.min(20, maxLimit);
|
|
226
|
+
for (const [label, n] of [
|
|
227
|
+
["minLimit", minLimit],
|
|
228
|
+
["maxLimit", maxLimit],
|
|
229
|
+
["defaultLimit", defaultLimit],
|
|
230
|
+
]) {
|
|
231
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
232
|
+
throw new Error(`paginationQuery(): ${label} must be a positive integer.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (minLimit > maxLimit) {
|
|
236
|
+
throw new Error("paginationQuery(): minLimit must not exceed maxLimit.");
|
|
237
|
+
}
|
|
238
|
+
if (defaultLimit < minLimit || defaultLimit > maxLimit) {
|
|
239
|
+
throw new Error("paginationQuery(): defaultLimit must be within [minLimit, maxLimit].");
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
"~standard": {
|
|
243
|
+
version: 1,
|
|
244
|
+
vendor: "daloyjs",
|
|
245
|
+
validate(value) {
|
|
246
|
+
if (value === null || typeof value !== "object") {
|
|
247
|
+
return { issues: [{ message: "Expected a query object" }] };
|
|
248
|
+
}
|
|
249
|
+
const input = value;
|
|
250
|
+
const out = { limit: defaultLimit };
|
|
251
|
+
const rawLimit = input[limitParam];
|
|
252
|
+
if (rawLimit !== undefined && rawLimit !== "") {
|
|
253
|
+
const limitStr = Array.isArray(rawLimit) ? rawLimit[0] : rawLimit;
|
|
254
|
+
const n = Number(limitStr);
|
|
255
|
+
if (!Number.isInteger(n)) {
|
|
256
|
+
return {
|
|
257
|
+
issues: [{ message: `${limitParam} must be an integer`, path: [limitParam] }],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (n < minLimit || n > maxLimit) {
|
|
261
|
+
return {
|
|
262
|
+
issues: [
|
|
263
|
+
{
|
|
264
|
+
message: `${limitParam} must be between ${minLimit} and ${maxLimit}`,
|
|
265
|
+
path: [limitParam],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
out.limit = n;
|
|
271
|
+
}
|
|
272
|
+
const rawCursor = input[cursorParam];
|
|
273
|
+
if (rawCursor !== undefined && rawCursor !== "") {
|
|
274
|
+
const cursorStr = Array.isArray(rawCursor) ? rawCursor[0] : rawCursor;
|
|
275
|
+
if (typeof cursorStr !== "string") {
|
|
276
|
+
return {
|
|
277
|
+
issues: [{ message: `${cursorParam} must be a string`, path: [cursorParam] }],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (cursorStr.length > MAX_CURSOR_LENGTH) {
|
|
281
|
+
return {
|
|
282
|
+
issues: [{ message: `${cursorParam} is too long`, path: [cursorParam] }],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
out.cursor = cursorStr;
|
|
286
|
+
}
|
|
287
|
+
return { value: out };
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
toJSONSchema() {
|
|
291
|
+
return {
|
|
292
|
+
type: "object",
|
|
293
|
+
properties: {
|
|
294
|
+
[limitParam]: {
|
|
295
|
+
type: "integer",
|
|
296
|
+
minimum: minLimit,
|
|
297
|
+
maximum: maxLimit,
|
|
298
|
+
default: defaultLimit,
|
|
299
|
+
description: "Maximum number of items to return.",
|
|
300
|
+
},
|
|
301
|
+
[cursorParam]: {
|
|
302
|
+
type: "string",
|
|
303
|
+
maxLength: MAX_CURSOR_LENGTH,
|
|
304
|
+
description: "Opaque cursor identifying the page to return.",
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
required: [],
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// ---------- Internal helpers ----------
|
|
313
|
+
/** Encode a UTF-8 string as base64url without padding. */
|
|
314
|
+
function base64UrlEncode(input) {
|
|
315
|
+
const bytes = new TextEncoder().encode(input);
|
|
316
|
+
let bin = "";
|
|
317
|
+
const CHUNK = 0x8000;
|
|
318
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
319
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
320
|
+
}
|
|
321
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
322
|
+
}
|
|
323
|
+
/** Decode a base64url (optionally padded) string back to a UTF-8 string. */
|
|
324
|
+
function base64UrlDecode(input) {
|
|
325
|
+
if (!/^[A-Za-z0-9_-]+$/.test(input)) {
|
|
326
|
+
throw new Error("invalid base64url");
|
|
327
|
+
}
|
|
328
|
+
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
329
|
+
const bin = atob(padded);
|
|
330
|
+
const bytes = new Uint8Array(bin.length);
|
|
331
|
+
for (let i = 0; i < bin.length; i++)
|
|
332
|
+
bytes[i] = bin.charCodeAt(i);
|
|
333
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Recursively remove prototype-pollution sink keys (`__proto__`,
|
|
337
|
+
* `constructor`, `prototype`) from a decoded cursor payload.
|
|
338
|
+
*/
|
|
339
|
+
function stripForbiddenKeys(value) {
|
|
340
|
+
if (Array.isArray(value)) {
|
|
341
|
+
return value.map((v) => stripForbiddenKeys(v));
|
|
342
|
+
}
|
|
343
|
+
if (value !== null && typeof value === "object") {
|
|
344
|
+
const out = {};
|
|
345
|
+
for (const [k, v] of Object.entries(value)) {
|
|
346
|
+
if (isForbiddenObjectKey(k))
|
|
347
|
+
continue;
|
|
348
|
+
out[k] = stripForbiddenKeys(v);
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
return value;
|
|
353
|
+
}
|
|
@@ -75,6 +75,14 @@ export interface RedisRateLimitStoreOptions {
|
|
|
75
75
|
* The returned store is safe to share between requests and replicas. Errors
|
|
76
76
|
* from Redis are fail-open by default (see {@link RedisRateLimitStoreOptions.onError});
|
|
77
77
|
* pass a custom handler to fail-closed (return `"fail-closed"`).
|
|
78
|
+
*
|
|
79
|
+
* @remarks
|
|
80
|
+
* Security: the default fail-open posture biases toward availability — while
|
|
81
|
+
* Redis is unreachable the limiter stops enforcing and every request is
|
|
82
|
+
* allowed (reported as the first hit of a fresh local window). For
|
|
83
|
+
* abuse-sensitive limiters in front of auth, password-reset, or other
|
|
84
|
+
* credential endpoints, pass `onError: () => "fail-closed"` so a Redis
|
|
85
|
+
* outage rejects rather than silently disables the limit.
|
|
78
86
|
*/
|
|
79
87
|
export declare function redisRateLimitStore(opts: RedisRateLimitStoreOptions): RateLimitStore;
|
|
80
88
|
/**
|
package/dist/rate-limit-redis.js
CHANGED
|
@@ -76,6 +76,14 @@ function toNumber(value) {
|
|
|
76
76
|
* The returned store is safe to share between requests and replicas. Errors
|
|
77
77
|
* from Redis are fail-open by default (see {@link RedisRateLimitStoreOptions.onError});
|
|
78
78
|
* pass a custom handler to fail-closed (return `"fail-closed"`).
|
|
79
|
+
*
|
|
80
|
+
* @remarks
|
|
81
|
+
* Security: the default fail-open posture biases toward availability — while
|
|
82
|
+
* Redis is unreachable the limiter stops enforcing and every request is
|
|
83
|
+
* allowed (reported as the first hit of a fresh local window). For
|
|
84
|
+
* abuse-sensitive limiters in front of auth, password-reset, or other
|
|
85
|
+
* credential endpoints, pass `onError: () => "fail-closed"` so a Redis
|
|
86
|
+
* outage rejects rather than silently disables the limit.
|
|
79
87
|
*/
|
|
80
88
|
export function redisRateLimitStore(opts) {
|
|
81
89
|
const prefix = opts.prefix ?? "daloy:rl:";
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound request-decompression bomb guard.
|
|
3
|
+
*
|
|
4
|
+
* DaloyJS core deliberately does **not** decompress request bodies — it is safe
|
|
5
|
+
* by omission, so a `Content-Encoding: gzip` request body is read as-is and a
|
|
6
|
+
* schema parse simply fails on the compressed bytes. Some services, though,
|
|
7
|
+
* genuinely need to accept compressed uploads (chatty IoT clients, log
|
|
8
|
+
* shippers, mobile apps on slow links). The moment you inflate attacker-supplied
|
|
9
|
+
* bytes you inherit the classic **decompression bomb** (a.k.a. "zip bomb"): a
|
|
10
|
+
* few kilobytes of crafted gzip can expand to gigabytes and blow straight past
|
|
11
|
+
* {@link "./app.js".AppOptions.bodyLimitBytes}, which only ever sees the small
|
|
12
|
+
* compressed payload.
|
|
13
|
+
*
|
|
14
|
+
* {@link requestDecompression} is the opt-in middleware that adds request
|
|
15
|
+
* decompression **with the bomb guard baked in**. It inflates the body with two
|
|
16
|
+
* independent caps enforced *during* inflation (so a bomb is aborted long before
|
|
17
|
+
* it is fully materialised):
|
|
18
|
+
*
|
|
19
|
+
* - an **absolute** cap (`maxDecompressedBytes`) — the inflated body may never
|
|
20
|
+
* exceed this many bytes; and
|
|
21
|
+
* - a **ratio** cap (`maxRatio`) — the inflated size may never exceed
|
|
22
|
+
* `compressedBytes * maxRatio`, which catches small-but-explosive payloads
|
|
23
|
+
* that stay under the absolute cap in isolation but would amplify wildly.
|
|
24
|
+
*
|
|
25
|
+
* The compressed input itself is bounded by `maxCompressedBytes` before a single
|
|
26
|
+
* byte is inflated. Built on the web-standard `DecompressionStream`, so the same
|
|
27
|
+
* line works on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge. Zero
|
|
28
|
+
* runtime dependencies.
|
|
29
|
+
*
|
|
30
|
+
* The middleware runs in the {@link "./types.js".Hooks.onRequest} phase — before
|
|
31
|
+
* the per-request context (and therefore before schema-body validation) is
|
|
32
|
+
* built — and stashes the inflated bytes on the request so the framework's own
|
|
33
|
+
* body reader transparently sees the decompressed payload. That means it works
|
|
34
|
+
* for both schema-validated bodies and handlers that read the raw body
|
|
35
|
+
* themselves. Register it globally with `app.use(requestDecompression(...))`.
|
|
36
|
+
*
|
|
37
|
+
* Secure-by-default posture:
|
|
38
|
+
* - Only `gzip` and `deflate` are accepted (the encodings `DecompressionStream`
|
|
39
|
+
* implements across runtimes). An unknown, unsupported, or **layered**
|
|
40
|
+
* (`gzip, gzip`) `Content-Encoding` is refused with `415` — never inflated.
|
|
41
|
+
* - Malformed compressed input is refused with `400`, never silently treated as
|
|
42
|
+
* an empty body.
|
|
43
|
+
* - The bomb caps are mandatory: there is no "unlimited" mode.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
* @since 0.37.0
|
|
47
|
+
*/
|
|
48
|
+
import { HttpError } from "./errors.js";
|
|
49
|
+
import type { Hooks } from "./types.js";
|
|
50
|
+
/**
|
|
51
|
+
* Request `Content-Encoding` values this guard can safely inflate. Limited to
|
|
52
|
+
* the formats the web-standard `DecompressionStream` implements consistently
|
|
53
|
+
* across runtimes (brotli is intentionally excluded — it is not part of the
|
|
54
|
+
* Compression Streams spec and is unavailable on most runtimes).
|
|
55
|
+
*
|
|
56
|
+
* @since 0.37.0
|
|
57
|
+
*/
|
|
58
|
+
export type RequestDecompressionEncoding = "gzip" | "deflate";
|
|
59
|
+
/**
|
|
60
|
+
* Details of a rejected decompression bomb, passed to
|
|
61
|
+
* {@link RequestDecompressionOptions.onBomb} and carried by
|
|
62
|
+
* {@link DecompressionBombError}.
|
|
63
|
+
*
|
|
64
|
+
* @since 0.37.0
|
|
65
|
+
*/
|
|
66
|
+
export interface DecompressionBombInfo {
|
|
67
|
+
/** The declared request `Content-Encoding` that was being inflated. */
|
|
68
|
+
encoding: RequestDecompressionEncoding;
|
|
69
|
+
/** Size of the compressed input, in bytes. */
|
|
70
|
+
compressedBytes: number;
|
|
71
|
+
/** Inflated bytes produced before the guard aborted (always over a cap). */
|
|
72
|
+
decompressedBytes: number;
|
|
73
|
+
/** Which cap tripped: the absolute byte cap or the expansion-ratio cap. */
|
|
74
|
+
reason: "absolute" | "ratio";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `413 Payload Too Large` raised when an inflating request body crosses either
|
|
78
|
+
* the absolute (`maxDecompressedBytes`) or ratio (`maxRatio`) cap. Thrown
|
|
79
|
+
* *during* inflation, so the full bomb is never materialised in memory.
|
|
80
|
+
*
|
|
81
|
+
* @since 0.37.0
|
|
82
|
+
*/
|
|
83
|
+
export declare class DecompressionBombError extends HttpError {
|
|
84
|
+
/** Structured details about the rejected bomb. */
|
|
85
|
+
readonly info: DecompressionBombInfo;
|
|
86
|
+
constructor(info: DecompressionBombInfo);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `415 Unsupported Media Type` raised when a request declares a
|
|
90
|
+
* `Content-Encoding` this guard cannot safely inflate — an unknown encoding, an
|
|
91
|
+
* encoding not in the configured allowlist, an encoding the runtime's
|
|
92
|
+
* `DecompressionStream` does not implement, or a layered encoding such as
|
|
93
|
+
* `gzip, gzip`. The body is refused, never inflated.
|
|
94
|
+
*
|
|
95
|
+
* @since 0.37.0
|
|
96
|
+
*/
|
|
97
|
+
export declare class UnsupportedContentEncodingError extends HttpError {
|
|
98
|
+
constructor(encoding: string, allowed: readonly string[]);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `400 Bad Request` raised when the compressed request body is not valid for its
|
|
102
|
+
* declared `Content-Encoding` (truncated or corrupt stream). Refusing — rather
|
|
103
|
+
* than treating a malformed body as empty — prevents request-smuggling-style
|
|
104
|
+
* desync between this guard and any downstream parser.
|
|
105
|
+
*
|
|
106
|
+
* @since 0.37.0
|
|
107
|
+
*/
|
|
108
|
+
export declare class MalformedCompressedBodyError extends HttpError {
|
|
109
|
+
constructor(encoding: RequestDecompressionEncoding);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Options for {@link requestDecompression} and {@link decompressRequestBody}.
|
|
113
|
+
*
|
|
114
|
+
* @since 0.37.0
|
|
115
|
+
*/
|
|
116
|
+
export interface RequestDecompressionOptions {
|
|
117
|
+
/**
|
|
118
|
+
* Absolute hard cap (in bytes) on the inflated body. Required — there is no
|
|
119
|
+
* unlimited mode. Inflation aborts the moment output crosses this value, so a
|
|
120
|
+
* bomb is never fully materialised. Must be a positive integer.
|
|
121
|
+
*
|
|
122
|
+
* Set this at or below your {@link "./app.js".AppOptions.bodyLimitBytes} so the
|
|
123
|
+
* inflated payload still fits the body the rest of the app expects.
|
|
124
|
+
*/
|
|
125
|
+
maxDecompressedBytes: number;
|
|
126
|
+
/**
|
|
127
|
+
* Cap (in bytes) on the *compressed* input accepted before inflation. The
|
|
128
|
+
* compressed body is read with this limit, so an oversized upload is rejected
|
|
129
|
+
* with `413` without inflating anything. Default `1048576` (1 MiB). Must be a
|
|
130
|
+
* positive integer.
|
|
131
|
+
*/
|
|
132
|
+
maxCompressedBytes?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Maximum allowed inflated:compressed expansion ratio. The inflated body may
|
|
135
|
+
* not exceed `compressedBytes * maxRatio`; crossing it aborts inflation with
|
|
136
|
+
* `413`. Default `100`. Must be a finite number `>= 1`.
|
|
137
|
+
*/
|
|
138
|
+
maxRatio?: number;
|
|
139
|
+
/**
|
|
140
|
+
* Allowed request encodings. Defaults to `["gzip", "deflate"]`. Any encoding
|
|
141
|
+
* outside this set (or unsupported by the runtime) is refused with `415`.
|
|
142
|
+
*/
|
|
143
|
+
encodings?: readonly RequestDecompressionEncoding[];
|
|
144
|
+
/**
|
|
145
|
+
* Optional observability callback invoked when a bomb is rejected, before the
|
|
146
|
+
* `413` is thrown. Receives the structured {@link DecompressionBombInfo}. Must
|
|
147
|
+
* not throw.
|
|
148
|
+
*/
|
|
149
|
+
onBomb?: (info: DecompressionBombInfo) => void;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* @internal Reset the cached `DecompressionStream` runtime probe. Test-only.
|
|
153
|
+
* @since 0.37.0
|
|
154
|
+
*/
|
|
155
|
+
export declare function _resetRequestDecompressionProbeForTests(): void;
|
|
156
|
+
/**
|
|
157
|
+
* Inflate `compressed` under `encoding` while enforcing the absolute-size and
|
|
158
|
+
* expansion-ratio caps *during* decompression. This is the low-level guard used
|
|
159
|
+
* by {@link requestDecompression}; it is exported so handlers that read raw
|
|
160
|
+
* bodies (or custom flows) can decompress request bytes with the same
|
|
161
|
+
* bomb-resistant semantics.
|
|
162
|
+
*
|
|
163
|
+
* @param compressed - The compressed request bytes.
|
|
164
|
+
* @param encoding - The declared `Content-Encoding` (`"gzip"` or `"deflate"`).
|
|
165
|
+
* @param opts - Caps; only the size/ratio/`onBomb` fields are consulted here.
|
|
166
|
+
* @returns The inflated body as a `Uint8Array`.
|
|
167
|
+
* @throws {DecompressionBombError} When an inflating cap is exceeded (`413`).
|
|
168
|
+
* @throws {MalformedCompressedBodyError} When the input is not a valid stream (`400`).
|
|
169
|
+
* @throws {UnsupportedContentEncodingError} When the runtime cannot inflate the encoding (`415`).
|
|
170
|
+
* @since 0.37.0
|
|
171
|
+
*/
|
|
172
|
+
export declare function decompressRequestBody(compressed: Uint8Array, encoding: RequestDecompressionEncoding, opts: RequestDecompressionOptions): Promise<Uint8Array>;
|
|
173
|
+
/**
|
|
174
|
+
* Opt-in middleware that decompresses inbound request bodies behind a
|
|
175
|
+
* decompression-bomb guard. Inflates `gzip` / `deflate` request bodies under an
|
|
176
|
+
* absolute size cap and an expansion-ratio cap, then hands the inflated bytes to
|
|
177
|
+
* the framework's normal body pipeline so schema validation and raw-body reads
|
|
178
|
+
* both see the decompressed payload.
|
|
179
|
+
*
|
|
180
|
+
* Register it globally so it runs before the per-request context is built:
|
|
181
|
+
*
|
|
182
|
+
* ```ts
|
|
183
|
+
* app.use(requestDecompression({
|
|
184
|
+
* maxDecompressedBytes: 1024 * 1024, // inflated body never exceeds 1 MiB
|
|
185
|
+
* maxCompressedBytes: 64 * 1024, // reject compressed uploads over 64 KiB
|
|
186
|
+
* maxRatio: 50, // and never expand more than 50x
|
|
187
|
+
* }));
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* Requests without a `Content-Encoding` (or `identity`) pass through untouched.
|
|
191
|
+
* `GET` / `HEAD` requests are never decompressed. Unknown, unsupported, or
|
|
192
|
+
* layered encodings are refused with `415`; malformed streams with `400`; bombs
|
|
193
|
+
* with `413` (thrown mid-inflation).
|
|
194
|
+
*
|
|
195
|
+
* @param opts - Bomb-guard caps and the encoding allowlist. `maxDecompressedBytes` is required.
|
|
196
|
+
* @returns A {@link "./types.js".Hooks} bundle exposing only an `onRequest` hook.
|
|
197
|
+
* @throws {TypeError} At construction when a cap is invalid.
|
|
198
|
+
* @since 0.37.0
|
|
199
|
+
*/
|
|
200
|
+
export declare function requestDecompression(opts: RequestDecompressionOptions): Hooks;
|