@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -22
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +42 -7
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +117 -46
- package/dist/app.d.ts +30 -4
- package/dist/app.js +187 -45
- package/dist/auto-ban.js +1 -3
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +9 -6
- package/dist/client.d.ts +36 -7
- package/dist/client.js +7 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +14 -8
- package/dist/etag.js +12 -2
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/geo-block.js +4 -9
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +16 -9
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/ip-reputation.js +1 -1
- package/dist/ip-restriction.js +3 -12
- package/dist/jwt.js +12 -14
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +135 -0
- package/dist/mcp.js +10 -9
- package/dist/middleware.js +33 -3
- package/dist/mtls.js +6 -1
- 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/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +5 -1
- package/dist/safe-redirect.js +27 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/security.d.ts +41 -0
- package/dist/security.js +131 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/time-claims.js +3 -1
- package/dist/waf.js +124 -32
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- package/package.json +6 -5
package/dist/tenancy.js
CHANGED
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
* (as the first group hook, or in `AppOptions.hooks`) so `ctx.state.tenant`
|
|
33
33
|
* is populated by the time their `keyGenerator` / `scope` callbacks run.
|
|
34
34
|
*
|
|
35
|
+
* `responseCache()` is a special case in two ways: it partitions on the resolved
|
|
36
|
+
* tenant **automatically** (no `keyGenerator` needed — see
|
|
37
|
+
* {@link TENANCY_RESOLVED_MARKER}), and because a mis-ordered cache is a silent
|
|
38
|
+
* cross-tenant disclosure rather than a merely wrong bucket, mounting it *ahead*
|
|
39
|
+
* of `tenancy()` refuses to boot in production instead of leaking.
|
|
40
|
+
*
|
|
35
41
|
* ```ts
|
|
36
42
|
* import { App, tenancy, tenantFromSubdomain, tenantScope, rateLimit } from "@daloyjs/core";
|
|
37
43
|
*
|
|
@@ -48,7 +54,7 @@
|
|
|
48
54
|
*
|
|
49
55
|
* @since 0.42.0
|
|
50
56
|
*/
|
|
51
|
-
import { BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError
|
|
57
|
+
import { BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError } from "./errors.js";
|
|
52
58
|
import { subdomains } from "./subdomains.js";
|
|
53
59
|
/**
|
|
54
60
|
* Conservative default tenant-id grammar: a DNS-label-like token, lowercase
|
|
@@ -181,6 +187,41 @@ export function tenantFromClaim(claim, opts = {}) {
|
|
|
181
187
|
return undefined;
|
|
182
188
|
};
|
|
183
189
|
}
|
|
190
|
+
/** Status codes acceptable for an unresolved-tenant rejection. @since 0.42.0 */
|
|
191
|
+
/**
|
|
192
|
+
* Marker stamped on the `Hooks` object returned by {@link tenancy}, so the `App`
|
|
193
|
+
* boot guard can verify that a `responseCache()` in the same chain is mounted
|
|
194
|
+
* *after* tenancy — i.e. that the tenant is in `ctx.state` by the time the cache
|
|
195
|
+
* key is built.
|
|
196
|
+
*
|
|
197
|
+
* @since 1.0.0
|
|
198
|
+
*/
|
|
199
|
+
export const TENANCY_HOOK_MARKER = Symbol.for("daloyjs.tenancy.hook");
|
|
200
|
+
/**
|
|
201
|
+
* `ctx.state` symbol under which {@link tenancy} records the tenant it resolved,
|
|
202
|
+
* independently of the configurable {@link TenancyOptions.stateKey}.
|
|
203
|
+
*
|
|
204
|
+
* Consumers that must partition shared state per tenant — notably
|
|
205
|
+
* `responseCache()`, which folds it into the cache key to prevent cross-tenant
|
|
206
|
+
* cached-response disclosure (CWE-524) — read this instead of guessing the
|
|
207
|
+
* `stateKey`. Kept in the global symbol registry so a consumer can re-derive it
|
|
208
|
+
* with `Symbol.for(...)` without importing this module.
|
|
209
|
+
*
|
|
210
|
+
* @since 1.0.0
|
|
211
|
+
*/
|
|
212
|
+
export const TENANCY_RESOLVED_MARKER = Symbol.for("daloyjs.tenancy.resolved");
|
|
213
|
+
/**
|
|
214
|
+
* Value recorded under {@link TENANCY_RESOLVED_MARKER} when {@link tenancy} ran
|
|
215
|
+
* but resolved no tenant (only reachable with `tenancy({ require: false })`).
|
|
216
|
+
*
|
|
217
|
+
* Distinguishing "tenancy is active and resolved nothing" from "no tenancy at
|
|
218
|
+
* all" lets a consumer keep tenant-less traffic in its own partition rather than
|
|
219
|
+
* sharing the unpartitioned one. The leading space cannot occur in a normalized
|
|
220
|
+
* tenant id, so it can never collide with a real one.
|
|
221
|
+
*
|
|
222
|
+
* @since 1.0.0
|
|
223
|
+
*/
|
|
224
|
+
export const TENANT_UNRESOLVED = " unresolved";
|
|
184
225
|
/** Build the right `HttpError` for a configured status. */
|
|
185
226
|
function rejection(status, detail) {
|
|
186
227
|
switch (status) {
|
|
@@ -233,8 +274,13 @@ export function tenancy(opts) {
|
|
|
233
274
|
else if (typeof opts.allow === "function") {
|
|
234
275
|
allowFn = opts.allow;
|
|
235
276
|
}
|
|
236
|
-
|
|
277
|
+
const hooks = {
|
|
237
278
|
async beforeHandle(ctx) {
|
|
279
|
+
const state = ctx.state;
|
|
280
|
+
// Record "tenancy ran" up front, so a tenant-less request is partitioned
|
|
281
|
+
// as such by downstream consumers instead of falling into the shared,
|
|
282
|
+
// unpartitioned bucket alongside resolved tenants.
|
|
283
|
+
state[TENANCY_RESOLVED_MARKER] = TENANT_UNRESOLVED;
|
|
238
284
|
let raw;
|
|
239
285
|
for (const resolve of resolvers) {
|
|
240
286
|
raw = await resolve(ctx);
|
|
@@ -259,9 +305,14 @@ export function tenancy(opts) {
|
|
|
259
305
|
if (allowFn && !(await allowFn(id, ctx))) {
|
|
260
306
|
throw rejection(invalidStatus, "Unknown tenant.");
|
|
261
307
|
}
|
|
262
|
-
|
|
308
|
+
state[stateKey] = id;
|
|
309
|
+
state[TENANCY_RESOLVED_MARKER] = id;
|
|
263
310
|
},
|
|
264
311
|
};
|
|
312
|
+
// Let the App boot guard see tenancy's position in the hook chain relative to
|
|
313
|
+
// any responseCache() that must partition on the tenant it resolves.
|
|
314
|
+
hooks[TENANCY_HOOK_MARKER] = true;
|
|
315
|
+
return hooks;
|
|
265
316
|
}
|
|
266
317
|
/**
|
|
267
318
|
* Build a `(ctx) => string` key function that reads the resolved tenant and
|
package/dist/time-claims.js
CHANGED
|
@@ -69,7 +69,9 @@ export function assertTemporalClaims(claims, opts) {
|
|
|
69
69
|
if (!isFiniteNumber(claims.exp)) {
|
|
70
70
|
throw new TemporalClaimError("invalid_exp", "payload.exp is not a finite number.");
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
// RFC 7519 §4.1.4: current time must be *before* exp. At the exact
|
|
73
|
+
// expiration second (after skew) the token is no longer valid.
|
|
74
|
+
if (now >= claims.exp + skew) {
|
|
73
75
|
throw new TemporalClaimError("token_expired", "token has expired (exp).");
|
|
74
76
|
}
|
|
75
77
|
}
|
package/dist/waf.js
CHANGED
|
@@ -45,12 +45,7 @@ import { ForbiddenError } from "./errors.js";
|
|
|
45
45
|
import { hasMongoOperatorKeys } from "./security.js";
|
|
46
46
|
import { readRemoteAddress } from "./conn-info.js";
|
|
47
47
|
/** The four built-in rule categories, in stable order. */
|
|
48
|
-
const ALL_RULE_IDS = Object.freeze([
|
|
49
|
-
"sqli",
|
|
50
|
-
"xss",
|
|
51
|
-
"nosqli",
|
|
52
|
-
"cmdi",
|
|
53
|
-
]);
|
|
48
|
+
const ALL_RULE_IDS = Object.freeze(["sqli", "xss", "nosqli", "cmdi"]);
|
|
54
49
|
/** Default anomaly score contributed by each rule when it matches. */
|
|
55
50
|
const DEFAULT_RULE_SCORE = 5;
|
|
56
51
|
/** Default total anomaly score at which a request is blocked / reported. */
|
|
@@ -69,6 +64,13 @@ const SQLI_SIGNATURES = Object.freeze([
|
|
|
69
64
|
/\bUNION\b[\s\S]{0,40}?\bSELECT\b/i,
|
|
70
65
|
/\b(?:OR|AND)\b\s+['"]?\d+['"]?\s*=\s*['"]?\d+/i,
|
|
71
66
|
/'\s*(?:OR|AND)\s+'?[\w]+'?\s*=\s*'?[\w]+/i,
|
|
67
|
+
// Parenthesized subquery behind a boolean operator — `1 OR (SELECT 1)`. The
|
|
68
|
+
// tautology patterns above anchor on `= <digit>`, so a subquery carrying no
|
|
69
|
+
// comparison slipped through. Paired with the comment-stripped inspection
|
|
70
|
+
// variant this also catches `1/**/OR/**/(SELECT/**/1)`. High confidence:
|
|
71
|
+
// prose query values virtually never contain `OR (` immediately followed by
|
|
72
|
+
// the SELECT keyword.
|
|
73
|
+
/\b(?:OR|AND)\s*\(\s*SELECT\b/i,
|
|
72
74
|
/;\s*(?:DROP|DELETE|INSERT|UPDATE|TRUNCATE|ALTER|CREATE)\b/i,
|
|
73
75
|
/\b(?:SLEEP|BENCHMARK|PG_SLEEP)\s*\(/i,
|
|
74
76
|
/\bWAITFOR\s+DELAY\b/i,
|
|
@@ -161,6 +163,105 @@ function safeDecode(value) {
|
|
|
161
163
|
return value;
|
|
162
164
|
}
|
|
163
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Maximum percent-decode passes applied when expanding inspection variants.
|
|
168
|
+
*
|
|
169
|
+
* One pass matches what most HTTP stacks hand the handler. A second pass
|
|
170
|
+
* catches classic double-encoding WAF evasions (`%2527` → `%27` → `'`). A
|
|
171
|
+
* third is omitted on purpose: deeper recursive decoding inflates false
|
|
172
|
+
* positives on legitimately percent-bearing text and is not how frameworks
|
|
173
|
+
* deliver query/path values.
|
|
174
|
+
*/
|
|
175
|
+
const MAX_DECODE_PASSES = 2;
|
|
176
|
+
/**
|
|
177
|
+
* Control characters that are NOT matched by JS `\s`, used to split keywords
|
|
178
|
+
* past whitespace-anchored signatures (e.g. `1'%00OR%001=1`).
|
|
179
|
+
*
|
|
180
|
+
* U+0009-U+000D (\t \n \v \f \r) are deliberately absent: `\s` already
|
|
181
|
+
* matches them and every signature separates tokens with `\s`, `\b`, or
|
|
182
|
+
* `[\s\S]`, so normalizing them would only duplicate an existing variant.
|
|
183
|
+
*
|
|
184
|
+
* Hoisted to module scope so the hot path neither re-creates the RegExp object
|
|
185
|
+
* nor pays literal-evaluation overhead per inspected value. The probe is
|
|
186
|
+
* non-global (stateless `test()`); the replace copy is global and `replace()`
|
|
187
|
+
* resets `lastIndex`, so neither carries state between calls.
|
|
188
|
+
*/
|
|
189
|
+
const CONTROL_CHAR_PROBE = /[\u0000-\u0008\u000e-\u001f\u007f]/;
|
|
190
|
+
const CONTROL_CHAR_GLOBAL = /[\u0000-\u0008\u000e-\u001f\u007f]/g;
|
|
191
|
+
/**
|
|
192
|
+
* Expand a single inbound string into the variants the WAF should scan.
|
|
193
|
+
*
|
|
194
|
+
* Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
|
|
195
|
+
* a `+`→space form (URLSearchParams parity), a SQL-comment-stripped
|
|
196
|
+
* form so comment-split keywords (e.g. OR wrapped in block comments) score
|
|
197
|
+
* the same as the whitespace-separated form, and a control-character→space
|
|
198
|
+
* form so embedded NUL / escape bytes cannot split keywords past the
|
|
199
|
+
* whitespace-anchored signatures (e.g. `1'%00OR%001=1` → `1' OR 1=1`).
|
|
200
|
+
*
|
|
201
|
+
* Scanning variants is pure defense-in-depth: the handler still receives
|
|
202
|
+
* whatever the framework's single-decode path produced. Each variant is
|
|
203
|
+
* truncated to `maxValueLength` and deduplicated so hostile inputs cannot
|
|
204
|
+
* explode the scan set.
|
|
205
|
+
*
|
|
206
|
+
* @param value - Raw or already-decoded string from path/query/header/body.
|
|
207
|
+
* @param maxValueLength - Cap applied to every variant before scanning.
|
|
208
|
+
* @returns Deduplicated inspection variants in stable insertion order.
|
|
209
|
+
*/
|
|
210
|
+
function inspectionVariants(value, maxValueLength) {
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
const out = [];
|
|
213
|
+
const push = (v) => {
|
|
214
|
+
const truncated = v.length > maxValueLength ? v.slice(0, maxValueLength) : v;
|
|
215
|
+
if (!seen.has(truncated)) {
|
|
216
|
+
seen.add(truncated);
|
|
217
|
+
out.push(truncated);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
let current = value;
|
|
221
|
+
push(current);
|
|
222
|
+
for (let i = 0; i < MAX_DECODE_PASSES; i++) {
|
|
223
|
+
const decoded = safeDecode(current);
|
|
224
|
+
if (decoded === current)
|
|
225
|
+
break;
|
|
226
|
+
push(decoded);
|
|
227
|
+
current = decoded;
|
|
228
|
+
}
|
|
229
|
+
// Snapshot before secondary transforms so we only expand the decode chain.
|
|
230
|
+
const decodedChain = out.slice();
|
|
231
|
+
for (const v of decodedChain) {
|
|
232
|
+
if (v.includes("+"))
|
|
233
|
+
push(v.replace(/\+/g, " "));
|
|
234
|
+
if (v.includes("/*"))
|
|
235
|
+
push(v.replace(/\/\*[\s\S]*?\*\//g, " "));
|
|
236
|
+
// Control characters (notably NUL) are not `\s`, so `1'%00OR%001=1` split
|
|
237
|
+
// `OR` from `1=1` and walked past the whitespace-anchored signatures. Scan
|
|
238
|
+
// a control-char→space form; benign traffic carries almost no C0 bytes, so
|
|
239
|
+
// the false-positive surface is negligible.
|
|
240
|
+
//
|
|
241
|
+
// The class deliberately excludes U+0009-U+000D (\t \n \v \f \r): JS `\s`
|
|
242
|
+
// already matches those, and every signature separates tokens with `\s`,
|
|
243
|
+
// `\b`, or `[\s\S]`, so normalizing them only ever yields a variant that
|
|
244
|
+
// scores identically to one already in the set. Including them cost ~13%
|
|
245
|
+
// on every request carrying a multi-line body or query value.
|
|
246
|
+
if (CONTROL_CHAR_PROBE.test(v)) {
|
|
247
|
+
push(v.replace(CONTROL_CHAR_GLOBAL, " "));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Scan every inspection variant of `value` for the active rule set.
|
|
254
|
+
*
|
|
255
|
+
* @see inspectionVariants
|
|
256
|
+
*/
|
|
257
|
+
function scanValueVariants(value, location, rules, scored, maxValueLength) {
|
|
258
|
+
for (const variant of inspectionVariants(value, maxValueLength)) {
|
|
259
|
+
scanValue(variant, location, rules, scored);
|
|
260
|
+
// Early exit once every rule has already fired — no further variants needed.
|
|
261
|
+
if (scored.size === rules.length)
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
164
265
|
/**
|
|
165
266
|
* Collect up to `maxNodes` string values from a parsed body value (object /
|
|
166
267
|
* array / scalar), each truncated to `maxValueLength`. Depth and node count are
|
|
@@ -268,37 +369,29 @@ export function waf(opts = {}) {
|
|
|
268
369
|
const scored = new Map();
|
|
269
370
|
const url = new URL(ctx.request.url);
|
|
270
371
|
if (inspectPath) {
|
|
271
|
-
|
|
372
|
+
// Path is scanned across raw + up to two decode passes so double-
|
|
373
|
+
// encoded traversal / injection tokens in path segments still score.
|
|
374
|
+
scanValueVariants(url.pathname, "path", rules, scored, maxValueLength);
|
|
272
375
|
}
|
|
273
376
|
if (inspectQuery && url.search.length > 1) {
|
|
274
|
-
// Scan
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// text, and a double-encoded payload stays inert (`%3Cscript%3E`) all
|
|
281
|
-
// the way to the handler. See red-team-attacks-6 "DOCUMENTED LIMITATION".
|
|
377
|
+
// Scan the raw query, bounded multi-decode variants, and each
|
|
378
|
+
// URLSearchParams key/value. Multi-decode (max 2) closes classic
|
|
379
|
+
// double-encoding WAF evasions (`%2527` → `%27` → `'`) without open-
|
|
380
|
+
// ended recursive decoding. URLSearchParams also turns `+` into
|
|
381
|
+
// space; inspectionVariants covers that form so `1+OR+1=1` scores
|
|
382
|
+
// the same as `1 OR 1=1` (parser-differential defense).
|
|
282
383
|
const raw = url.search.slice(1);
|
|
283
|
-
|
|
284
|
-
const decoded = safeDecode(raw);
|
|
285
|
-
if (decoded !== raw)
|
|
286
|
-
scanValue(decoded, "query", rules, scored);
|
|
287
|
-
// Additionally inspect each key/value the way the app's OWN query parser
|
|
288
|
-
// (`URLSearchParams`) decodes them: notably `+` becomes a space, which a
|
|
289
|
-
// plain `decodeURIComponent` does NOT do. Without this, `1+OR+1=1` slipped
|
|
290
|
-
// past the WAF while the handler still received `1 OR 1=1` (a parser
|
|
291
|
-
// differential — the WAF must inspect the bytes the app actually parses).
|
|
384
|
+
scanValueVariants(raw, "query", rules, scored, maxValueLength);
|
|
292
385
|
for (const [k, v] of url.searchParams) {
|
|
293
|
-
|
|
294
|
-
|
|
386
|
+
scanValueVariants(k, "query", rules, scored, maxValueLength);
|
|
387
|
+
scanValueVariants(v, "query", rules, scored, maxValueLength);
|
|
295
388
|
}
|
|
296
389
|
}
|
|
297
390
|
if (headerAllowlist.length > 0) {
|
|
298
391
|
for (const name of headerAllowlist) {
|
|
299
392
|
const value = ctx.request.headers.get(name);
|
|
300
393
|
if (value)
|
|
301
|
-
|
|
394
|
+
scanValueVariants(value, "header", rules, scored, maxValueLength);
|
|
302
395
|
}
|
|
303
396
|
}
|
|
304
397
|
if (inspectBody && ctx.body !== undefined && ctx.body !== null) {
|
|
@@ -316,14 +409,13 @@ export function waf(opts = {}) {
|
|
|
316
409
|
});
|
|
317
410
|
}
|
|
318
411
|
if (typeof ctx.body === "string") {
|
|
319
|
-
|
|
320
|
-
? ctx.body.slice(0, maxValueLength)
|
|
321
|
-
: ctx.body, "body", rules, scored);
|
|
412
|
+
scanValueVariants(ctx.body, "body", rules, scored, maxValueLength);
|
|
322
413
|
}
|
|
323
414
|
else if (typeof ctx.body === "object") {
|
|
324
415
|
const strings = collectBodyStrings(ctx.body, maxBodyNodes, maxValueLength);
|
|
325
|
-
for (const value of strings)
|
|
326
|
-
|
|
416
|
+
for (const value of strings) {
|
|
417
|
+
scanValueVariants(value, "body", rules, scored, maxValueLength);
|
|
418
|
+
}
|
|
327
419
|
}
|
|
328
420
|
}
|
|
329
421
|
if (scored.size === 0)
|
package/dist/webhook-delivery.js
CHANGED
|
@@ -256,11 +256,25 @@ export function createWebhookSender(options) {
|
|
|
256
256
|
lastError = undefined;
|
|
257
257
|
if (response.ok) {
|
|
258
258
|
options.onAttempt?.({ id, attempt, status: response.status, willRetry: false });
|
|
259
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
id,
|
|
262
|
+
eventType: event.eventType,
|
|
263
|
+
attempts: attempt,
|
|
264
|
+
status: response.status,
|
|
265
|
+
response,
|
|
266
|
+
deadLettered: false,
|
|
267
|
+
};
|
|
260
268
|
}
|
|
261
269
|
const retryable = retryStatuses.has(response.status) && attempt < maxAttempts;
|
|
262
270
|
const delayMs = retryable ? backoffFor(attempt, response) : undefined;
|
|
263
|
-
options.onAttempt?.({
|
|
271
|
+
options.onAttempt?.({
|
|
272
|
+
id,
|
|
273
|
+
attempt,
|
|
274
|
+
status: response.status,
|
|
275
|
+
willRetry: retryable,
|
|
276
|
+
delayMs,
|
|
277
|
+
});
|
|
264
278
|
if (!retryable)
|
|
265
279
|
break;
|
|
266
280
|
await sleep(delayMs);
|
|
@@ -291,7 +305,9 @@ export function createWebhookSender(options) {
|
|
|
291
305
|
contentType,
|
|
292
306
|
attempts: madeAttempts,
|
|
293
307
|
...(lastStatus !== undefined ? { lastStatus } : {}),
|
|
294
|
-
...(lastError !== undefined
|
|
308
|
+
...(lastError !== undefined
|
|
309
|
+
? { lastError: lastError instanceof Error ? lastError.message : String(lastError) }
|
|
310
|
+
: {}),
|
|
295
311
|
timestamp,
|
|
296
312
|
failedAt: now(),
|
|
297
313
|
});
|
package/dist/websocket.d.ts
CHANGED
|
@@ -445,14 +445,22 @@ export declare const FRAME_INCOMPLETE: unique symbol;
|
|
|
445
445
|
* @param buf - Buffered socket bytes beginning at a frame boundary.
|
|
446
446
|
* @param opts - `requireMask: true` enforces the RFC 6455 rule that
|
|
447
447
|
* client-to-server frames are masked. Defaults to `{}` (not enforced).
|
|
448
|
+
* `maxPayload` rejects data frames whose **declared** payload length
|
|
449
|
+
* exceeds the limit as soon as the header is parsed, before the payload
|
|
450
|
+
* bytes have arrived — this keeps a slow-or-stalled sender from making
|
|
451
|
+
* the caller buffer an oversized incomplete frame. Control frames are
|
|
452
|
+
* already capped at 125 bytes and are not affected.
|
|
448
453
|
* @returns The decoded {@link ParsedFrame}, or {@link FRAME_INCOMPLETE}
|
|
449
454
|
* when more bytes are needed.
|
|
450
455
|
* @throws WebSocketProtocolError on RSV bits, unknown opcodes, fragmented
|
|
451
456
|
* or oversized control frames, unmasked client frames, or payload lengths
|
|
452
457
|
* above `Number.MAX_SAFE_INTEGER`.
|
|
458
|
+
* @throws WebSocketPayloadTooLargeError when `maxPayload` is set and a data
|
|
459
|
+
* frame declares a payload length above it.
|
|
453
460
|
*/
|
|
454
461
|
export declare function parseFrame(buf: Uint8Array, opts?: {
|
|
455
462
|
requireMask?: boolean;
|
|
463
|
+
maxPayload?: number;
|
|
456
464
|
}): ParsedFrame | typeof FRAME_INCOMPLETE;
|
|
457
465
|
/**
|
|
458
466
|
* Encode a single frame. By default the frame is emitted unmasked (server
|
package/dist/websocket.js
CHANGED
|
@@ -92,7 +92,8 @@ function assertWebSocketOriginPolicy(policy) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
function schemaToJson(schema) {
|
|
95
|
-
const converter = schema
|
|
95
|
+
const converter = schema
|
|
96
|
+
?.toJSONSchema;
|
|
96
97
|
if (typeof converter !== "function")
|
|
97
98
|
return undefined;
|
|
98
99
|
try {
|
|
@@ -501,11 +502,18 @@ export const FRAME_INCOMPLETE = Symbol("daloy.ws.frameIncomplete");
|
|
|
501
502
|
* @param buf - Buffered socket bytes beginning at a frame boundary.
|
|
502
503
|
* @param opts - `requireMask: true` enforces the RFC 6455 rule that
|
|
503
504
|
* client-to-server frames are masked. Defaults to `{}` (not enforced).
|
|
505
|
+
* `maxPayload` rejects data frames whose **declared** payload length
|
|
506
|
+
* exceeds the limit as soon as the header is parsed, before the payload
|
|
507
|
+
* bytes have arrived — this keeps a slow-or-stalled sender from making
|
|
508
|
+
* the caller buffer an oversized incomplete frame. Control frames are
|
|
509
|
+
* already capped at 125 bytes and are not affected.
|
|
504
510
|
* @returns The decoded {@link ParsedFrame}, or {@link FRAME_INCOMPLETE}
|
|
505
511
|
* when more bytes are needed.
|
|
506
512
|
* @throws WebSocketProtocolError on RSV bits, unknown opcodes, fragmented
|
|
507
513
|
* or oversized control frames, unmasked client frames, or payload lengths
|
|
508
514
|
* above `Number.MAX_SAFE_INTEGER`.
|
|
515
|
+
* @throws WebSocketPayloadTooLargeError when `maxPayload` is set and a data
|
|
516
|
+
* frame declares a payload length above it.
|
|
509
517
|
*/
|
|
510
518
|
export function parseFrame(buf, opts = {}) {
|
|
511
519
|
if (buf.length < 2)
|
|
@@ -525,9 +533,7 @@ export function parseFrame(buf, opts = {}) {
|
|
|
525
533
|
throw new WebSocketProtocolError("Control frames must not be fragmented");
|
|
526
534
|
if (payloadLen > WS_MAX_CONTROL_PAYLOAD)
|
|
527
535
|
throw new WebSocketProtocolError("Control frame payload exceeds 125 bytes");
|
|
528
|
-
if (opcode !== WS_OPCODE.CLOSE &&
|
|
529
|
-
opcode !== WS_OPCODE.PING &&
|
|
530
|
-
opcode !== WS_OPCODE.PONG)
|
|
536
|
+
if (opcode !== WS_OPCODE.CLOSE && opcode !== WS_OPCODE.PING && opcode !== WS_OPCODE.PONG)
|
|
531
537
|
throw new WebSocketProtocolError(`Unknown control opcode 0x${opcode.toString(16)}`);
|
|
532
538
|
}
|
|
533
539
|
else if (opcode !== WS_OPCODE.CONTINUATION &&
|
|
@@ -558,6 +564,14 @@ export function parseFrame(buf, opts = {}) {
|
|
|
558
564
|
payloadLen = hi * 2 ** 32 + lo;
|
|
559
565
|
offset += 8;
|
|
560
566
|
}
|
|
567
|
+
// Reject oversized declared lengths as soon as the header is complete —
|
|
568
|
+
// before waiting on mask or payload bytes — so an attacker cannot make the
|
|
569
|
+
// caller buffer an unbounded incomplete frame by trickling payload bytes.
|
|
570
|
+
// Cumulative accounting across fragments stays with the caller; a declared
|
|
571
|
+
// length above the limit always implies the assembled message exceeds it.
|
|
572
|
+
if ((opcode & 0x8) === 0 && opts.maxPayload !== undefined && payloadLen > opts.maxPayload) {
|
|
573
|
+
throw new WebSocketPayloadTooLargeError(opts.maxPayload, payloadLen);
|
|
574
|
+
}
|
|
561
575
|
if (opts.requireMask && !masked) {
|
|
562
576
|
throw new WebSocketProtocolError("Client frames must be masked");
|
|
563
577
|
}
|
|
@@ -745,6 +759,7 @@ export class FrameSink {
|
|
|
745
759
|
while (this.buffer.length > 0) {
|
|
746
760
|
const frame = parseFrame(this.buffer, {
|
|
747
761
|
requireMask: this.opts.requireMask,
|
|
762
|
+
maxPayload: this.opts.maxPayloadLength,
|
|
748
763
|
});
|
|
749
764
|
if (frame === FRAME_INCOMPLETE)
|
|
750
765
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.6",
|
|
4
4
|
"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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -236,12 +236,12 @@
|
|
|
236
236
|
}
|
|
237
237
|
},
|
|
238
238
|
"devDependencies": {
|
|
239
|
-
"@hey-api/openapi-ts": "
|
|
239
|
+
"@hey-api/openapi-ts": "0.0.0-next-20260711024907",
|
|
240
240
|
"@types/node": "^26.0.1",
|
|
241
241
|
"fast-check": "^4.8.0",
|
|
242
242
|
"prettier": "^3.8.3",
|
|
243
243
|
"tsx": "^4.22.3",
|
|
244
|
-
"typescript": "^
|
|
244
|
+
"typescript": "^7.0.2",
|
|
245
245
|
"zod": "^4.4.3"
|
|
246
246
|
},
|
|
247
247
|
"scripts": {
|
|
@@ -249,11 +249,12 @@
|
|
|
249
249
|
"dev": "tsc -w -p tsconfig.json",
|
|
250
250
|
"example": "node --import tsx examples/basic.ts",
|
|
251
251
|
"bench": "node --import tsx bench/router.bench.ts",
|
|
252
|
-
"bench:serverless": "node --import tsx bench/serverless-cold-path.bench.ts",
|
|
252
|
+
"bench:serverless": "pnpm build && node --import tsx bench/serverless-cold-path.bench.ts",
|
|
253
253
|
"bench:json": "node --import tsx bench/json-body.bench.ts",
|
|
254
254
|
"bench:json-e2e": "node --import tsx bench/json-body-e2e.bench.ts",
|
|
255
|
+
"bench:ablation": "pnpm build && node --import tsx bench/ablation.bench.ts",
|
|
255
256
|
"test": "node --import tsx --test tests/**/*.test.ts",
|
|
256
|
-
"test:red-team": "node --import tsx --test tests/red-team-attacks.test.ts tests/red-team-attacks-2.test.ts tests/red-team-attacks-3.test.ts tests/red-team-attacks-4.test.ts tests/red-team-attacks-5.test.ts tests/red-team-attacks-6.test.ts tests/red-team-attacks-7.test.ts tests/red-team-attacks-8.test.ts tests/red-team-attacks-9.test.ts tests/red-team-attacks-10.test.ts",
|
|
257
|
+
"test:red-team": "node --import tsx --test tests/red-team-attacks.test.ts tests/red-team-attacks-2.test.ts tests/red-team-attacks-3.test.ts tests/red-team-attacks-4.test.ts tests/red-team-attacks-5.test.ts tests/red-team-attacks-6.test.ts tests/red-team-attacks-7.test.ts tests/red-team-attacks-8.test.ts tests/red-team-attacks-9.test.ts tests/red-team-attacks-10.test.ts tests/red-team-attacks-11.test.ts",
|
|
257
258
|
"red-team:live": "node --import tsx red-team-live/run.ts",
|
|
258
259
|
"coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
|
|
259
260
|
"coverage:branches": "tsc -p tsconfig.coverage.json && node --test --experimental-test-coverage --test-coverage-include='dist-coverage/src/**' --test-coverage-branches=92 dist-coverage/tests/**/*.test.js",
|