@daloyjs/core 1.0.0-rc.7 → 1.0.0-rc.9
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/dist/adapters/node.js +76 -14
- package/dist/app.d.ts +25 -0
- package/dist/app.js +84 -0
- package/dist/auto-ban.d.ts +49 -5
- package/dist/auto-ban.js +99 -24
- package/dist/bot-guard.d.ts +2 -2
- package/dist/bot-guard.js +6 -1
- package/dist/geo-block.d.ts +3 -3
- package/dist/geo-block.js +7 -1
- package/dist/idempotency.d.ts +69 -2
- package/dist/idempotency.js +151 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/ip-reputation.d.ts +2 -2
- package/dist/ip-reputation.js +6 -1
- package/dist/ip-restriction.d.ts +3 -3
- package/dist/ip-restriction.js +7 -2
- package/dist/mcp.d.ts +4 -12
- package/dist/safe-redirect.js +19 -0
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/types.d.ts +31 -0
- package/dist/waf.js +38 -2
- package/dist/websocket.d.ts +48 -5
- package/dist/websocket.js +57 -5
- package/package.json +6 -4
package/dist/waf.js
CHANGED
|
@@ -188,15 +188,32 @@ const MAX_DECODE_PASSES = 2;
|
|
|
188
188
|
*/
|
|
189
189
|
const CONTROL_CHAR_PROBE = /[\u0000-\u0008\u000e-\u001f\u007f]/;
|
|
190
190
|
const CONTROL_CHAR_GLOBAL = /[\u0000-\u0008\u000e-\u001f\u007f]/g;
|
|
191
|
+
/**
|
|
192
|
+
* Non-ASCII probe used to skip {@link String.prototype.normalize} on the pure
|
|
193
|
+
* ASCII hot path. Fullwidth Latin (U+FF01–U+FF5E), compatibility ideographs,
|
|
194
|
+
* and other NFKC-collapsible code points only appear when this matches.
|
|
195
|
+
*
|
|
196
|
+
* Hoisted so the hot path neither re-creates the RegExp nor pays a
|
|
197
|
+
* literal-evaluation cost per inspected value.
|
|
198
|
+
*/
|
|
199
|
+
const NON_ASCII_PROBE = /[^\x00-\x7F]/;
|
|
191
200
|
/**
|
|
192
201
|
* Expand a single inbound string into the variants the WAF should scan.
|
|
193
202
|
*
|
|
194
203
|
* Includes the raw value, up to {@link MAX_DECODE_PASSES} percent-decodes,
|
|
195
204
|
* a `+`→space form (URLSearchParams parity), a SQL-comment-stripped
|
|
196
205
|
* form so comment-split keywords (e.g. OR wrapped in block comments) score
|
|
197
|
-
* the same as the whitespace-separated form,
|
|
206
|
+
* the same as the whitespace-separated form, a control-character→space
|
|
198
207
|
* 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`)
|
|
208
|
+
* whitespace-anchored signatures (e.g. `1'%00OR%001=1` → `1' OR 1=1`), and
|
|
209
|
+
* an NFKC-normalized form so fullwidth / compatibility-homoglyph keywords
|
|
210
|
+
* (e.g. `union select`) score the same as their ASCII counterparts.
|
|
211
|
+
*
|
|
212
|
+
* The NFKC fold is applied to the decode chain *before* the `+` / comment /
|
|
213
|
+
* control-character passes, and its output joins that chain, so the transforms
|
|
214
|
+
* **compose**: a payload mixing homoglyphs with comment- or NUL-splitting
|
|
215
|
+
* (`'%00OR%00'1'='1`) still converges on the ASCII form the signatures
|
|
216
|
+
* anchor on. Closing each evasion only in isolation leaves the combination open.
|
|
200
217
|
*
|
|
201
218
|
* Scanning variants is pure defense-in-depth: the handler still receives
|
|
202
219
|
* whatever the framework's single-decode path produced. Each variant is
|
|
@@ -228,6 +245,25 @@ function inspectionVariants(value, maxValueLength) {
|
|
|
228
245
|
}
|
|
229
246
|
// Snapshot before secondary transforms so we only expand the decode chain.
|
|
230
247
|
const decodedChain = out.slice();
|
|
248
|
+
// Fold compatibility characters FIRST, and extend the chain with the folded
|
|
249
|
+
// forms, so the secondary transforms below run on them too. Order matters:
|
|
250
|
+
// pushing the NFKC form after the loop (or without extending `decodedChain`)
|
|
251
|
+
// leaves each evasion closed only in isolation, and composing two of them
|
|
252
|
+
// reopens the hole — `'%00OR%00'1'='1` folds to a NUL-split ASCII
|
|
253
|
+
// tautology that the control-char pass would catch, and control-strips to a
|
|
254
|
+
// fullwidth tautology that the fold would catch, but neither variant is ever
|
|
255
|
+
// subjected to the other transform. Extending the chain makes the passes
|
|
256
|
+
// compose, so any combination of fold + decode + comment/control/`+`
|
|
257
|
+
// splitting converges on the same ASCII form the signatures anchor on.
|
|
258
|
+
for (const v of out.slice()) {
|
|
259
|
+
if (NON_ASCII_PROBE.test(v)) {
|
|
260
|
+
const nfkc = v.normalize("NFKC");
|
|
261
|
+
if (nfkc !== v) {
|
|
262
|
+
push(nfkc);
|
|
263
|
+
decodedChain.push(nfkc);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
231
267
|
for (const v of decodedChain) {
|
|
232
268
|
if (v.includes("+"))
|
|
233
269
|
push(v.replace(/\+/g, " "));
|
package/dist/websocket.d.ts
CHANGED
|
@@ -20,13 +20,24 @@ export declare const WS_OPCODE: {
|
|
|
20
20
|
readonly PING: 9;
|
|
21
21
|
readonly PONG: 10;
|
|
22
22
|
};
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Common RFC 6455 / IANA close codes.
|
|
25
|
+
*
|
|
26
|
+
* `NO_STATUS_RECEIVED` (1005) and `ABNORMAL_CLOSURE` (1006) are **receive-only
|
|
27
|
+
* sentinels**: RFC 6455 §7.4.1 reserves them for reporting a local condition to
|
|
28
|
+
* the application and forbids them in a CLOSE frame on the wire. Passing either
|
|
29
|
+
* to `close()` or {@link encodeClosePayload} throws
|
|
30
|
+
* {@link WebSocketProtocolError} — to close with no status code, send an empty
|
|
31
|
+
* payload instead.
|
|
32
|
+
*/
|
|
24
33
|
export declare const WS_CLOSE_CODE: {
|
|
25
34
|
readonly NORMAL_CLOSURE: 1000;
|
|
26
35
|
readonly GOING_AWAY: 1001;
|
|
27
36
|
readonly PROTOCOL_ERROR: 1002;
|
|
28
37
|
readonly UNSUPPORTED_DATA: 1003;
|
|
38
|
+
/** Receive-only sentinel — never send this on the wire. */
|
|
29
39
|
readonly NO_STATUS_RECEIVED: 1005;
|
|
40
|
+
/** Receive-only sentinel — never send this on the wire. */
|
|
30
41
|
readonly ABNORMAL_CLOSURE: 1006;
|
|
31
42
|
readonly INVALID_PAYLOAD: 1007;
|
|
32
43
|
readonly POLICY_VIOLATION: 1008;
|
|
@@ -479,13 +490,37 @@ export declare function encodeFrame(opts: {
|
|
|
479
490
|
payload?: Uint8Array;
|
|
480
491
|
mask?: boolean;
|
|
481
492
|
}): Uint8Array;
|
|
493
|
+
/**
|
|
494
|
+
* Whether `code` may legally appear in a CLOSE frame on the wire per
|
|
495
|
+
* RFC 6455 §7.1.6 / §7.4.
|
|
496
|
+
*
|
|
497
|
+
* Valid: `1000`–`1014` from the registered range, minus the three codes
|
|
498
|
+
* §7.4.1 reserves for local reporting only (`1004` unassigned, `1005`
|
|
499
|
+
* "no status received", `1006` "abnormal closure"), plus the `3000`–`4999`
|
|
500
|
+
* library/application range. Everything else — `0`–`999`, `1015`+, and all of
|
|
501
|
+
* `2000`–`2999` — is a protocol violation.
|
|
502
|
+
*
|
|
503
|
+
* Used on both sides of the codec so the framework can never *emit* a code it
|
|
504
|
+
* would reject on receipt.
|
|
505
|
+
*
|
|
506
|
+
* @param code - Candidate close status code.
|
|
507
|
+
* @returns `true` when the code is legal in a wire CLOSE frame.
|
|
508
|
+
* @since 1.0.0-rc.8
|
|
509
|
+
*/
|
|
510
|
+
export declare function isValidWireCloseCode(code: number): boolean;
|
|
482
511
|
/**
|
|
483
512
|
* Encode a CLOSE frame payload (`uint16 code` + optional UTF-8 reason).
|
|
484
513
|
*
|
|
485
|
-
* @param code - RFC 6455 close status code, written big-endian.
|
|
514
|
+
* @param code - RFC 6455 close status code, written big-endian. Must be legal
|
|
515
|
+
* on the wire — see {@link isValidWireCloseCode}. To close with *no* status
|
|
516
|
+
* code, send an empty payload rather than passing `1005`.
|
|
486
517
|
* @param reason - Optional human-readable reason. Defaults to `""`.
|
|
487
518
|
* @returns The 2+N byte close payload.
|
|
488
|
-
* @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes
|
|
519
|
+
* @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes, or
|
|
520
|
+
* when `code` is not valid on the wire. Validating here as well as in
|
|
521
|
+
* {@link decodeClosePayload} keeps the codec symmetric: without it the
|
|
522
|
+
* framework could emit a frame its own decoder — and any conforming peer —
|
|
523
|
+
* must reject with `1002`.
|
|
489
524
|
*/
|
|
490
525
|
export declare function encodeClosePayload(code: number, reason?: string): Uint8Array;
|
|
491
526
|
/**
|
|
@@ -493,8 +528,16 @@ export declare function encodeClosePayload(code: number, reason?: string): Uint8
|
|
|
493
528
|
*
|
|
494
529
|
* @param payload - Unmasked close-frame payload bytes.
|
|
495
530
|
* @returns The close `code` and decoded UTF-8 `reason`.
|
|
496
|
-
* @throws WebSocketProtocolError when the payload is exactly 1 byte
|
|
497
|
-
* reason is not valid UTF-8
|
|
531
|
+
* @throws WebSocketProtocolError when the payload is exactly 1 byte, the
|
|
532
|
+
* reason is not valid UTF-8, or the status code is not valid on the wire —
|
|
533
|
+
* see {@link isValidWireCloseCode}. Without this check an endpoint would
|
|
534
|
+
* echo an attacker-supplied invalid code (e.g. 999) back in its own CLOSE
|
|
535
|
+
* frame instead of failing the connection with a 1002 protocol error.
|
|
536
|
+
*
|
|
537
|
+
* Note the asymmetry in the empty-payload case: a peer that closes with no
|
|
538
|
+
* status code yields the `1005` *sentinel*, which is deliberately not legal to
|
|
539
|
+
* send back. An endpoint echoing that close must reply with an empty payload,
|
|
540
|
+
* not with `1005`.
|
|
498
541
|
*/
|
|
499
542
|
export declare function decodeClosePayload(payload: Uint8Array): {
|
|
500
543
|
code: number;
|
package/dist/websocket.js
CHANGED
|
@@ -53,13 +53,24 @@ export const WS_OPCODE = {
|
|
|
53
53
|
PING: 0x9,
|
|
54
54
|
PONG: 0xa,
|
|
55
55
|
};
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Common RFC 6455 / IANA close codes.
|
|
58
|
+
*
|
|
59
|
+
* `NO_STATUS_RECEIVED` (1005) and `ABNORMAL_CLOSURE` (1006) are **receive-only
|
|
60
|
+
* sentinels**: RFC 6455 §7.4.1 reserves them for reporting a local condition to
|
|
61
|
+
* the application and forbids them in a CLOSE frame on the wire. Passing either
|
|
62
|
+
* to `close()` or {@link encodeClosePayload} throws
|
|
63
|
+
* {@link WebSocketProtocolError} — to close with no status code, send an empty
|
|
64
|
+
* payload instead.
|
|
65
|
+
*/
|
|
57
66
|
export const WS_CLOSE_CODE = {
|
|
58
67
|
NORMAL_CLOSURE: 1000,
|
|
59
68
|
GOING_AWAY: 1001,
|
|
60
69
|
PROTOCOL_ERROR: 1002,
|
|
61
70
|
UNSUPPORTED_DATA: 1003,
|
|
71
|
+
/** Receive-only sentinel — never send this on the wire. */
|
|
62
72
|
NO_STATUS_RECEIVED: 1005,
|
|
73
|
+
/** Receive-only sentinel — never send this on the wire. */
|
|
63
74
|
ABNORMAL_CLOSURE: 1006,
|
|
64
75
|
INVALID_PAYLOAD: 1007,
|
|
65
76
|
POLICY_VIOLATION: 1008,
|
|
@@ -667,15 +678,45 @@ export function encodeFrame(opts) {
|
|
|
667
678
|
}
|
|
668
679
|
return out;
|
|
669
680
|
}
|
|
681
|
+
/**
|
|
682
|
+
* Whether `code` may legally appear in a CLOSE frame on the wire per
|
|
683
|
+
* RFC 6455 §7.1.6 / §7.4.
|
|
684
|
+
*
|
|
685
|
+
* Valid: `1000`–`1014` from the registered range, minus the three codes
|
|
686
|
+
* §7.4.1 reserves for local reporting only (`1004` unassigned, `1005`
|
|
687
|
+
* "no status received", `1006` "abnormal closure"), plus the `3000`–`4999`
|
|
688
|
+
* library/application range. Everything else — `0`–`999`, `1015`+, and all of
|
|
689
|
+
* `2000`–`2999` — is a protocol violation.
|
|
690
|
+
*
|
|
691
|
+
* Used on both sides of the codec so the framework can never *emit* a code it
|
|
692
|
+
* would reject on receipt.
|
|
693
|
+
*
|
|
694
|
+
* @param code - Candidate close status code.
|
|
695
|
+
* @returns `true` when the code is legal in a wire CLOSE frame.
|
|
696
|
+
* @since 1.0.0-rc.8
|
|
697
|
+
*/
|
|
698
|
+
export function isValidWireCloseCode(code) {
|
|
699
|
+
return ((code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) ||
|
|
700
|
+
(code >= 3000 && code <= 4999));
|
|
701
|
+
}
|
|
670
702
|
/**
|
|
671
703
|
* Encode a CLOSE frame payload (`uint16 code` + optional UTF-8 reason).
|
|
672
704
|
*
|
|
673
|
-
* @param code - RFC 6455 close status code, written big-endian.
|
|
705
|
+
* @param code - RFC 6455 close status code, written big-endian. Must be legal
|
|
706
|
+
* on the wire — see {@link isValidWireCloseCode}. To close with *no* status
|
|
707
|
+
* code, send an empty payload rather than passing `1005`.
|
|
674
708
|
* @param reason - Optional human-readable reason. Defaults to `""`.
|
|
675
709
|
* @returns The 2+N byte close payload.
|
|
676
|
-
* @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes
|
|
710
|
+
* @throws WebSocketProtocolError when the encoded reason exceeds 123 bytes, or
|
|
711
|
+
* when `code` is not valid on the wire. Validating here as well as in
|
|
712
|
+
* {@link decodeClosePayload} keeps the codec symmetric: without it the
|
|
713
|
+
* framework could emit a frame its own decoder — and any conforming peer —
|
|
714
|
+
* must reject with `1002`.
|
|
677
715
|
*/
|
|
678
716
|
export function encodeClosePayload(code, reason = "") {
|
|
717
|
+
if (!isValidWireCloseCode(code)) {
|
|
718
|
+
throw new WebSocketProtocolError(`Invalid close status code ${code}`);
|
|
719
|
+
}
|
|
679
720
|
const reasonBytes = enc.encode(reason);
|
|
680
721
|
if (reasonBytes.length > WS_MAX_CONTROL_PAYLOAD - 2) {
|
|
681
722
|
throw new WebSocketProtocolError("Close reason exceeds 123 bytes");
|
|
@@ -691,8 +732,16 @@ export function encodeClosePayload(code, reason = "") {
|
|
|
691
732
|
*
|
|
692
733
|
* @param payload - Unmasked close-frame payload bytes.
|
|
693
734
|
* @returns The close `code` and decoded UTF-8 `reason`.
|
|
694
|
-
* @throws WebSocketProtocolError when the payload is exactly 1 byte
|
|
695
|
-
* reason is not valid UTF-8
|
|
735
|
+
* @throws WebSocketProtocolError when the payload is exactly 1 byte, the
|
|
736
|
+
* reason is not valid UTF-8, or the status code is not valid on the wire —
|
|
737
|
+
* see {@link isValidWireCloseCode}. Without this check an endpoint would
|
|
738
|
+
* echo an attacker-supplied invalid code (e.g. 999) back in its own CLOSE
|
|
739
|
+
* frame instead of failing the connection with a 1002 protocol error.
|
|
740
|
+
*
|
|
741
|
+
* Note the asymmetry in the empty-payload case: a peer that closes with no
|
|
742
|
+
* status code yields the `1005` *sentinel*, which is deliberately not legal to
|
|
743
|
+
* send back. An endpoint echoing that close must reply with an empty payload,
|
|
744
|
+
* not with `1005`.
|
|
696
745
|
*/
|
|
697
746
|
export function decodeClosePayload(payload) {
|
|
698
747
|
if (payload.length === 0)
|
|
@@ -700,6 +749,9 @@ export function decodeClosePayload(payload) {
|
|
|
700
749
|
if (payload.length === 1)
|
|
701
750
|
throw new WebSocketProtocolError("Close payload must be empty or ≥2 bytes");
|
|
702
751
|
const code = (payload[0] << 8) | payload[1];
|
|
752
|
+
if (!isValidWireCloseCode(code)) {
|
|
753
|
+
throw new WebSocketProtocolError(`Invalid close status code ${code}`);
|
|
754
|
+
}
|
|
703
755
|
const reason = new TextDecoder("utf-8", { fatal: true }).decode(payload.subarray(2));
|
|
704
756
|
return { code, reason };
|
|
705
757
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
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
|
|
3
|
+
"version": "1.0.0-rc.9",
|
|
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 \u2014 distributed via pnpm.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -258,7 +258,7 @@
|
|
|
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",
|
|
261
|
-
"typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit",
|
|
261
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit && tsc -p red-team-live/tsconfig.json --noEmit",
|
|
262
262
|
"typecheck:tests": "tsc -p tests/tsconfig.json --noEmit",
|
|
263
263
|
"format": "prettier --write .",
|
|
264
264
|
"gen:openapi": "node --import tsx scripts/dump-openapi.ts",
|
|
@@ -297,10 +297,12 @@
|
|
|
297
297
|
"verify:sbom": "node --import tsx scripts/verify-sbom.ts",
|
|
298
298
|
"verify:breaking-changes": "node --import tsx scripts/verify-breaking-changes.ts",
|
|
299
299
|
"verify:docs-links": "node --import tsx scripts/verify-docs-links.ts",
|
|
300
|
+
"verify:jsr-packaging": "npx --yes jsr publish --dry-run --allow-dirty",
|
|
300
301
|
"scan:staged-secrets": "node --import tsx scripts/scan-staged-secrets.ts",
|
|
301
302
|
"hooks:install": "node --import tsx scripts/install-git-hooks.ts",
|
|
302
303
|
"audit": "pnpm audit --prod",
|
|
303
|
-
"prepublishOnly": "pnpm build && pnpm gen:sbom"
|
|
304
|
+
"prepublishOnly": "pnpm build && pnpm gen:sbom",
|
|
305
|
+
"typecheck:red-team-live": "tsc -p red-team-live/tsconfig.json --noEmit"
|
|
304
306
|
},
|
|
305
307
|
"files": [
|
|
306
308
|
"dist",
|