@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.
- package/README.md +3 -2
- package/dist/adapters/node.js +9 -2
- package/dist/auto-ban.d.ts +65 -5
- package/dist/auto-ban.js +114 -29
- package/dist/bot-guard.d.ts +16 -2
- package/dist/bot-guard.js +18 -13
- package/dist/concurrency-limit.d.ts +14 -0
- package/dist/concurrency-limit.js +18 -9
- package/dist/conn-info.d.ts +65 -0
- package/dist/conn-info.js +99 -4
- package/dist/geo-block.d.ts +18 -3
- package/dist/geo-block.js +18 -12
- package/dist/idempotency.d.ts +56 -2
- package/dist/idempotency.js +135 -6
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/ip-reputation.d.ts +16 -2
- package/dist/ip-reputation.js +16 -11
- package/dist/ip-restriction.d.ts +17 -3
- package/dist/ip-restriction.js +12 -9
- package/dist/mcp.d.ts +297 -34
- package/dist/mcp.js +554 -49
- package/dist/middleware.d.ts +31 -1
- package/dist/middleware.js +21 -19
- 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/middleware.d.ts
CHANGED
|
@@ -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. */
|
package/dist/middleware.js
CHANGED
|
@@ -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
|
|
779
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
|
856
|
+
const hops = resolveForwardedTrust("loginThrottle()", opts);
|
|
857
|
+
const keyGenerator = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops);
|
|
856
858
|
const limiter = rateLimit({
|
|
857
859
|
windowMs,
|
|
858
860
|
max,
|
package/dist/safe-redirect.js
CHANGED
|
@@ -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" };
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:91539bfb-7f26-5f45-8628-f7951991c7ca",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-07-
|
|
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.
|
|
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.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.8",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.0.0-rc.
|
|
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.
|
|
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.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.0.0-rc.8",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.0.0-rc.
|
|
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.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.8",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -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
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.
|
|
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-
|
|
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.
|
|
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.
|
|
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,
|
|
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.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",
|