@daloyjs/core 1.0.0-rc.5 → 1.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -12
- package/dist/adapters/bun.js +1 -2
- package/dist/adapters/node.js +16 -30
- package/dist/app.d.ts +5 -1
- package/dist/app.js +74 -6
- package/dist/auto-ban.js +1 -3
- package/dist/cli.js +9 -6
- package/dist/config.js +1 -3
- package/dist/errors.js +2 -5
- package/dist/etag.js +12 -2
- package/dist/geo-block.js +4 -9
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.js +3 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/ip-reputation.js +1 -1
- package/dist/ip-restriction.js +3 -12
- package/dist/jwt.js +12 -14
- package/dist/logger.js +1 -3
- package/dist/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/safe-redirect.js +3 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/waf.js +40 -8
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- package/package.json +2 -2
package/dist/tenancy.d.ts
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
|
*
|
|
@@ -161,6 +167,40 @@ export interface ClaimTenantOptions {
|
|
|
161
167
|
*/
|
|
162
168
|
export declare function tenantFromClaim(claim: string, opts?: ClaimTenantOptions): TenantResolver;
|
|
163
169
|
/** Status codes acceptable for an unresolved-tenant rejection. @since 0.42.0 */
|
|
170
|
+
/**
|
|
171
|
+
* Marker stamped on the `Hooks` object returned by {@link tenancy}, so the `App`
|
|
172
|
+
* boot guard can verify that a `responseCache()` in the same chain is mounted
|
|
173
|
+
* *after* tenancy — i.e. that the tenant is in `ctx.state` by the time the cache
|
|
174
|
+
* key is built.
|
|
175
|
+
*
|
|
176
|
+
* @since 1.0.0
|
|
177
|
+
*/
|
|
178
|
+
export declare const TENANCY_HOOK_MARKER: unique symbol;
|
|
179
|
+
/**
|
|
180
|
+
* `ctx.state` symbol under which {@link tenancy} records the tenant it resolved,
|
|
181
|
+
* independently of the configurable {@link TenancyOptions.stateKey}.
|
|
182
|
+
*
|
|
183
|
+
* Consumers that must partition shared state per tenant — notably
|
|
184
|
+
* `responseCache()`, which folds it into the cache key to prevent cross-tenant
|
|
185
|
+
* cached-response disclosure (CWE-524) — read this instead of guessing the
|
|
186
|
+
* `stateKey`. Kept in the global symbol registry so a consumer can re-derive it
|
|
187
|
+
* with `Symbol.for(...)` without importing this module.
|
|
188
|
+
*
|
|
189
|
+
* @since 1.0.0
|
|
190
|
+
*/
|
|
191
|
+
export declare const TENANCY_RESOLVED_MARKER: unique symbol;
|
|
192
|
+
/**
|
|
193
|
+
* Value recorded under {@link TENANCY_RESOLVED_MARKER} when {@link tenancy} ran
|
|
194
|
+
* but resolved no tenant (only reachable with `tenancy({ require: false })`).
|
|
195
|
+
*
|
|
196
|
+
* Distinguishing "tenancy is active and resolved nothing" from "no tenancy at
|
|
197
|
+
* all" lets a consumer keep tenant-less traffic in its own partition rather than
|
|
198
|
+
* sharing the unpartitioned one. The leading space cannot occur in a normalized
|
|
199
|
+
* tenant id, so it can never collide with a real one.
|
|
200
|
+
*
|
|
201
|
+
* @since 1.0.0
|
|
202
|
+
*/
|
|
203
|
+
export declare const TENANT_UNRESOLVED = " unresolved";
|
|
164
204
|
export type UnresolvedStatus = 400 | 401 | 403 | 404;
|
|
165
205
|
/** Status codes acceptable for an unknown/disallowed-tenant rejection. @since 0.42.0 */
|
|
166
206
|
export type InvalidStatus = 400 | 403 | 404;
|
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/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,
|
|
@@ -171,13 +173,30 @@ function safeDecode(value) {
|
|
|
171
173
|
* deliver query/path values.
|
|
172
174
|
*/
|
|
173
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;
|
|
174
191
|
/**
|
|
175
192
|
* Expand a single inbound string into the variants the WAF should scan.
|
|
176
193
|
*
|
|
177
194
|
* Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
|
|
178
|
-
* a `+`→space form (URLSearchParams parity),
|
|
195
|
+
* a `+`→space form (URLSearchParams parity), a SQL-comment-stripped
|
|
179
196
|
* form so comment-split keywords (e.g. OR wrapped in block comments) score
|
|
180
|
-
* the same as the whitespace-separated form
|
|
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`).
|
|
181
200
|
*
|
|
182
201
|
* Scanning variants is pure defense-in-depth: the handler still receives
|
|
183
202
|
* whatever the framework's single-decode path produced. Each variant is
|
|
@@ -214,6 +233,19 @@ function inspectionVariants(value, maxValueLength) {
|
|
|
214
233
|
push(v.replace(/\+/g, " "));
|
|
215
234
|
if (v.includes("/*"))
|
|
216
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
|
+
}
|
|
217
249
|
}
|
|
218
250
|
return out;
|
|
219
251
|
}
|
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": {
|
|
@@ -254,7 +254,7 @@
|
|
|
254
254
|
"bench:json-e2e": "node --import tsx bench/json-body-e2e.bench.ts",
|
|
255
255
|
"bench:ablation": "pnpm build && node --import tsx bench/ablation.bench.ts",
|
|
256
256
|
"test": "node --import tsx --test tests/**/*.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",
|
|
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",
|
|
258
258
|
"red-team:live": "node --import tsx red-team-live/run.ts",
|
|
259
259
|
"coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
|
|
260
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",
|