@daloyjs/core 1.0.0-rc.6 → 1.0.0-rc.8

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.
@@ -470,8 +470,22 @@ export interface RateLimitOptions {
470
470
  * Trust x-forwarded-for / x-real-ip when deriving the default key.
471
471
  * Off by default because those headers are client-spoofable unless your
472
472
  * reverse proxy strips and rewrites them.
473
+ *
474
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
475
+ * one your immediate proxy appended — never the attacker-influenceable
476
+ * leftmost one, so rotating spoofed left entries cannot evade the limit.
477
+ * Behind more than one proxy hop, set {@link trustedHops} instead.
473
478
  */
474
479
  trustProxyHeaders?: boolean;
480
+ /**
481
+ * Declare exactly how many proxy hops sit between Daloy and the public
482
+ * internet. Implies proxy-header trust and derives the default key that
483
+ * many entries from the right of `X-Forwarded-For` via
484
+ * {@link "./conn-info.js".resolveForwardedClientIp}. Must be an integer in
485
+ * [1, 64]; validated at construction. Ignored when a custom `keyGenerator`
486
+ * is supplied.
487
+ */
488
+ trustedHops?: number;
475
489
  /** When true, set Retry-After header on 429. Default: true. */
476
490
  retryAfter?: boolean;
477
491
  /**
@@ -546,8 +560,24 @@ export interface LoginThrottleOptions {
546
560
  keyGenerator?: (ctx: RateLimitContext) => string;
547
561
  /** Shared store for the hard limit. Uses rateLimit()'s in-memory group bucket by default. */
548
562
  store?: RateLimitStore;
549
- /** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false. */
563
+ /** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false.
564
+ *
565
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
566
+ * one your immediate proxy appended — never the attacker-influenceable
567
+ * leftmost one. Behind more than one proxy hop, set {@link trustedHops}
568
+ * instead.
569
+ */
550
570
  trustProxyHeaders?: boolean;
571
+ /**
572
+ * Declare exactly how many proxy hops sit between Daloy and the public
573
+ * internet. Implies proxy-header trust and derives the default key that
574
+ * many entries from the right of `X-Forwarded-For` via
575
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so rotating spoofed
576
+ * left entries cannot evade the throttle. Must be an integer in [1, 64];
577
+ * validated at construction. Ignored when a custom `keyGenerator` is
578
+ * supplied.
579
+ */
580
+ trustedHops?: number;
551
581
  /** When true, set Retry-After header on 429. Default: true. */
552
582
  retryAfter?: boolean;
553
583
  /** Start slowing responses after this many attempts in the same window. Default: 2. */
@@ -7,6 +7,7 @@
7
7
  import { assertCookieAttributes, readRequestCookie, serializeCookie } from "./cookie.js";
8
8
  import { TooManyRequestsError, ForbiddenError } from "./errors.js";
9
9
  import { randomId, sanitizeHeaderName, timingSafeEqual } from "./security.js";
10
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
10
11
  /**
11
12
  * Generate or accept a stable `X-Request-ID` for every request. The id is
12
13
  * stamped on `ctx.state.requestId`, mirrored on outgoing response headers,
@@ -775,15 +776,8 @@ export function rateLimit(opts) {
775
776
  store = new MemoryStore();
776
777
  }
777
778
  const groupPrefix = opts.groupId ? `${opts.groupId}:` : "";
778
- const keyOf = opts.keyGenerator ??
779
- ((ctx) => {
780
- if (opts.trustProxyHeaders) {
781
- const xff = ctx.request.headers.get("x-forwarded-for");
782
- const first = xff ? xff.split(",")[0].trim() : "";
783
- return first || ctx.request.headers.get("x-real-ip") || "global";
784
- }
785
- return "global";
786
- });
779
+ const hops = resolveForwardedTrust("rateLimit()", opts);
780
+ const keyOf = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops);
787
781
  const enforce = async (ctx) => {
788
782
  const key = `${groupPrefix}${keyOf(ctx)}`;
789
783
  const { count, resetMs } = await store.hit(key, opts.windowMs);
@@ -811,15 +805,22 @@ function assertPositiveInteger(name, value) {
811
805
  throw new Error(`loginThrottle(): ${name} must be a positive integer.`);
812
806
  }
813
807
  }
814
- function defaultLoginThrottleKey(trustProxyHeaders) {
815
- return (ctx) => {
816
- if (trustProxyHeaders) {
817
- const forwardedFor = ctx.request.headers.get("x-forwarded-for");
818
- const firstForwarded = forwardedFor ? forwardedFor.split(",")[0].trim() : "";
819
- return firstForwarded || ctx.request.headers.get("x-real-ip") || "global";
820
- }
821
- return "global";
822
- };
808
+ /**
809
+ * Default rate-limit / login-throttle key: the spoof-resistant forwarded client
810
+ * IP, or the shared `"global"` bucket when proxy-header trust is off or the
811
+ * request carries no trustworthy forwarded identity.
812
+ *
813
+ * @param hops - Trusted proxy hop count from
814
+ * {@link "./conn-info.js".resolveForwardedTrust}, or `undefined` when
815
+ * forwarded-header trust is disabled.
816
+ * @returns A key generator suitable for {@link rateLimit} and
817
+ * {@link loginThrottle}.
818
+ * @internal
819
+ */
820
+ function defaultForwardedRateLimitKey(hops) {
821
+ if (hops === undefined)
822
+ return () => "global";
823
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops) ?? "global";
823
824
  }
824
825
  function wait(ms) {
825
826
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -852,7 +853,8 @@ export function loginThrottle(opts = {}) {
852
853
  assertNonNegativeInteger("delayMs", delayMs);
853
854
  assertNonNegativeInteger("maxDelayMs", maxDelayMs);
854
855
  const groupId = opts.groupId ?? "login";
855
- const keyGenerator = opts.keyGenerator ?? defaultLoginThrottleKey(opts.trustProxyHeaders);
856
+ const hops = resolveForwardedTrust("loginThrottle()", opts);
857
+ const keyGenerator = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops);
856
858
  const limiter = rateLimit({
857
859
  windowMs,
858
860
  max,
@@ -69,6 +69,17 @@ const ALLOWED_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
69
69
  // response-splitting via the `Location` header.
70
70
  // eslint-disable-next-line no-control-regex
71
71
  const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]/;
72
+ // Reject *percent-encoded* C0 controls and DEL (`%00`-`%1F`, `%7F`) that arrive
73
+ // as literal text in the target (e.g. a still-encoded query value passed
74
+ // straight through). `CONTROL_CHAR_RE` only sees decoded characters, so an
75
+ // encoded tab would otherwise be written verbatim into the `Location` header.
76
+ // Per WHATWG URL that stays same-origin, but legacy WebKit stacks strip
77
+ // decoded tabs/newlines and can re-interpret `/%09/host` as protocol-relative
78
+ // — the trick behind historical Safari open-redirect CVEs. The range is
79
+ // deliberately narrow: UTF-8 continuation bytes live in `%80`-`%BF`, so
80
+ // legitimate percent-encoded non-ASCII paths (e.g. `/s%C3%A9arch`) are
81
+ // unaffected.
82
+ const ENCODED_CONTROL_CHAR_RE = /%(?:0[0-9a-f]|1[0-9a-f]|7f)/i;
72
83
  // Any code point above U+00FF (outside Latin-1). Such characters cannot be
73
84
  // written to a `Location` header — which is serialized as an ISO-8859-1
74
85
  // ByteString, so `Headers.set` throws a raw `TypeError` — and they cover the
@@ -91,6 +102,14 @@ function classify(target, allowedPaths, allowedOrigins) {
91
102
  if (CONTROL_CHAR_RE.test(target)) {
92
103
  return { ok: false, reason: "invalid-control-characters" };
93
104
  }
105
+ // Encoded control characters (`%09`, `%00`, …) arriving as literal text:
106
+ // spec-compliant browsers keep `/%09/host` same-origin, but legacy WebKit
107
+ // strips decoded tabs/newlines and can fold it into an origin-escaping
108
+ // protocol-relative URL. Refuse rather than rely on every user agent
109
+ // parsing it the WHATWG way.
110
+ if (ENCODED_CONTROL_CHAR_RE.test(target)) {
111
+ return { ok: false, reason: "invalid-control-characters" };
112
+ }
94
113
  // Protocol-relative (`//evil.com`) is the classic open-redirect bypass.
95
114
  if (target.startsWith("//"))
96
115
  return { ok: false, reason: "protocol-relative" };
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:1fe1844d-d5f6-5dd3-b35a-8f79f17a35f5",
4
+ "serialNumber": "urn:uuid:91539bfb-7f26-5f45-8628-f7951991c7ca",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-07-26T17:41:07.733Z",
7
+ "timestamp": "2026-07-30T16:25:43.763Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.0.0-rc.6"
12
+ "version": "1.0.0-rc.8"
13
13
  }
14
14
  ],
15
15
  "authors": [
@@ -19,11 +19,11 @@
19
19
  ],
20
20
  "component": {
21
21
  "type": "library",
22
- "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.6",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.8",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-rc.6",
24
+ "version": "1.0.0-rc.8",
25
25
  "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.",
26
- "purl": "pkg:npm/@daloyjs/core@1.0.0-rc.6",
26
+ "purl": "pkg:npm/@daloyjs/core@1.0.0-rc.8",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-1.0.0-rc.6",
49
+ "tagId": "swidtag--daloyjs-core-1.0.0-rc.8",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-rc.6",
51
+ "version": "1.0.0-rc.8",
52
52
  "tagVersion": 0,
53
53
  "patch": false
54
54
  }
@@ -57,7 +57,7 @@
57
57
  "components": [],
58
58
  "dependencies": [
59
59
  {
60
- "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.6",
60
+ "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.8",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@daloyjs/core-1.0.0-rc.6",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.6-1fe1844d-d5f6-5dd3-b35a-8f79f17a35f5",
5
+ "name": "@daloyjs/core-1.0.0-rc.8",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.8-91539bfb-7f26-5f45-8628-f7951991c7ca",
7
7
  "creationInfo": {
8
- "created": "2026-07-26T17:41:07.733Z",
8
+ "created": "2026-07-30T16:25:43.763Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package--daloyjs-core",
18
18
  "name": "@daloyjs/core",
19
- "versionInfo": "1.0.0-rc.6",
19
+ "versionInfo": "1.0.0-rc.8",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.6"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.8"
31
31
  }
32
32
  ]
33
33
  }
package/dist/types.d.ts CHANGED
@@ -305,6 +305,37 @@ export interface PreBodyContext<P extends string = string> {
305
305
  headers: Headers;
306
306
  };
307
307
  }
308
+ /**
309
+ * Context handed to the caller-supplied resolvers of the network-identity
310
+ * access-control gates — `geoBlock`, `ipRestriction`, `botGuard`, `autoBan` and
311
+ * `ipReputation`.
312
+ *
313
+ * Those gates enforce from {@link Hooks.preBody} so a `responseCache()` hit
314
+ * cannot preempt them (see SECURITY.md, "Hook phase decides what a
315
+ * short-circuiting middleware can preempt"). Their callbacks therefore run
316
+ * before body I/O and before *any* `beforeHandle` middleware, which means:
317
+ *
318
+ * - `body` is always `undefined` — nothing has been parsed yet.
319
+ * - `state` holds only what `onRequest` / an earlier `preBody` layer put there.
320
+ * In particular it does **not** hold anything `session()` or another
321
+ * `beforeHandle` layer resolves.
322
+ *
323
+ * The alias exists so that is a compile error instead of a runtime surprise.
324
+ * Typing these callbacks on the full {@link BaseContext} — whose `body` widens
325
+ * to `any` — let `(ctx) => ctx.body.email` type-check and then silently evaluate
326
+ * to `undefined` at run time. The consequence differed per gate and two of the
327
+ * five failed *silently*: `ipReputation` fails open on an unresolved IP, and
328
+ * `autoBan` stopped recording strikes altogether because it never got an
329
+ * identity to attribute them to.
330
+ *
331
+ * Resolve identity from `request` (headers, URL), `params`, `query`, or state a
332
+ * `preBody` layer set. If a value genuinely requires the parsed body, it cannot
333
+ * be a `preBody` gate input — see {@link "./auto-ban.js".AutoBanOptions.keyGenerator},
334
+ * which falls back to a later phase for exactly that case.
335
+ *
336
+ * @since 1.0.0-rc.8
337
+ */
338
+ export type IdentityGateContext = PreBodyContext<any>;
308
339
  /**
309
340
  * Lifecycle hooks fired around request handling. Hooks compose pipeline-style
310
341
  * — the global hooks (`AppOptions.hooks`) run first, then group hooks added
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.6",
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.8",
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",