@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/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, and a control-character→space
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, " "));
@@ -20,13 +20,24 @@ export declare const WS_OPCODE: {
20
20
  readonly PING: 9;
21
21
  readonly PONG: 10;
22
22
  };
23
- /** Common RFC 6455 / IANA close codes. */
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 or the
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
- /** Common RFC 6455 / IANA close codes. */
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 or the
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.7",
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.",
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",