@daloyjs/core 1.0.0-rc.7 → 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/dist/adapters/node.js +9 -2
- 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 +56 -2
- package/dist/idempotency.js +135 -6
- 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/adapters/node.js
CHANGED
|
@@ -874,9 +874,16 @@ class NodeWebSocketConnection {
|
|
|
874
874
|
},
|
|
875
875
|
onClose: (code, reason) => {
|
|
876
876
|
if (this.readyState === WS_READY_STATE.OPEN) {
|
|
877
|
-
// Echo close per RFC 6455 §5.5.1.
|
|
877
|
+
// Echo close per RFC 6455 §5.5.1 ("SHOULD use the same status code").
|
|
878
|
+
// A peer that closed with an *empty* payload surfaces as the 1005
|
|
879
|
+
// sentinel, which §7.4.1 forbids on the wire — echoing it produced a
|
|
880
|
+
// CLOSE(1005) that a conforming peer (and this framework's own
|
|
881
|
+
// decoder) must reject with 1002. An empty close is answered with an
|
|
882
|
+
// empty close.
|
|
878
883
|
this.readyState = WS_READY_STATE.CLOSING;
|
|
879
|
-
this._writeFrame(WS_OPCODE.CLOSE,
|
|
884
|
+
this._writeFrame(WS_OPCODE.CLOSE, code === WS_CLOSE_CODE.NO_STATUS_RECEIVED
|
|
885
|
+
? new Uint8Array(0)
|
|
886
|
+
: encodeClosePayload(code, reason));
|
|
880
887
|
}
|
|
881
888
|
this.readyState = WS_READY_STATE.CLOSED;
|
|
882
889
|
this._fireClose(code, reason);
|
package/dist/auto-ban.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* The middleware is dependency-free and runtime-portable. It observes outgoing
|
|
12
12
|
* responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
|
|
13
13
|
* status produced by *any* later middleware or handler, not just its own) and
|
|
14
|
-
* enforces the ban in {@link "./types.js".Hooks.
|
|
14
|
+
* enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
|
|
15
15
|
* lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
|
|
16
16
|
* `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
|
|
17
17
|
* implementation for multi-instance deployments.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @module
|
|
20
20
|
* @since 0.37.0
|
|
21
21
|
*/
|
|
22
|
-
import type {
|
|
22
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
23
23
|
/**
|
|
24
24
|
* One client's auto-ban bookkeeping. A record tracks the current strike count
|
|
25
25
|
* inside the rolling strike window, when that window expires, the timestamp the
|
|
@@ -128,8 +128,22 @@ export interface AutoBanOptions {
|
|
|
128
128
|
* Derive the client identity from `ctx`, or `undefined` to skip the request
|
|
129
129
|
* (fail-open — never banned, never counted). Defaults to the proxy-header
|
|
130
130
|
* resolver when {@link trustProxyHeaders} is set.
|
|
131
|
+
*
|
|
132
|
+
* Called first in `preBody`, where the gate is immune to mount order (see
|
|
133
|
+
* {@link IdentityGateContext}). If it returns `undefined` there, it is called
|
|
134
|
+
* again in `beforeHandle` — by then `session()` and other `beforeHandle` layers
|
|
135
|
+
* have populated `ctx.state`, so a generator keyed on a resolved session works
|
|
136
|
+
* rather than silently disabling the ban. Requests enforced by that second
|
|
137
|
+
* attempt are order-sensitive again, because `beforeHandle` is the phase a
|
|
138
|
+
* `responseCache()` hit short-circuits; key off headers, params or query where
|
|
139
|
+
* you can and the `preBody` pass handles it. Returning `undefined` from *both*
|
|
140
|
+
* still skips the request.
|
|
141
|
+
*
|
|
142
|
+
* `ctx.body` is not available in either phase — `preBody` runs before parsing,
|
|
143
|
+
* and the type reflects that. Derive the key from the request line, headers, or
|
|
144
|
+
* state instead.
|
|
131
145
|
*/
|
|
132
|
-
keyGenerator?: (ctx:
|
|
146
|
+
keyGenerator?: (ctx: IdentityGateContext) => string | undefined;
|
|
133
147
|
/**
|
|
134
148
|
* Read `X-Forwarded-For` / `X-Real-IP` in the default key generator. Off by
|
|
135
149
|
* default because those headers are client-spoofable unless every request
|
|
@@ -152,6 +166,32 @@ export interface AutoBanOptions {
|
|
|
152
166
|
* [1, 64]; validated at construction.
|
|
153
167
|
*/
|
|
154
168
|
trustedHops?: number;
|
|
169
|
+
/**
|
|
170
|
+
* What to do when the default key generator cannot resolve a forwarded
|
|
171
|
+
* identity — the request carried no `X-Forwarded-For`, or a chain shorter than
|
|
172
|
+
* {@link trustedHops} declares.
|
|
173
|
+
*
|
|
174
|
+
* - `"peer"` (default) — fall back to the immediate TCP peer address, in its
|
|
175
|
+
* own `peer:` keyspace. The peer cannot be spoofed, and a request that
|
|
176
|
+
* skipped the declared proxy chain came *from* that peer, so strikes are
|
|
177
|
+
* attributed to the real origin of the traffic.
|
|
178
|
+
* - `"skip"` — never count and never ban such a request.
|
|
179
|
+
*
|
|
180
|
+
* `"peer"` is the default because `"skip"` is a silent bypass: an attacker who
|
|
181
|
+
* can reach the origin directly gets unlimited strikes simply by omitting a
|
|
182
|
+
* header. Choose `"skip"` only when unresolved requests are known-benign and
|
|
183
|
+
* arrive from a shared address — for instance a load balancer that does not
|
|
184
|
+
* always set `X-Forwarded-For`, where every such request would otherwise share
|
|
185
|
+
* the balancer's single `peer:` bucket and a few `401`s could ban the lot.
|
|
186
|
+
* Prefer fixing the proxy configuration over choosing `"skip"`.
|
|
187
|
+
*
|
|
188
|
+
* Ignored when {@link keyGenerator} is supplied — a custom generator owns its
|
|
189
|
+
* own unresolved-identity posture, and returning `undefined` from it still
|
|
190
|
+
* means skip.
|
|
191
|
+
*
|
|
192
|
+
* @since 1.0.0-rc.8
|
|
193
|
+
*/
|
|
194
|
+
onUnresolvedIdentity?: "peer" | "skip";
|
|
155
195
|
/** Pluggable ban store. Default: a shared in-memory store keyed by `groupId`. */
|
|
156
196
|
store?: AutoBanStore;
|
|
157
197
|
/**
|
|
@@ -201,8 +241,12 @@ export declare class MemoryAutoBanStore implements AutoBanStore {
|
|
|
201
241
|
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
|
|
202
242
|
* set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
|
|
203
243
|
* a misconfiguration can never collapse every caller into one shared bucket and
|
|
204
|
-
* ban the whole world at once.
|
|
205
|
-
*
|
|
244
|
+
* ban the whole world at once. When the default generator cannot resolve a
|
|
245
|
+
* forwarded identity — no `X-Forwarded-For`, or a chain shorter than
|
|
246
|
+
* {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
|
|
247
|
+
* unspoofable TCP peer instead of being discarded; see
|
|
248
|
+
* {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
|
|
249
|
+
* returns `undefined` still skips the request.
|
|
206
250
|
*
|
|
207
251
|
* @example
|
|
208
252
|
* ```ts
|
package/dist/auto-ban.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* The middleware is dependency-free and runtime-portable. It observes outgoing
|
|
12
12
|
* responses via the {@link "./types.js".Hooks.onSend} hook (so it counts the
|
|
13
13
|
* status produced by *any* later middleware or handler, not just its own) and
|
|
14
|
-
* enforces the ban in {@link "./types.js".Hooks.
|
|
14
|
+
* enforces the ban in {@link "./types.js".Hooks.preBody}. The ban state
|
|
15
15
|
* lives in a pluggable {@link AutoBanStore} — the in-memory default mirrors the
|
|
16
16
|
* `rateLimit()` store and is single-process only; supply a shared (e.g. Redis)
|
|
17
17
|
* implementation for multi-instance deployments.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* @since 0.37.0
|
|
21
21
|
*/
|
|
22
22
|
import { ForbiddenError, TooManyRequestsError } from "./errors.js";
|
|
23
|
-
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
23
|
+
import { readRemoteAddress, resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
24
24
|
const DEFAULT_WINDOW_MS = 10 * 60_000;
|
|
25
25
|
const DEFAULT_MAX_STRIKES = 5;
|
|
26
26
|
const DEFAULT_BAN_MS = 15 * 60_000;
|
|
@@ -90,9 +90,32 @@ function assertPositiveInteger(name, value) {
|
|
|
90
90
|
* trusted proxy hops from the right of `X-Forwarded-For` (falling back to
|
|
91
91
|
* `X-Real-IP`). Reading the right side keeps the key spoof-resistant — see
|
|
92
92
|
* {@link resolveForwardedClientIp}.
|
|
93
|
+
*
|
|
94
|
+
* When the forwarded chain cannot satisfy the declaration,
|
|
95
|
+
* {@link resolveForwardedClientIp} returns `undefined` — correct for *identity*,
|
|
96
|
+
* because such a request never traversed the declared topology. For *abuse
|
|
97
|
+
* accounting* that answer used to mean "skip", which handed an attacker unlimited
|
|
98
|
+
* strikes for free: reach the origin directly, past the CDN that appends the
|
|
99
|
+
* header, and every failed credential attempt went uncounted.
|
|
100
|
+
*
|
|
101
|
+
* So the fallback is the immediate TCP peer, prefixed to keep it in its own
|
|
102
|
+
* keyspace. The peer address cannot be spoofed — it is the socket actually
|
|
103
|
+
* talking to the adapter — and in exactly the direct-to-origin case that
|
|
104
|
+
* produced the bypass, the peer *is* the attacker, so accounting becomes precise
|
|
105
|
+
* rather than absent. Set `onUnresolvedIdentity: "skip"` to restore the previous
|
|
106
|
+
* behaviour; see {@link AutoBanOptions.onUnresolvedIdentity} for when that is
|
|
107
|
+
* the right call.
|
|
93
108
|
*/
|
|
94
|
-
function forwardedKey(hops) {
|
|
95
|
-
return (ctx) =>
|
|
109
|
+
function forwardedKey(hops, peerFallback) {
|
|
110
|
+
return (ctx) => {
|
|
111
|
+
const forwarded = resolveForwardedClientIp(ctx.request, hops);
|
|
112
|
+
if (forwarded !== undefined)
|
|
113
|
+
return forwarded;
|
|
114
|
+
if (!peerFallback)
|
|
115
|
+
return undefined;
|
|
116
|
+
const peer = readRemoteAddress(ctx);
|
|
117
|
+
return peer === undefined ? undefined : `peer:${peer}`;
|
|
118
|
+
};
|
|
96
119
|
}
|
|
97
120
|
/**
|
|
98
121
|
* Adaptive, escalating, decaying auto-ban middleware (fail2ban-style). Counts
|
|
@@ -103,8 +126,12 @@ function forwardedKey(hops) {
|
|
|
103
126
|
* Identity attribution is mandatory: pass {@link AutoBanOptions.keyGenerator} or
|
|
104
127
|
* set {@link AutoBanOptions.trustProxyHeaders}, otherwise construction throws so
|
|
105
128
|
* a misconfiguration can never collapse every caller into one shared bucket and
|
|
106
|
-
* ban the whole world at once.
|
|
107
|
-
*
|
|
129
|
+
* ban the whole world at once. When the default generator cannot resolve a
|
|
130
|
+
* forwarded identity — no `X-Forwarded-For`, or a chain shorter than
|
|
131
|
+
* {@link AutoBanOptions.trustedHops} declares — strikes are attributed to the
|
|
132
|
+
* unspoofable TCP peer instead of being discarded; see
|
|
133
|
+
* {@link AutoBanOptions.onUnresolvedIdentity}. A custom `keyGenerator` that
|
|
134
|
+
* returns `undefined` still skips the request.
|
|
108
135
|
*
|
|
109
136
|
* @example
|
|
110
137
|
* ```ts
|
|
@@ -150,12 +177,16 @@ export function autoBan(opts = {}) {
|
|
|
150
177
|
}
|
|
151
178
|
const watch = new Set(watchStatuses);
|
|
152
179
|
const hops = resolveForwardedTrust("autoBan()", opts);
|
|
180
|
+
const onUnresolved = opts.onUnresolvedIdentity ?? "peer";
|
|
181
|
+
if (onUnresolved !== "peer" && onUnresolved !== "skip") {
|
|
182
|
+
throw new Error(`autoBan(): onUnresolvedIdentity must be "peer" or "skip"; got ${String(onUnresolved)}.`);
|
|
183
|
+
}
|
|
153
184
|
let keyOf;
|
|
154
185
|
if (opts.keyGenerator) {
|
|
155
186
|
keyOf = opts.keyGenerator;
|
|
156
187
|
}
|
|
157
188
|
else if (hops !== undefined) {
|
|
158
|
-
keyOf = forwardedKey(hops);
|
|
189
|
+
keyOf = forwardedKey(hops, onUnresolved === "peer");
|
|
159
190
|
}
|
|
160
191
|
else {
|
|
161
192
|
throw new Error("autoBan(): provide keyGenerator, trustedHops, or set trustProxyHeaders so clients can be identified; " +
|
|
@@ -175,23 +206,40 @@ export function autoBan(opts = {}) {
|
|
|
175
206
|
store = shared;
|
|
176
207
|
}
|
|
177
208
|
const prefix = `${groupId}:`;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Resolve the identity, stash the key for `onSend`, and reject an active ban.
|
|
211
|
+
*
|
|
212
|
+
* Shared by the `preBody` gate and the `beforeHandle` fallback below so both
|
|
213
|
+
* phases enforce identically. Returns `true` once an identity was found, so
|
|
214
|
+
* the fallback knows whether `preBody` already handled the request.
|
|
215
|
+
*/
|
|
216
|
+
const enforce = async (ctx) => {
|
|
217
|
+
const identity = keyOf(ctx);
|
|
218
|
+
if (identity === undefined)
|
|
219
|
+
return false;
|
|
220
|
+
const key = `${prefix}${identity}`;
|
|
221
|
+
const state = ctx.state;
|
|
222
|
+
state[STATE_KEY] = key;
|
|
223
|
+
const record = await store.get(key);
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
if (record && record.bannedUntilMs > now) {
|
|
226
|
+
state[STATE_REJECTED] = true;
|
|
227
|
+
if (banStatus === 403)
|
|
228
|
+
throw new ForbiddenError(message);
|
|
229
|
+
const retry = Math.ceil((record.bannedUntilMs - now) / 1000);
|
|
230
|
+
throw new TooManyRequestsError(retryAfter ? retry : undefined);
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
234
|
+
const hooks = {
|
|
235
|
+
// `preBody`, not `beforeHandle`: the ban check must not be preemptable by an
|
|
236
|
+
// earlier `beforeHandle` middleware that short-circuits — a
|
|
237
|
+
// `responseCache()` HIT mounted above it would serve a banned client the
|
|
238
|
+
// cached body, so the ban would only ever apply to uncached routes.
|
|
239
|
+
// `preBody` always runs before any `beforeHandle`. Strike accounting stays in
|
|
240
|
+
// `onSend`, which observes the final status either way.
|
|
241
|
+
async preBody(ctx) {
|
|
242
|
+
await enforce(ctx);
|
|
195
243
|
return undefined;
|
|
196
244
|
},
|
|
197
245
|
async onSend(res, ctx) {
|
|
@@ -227,4 +275,31 @@ export function autoBan(opts = {}) {
|
|
|
227
275
|
return undefined;
|
|
228
276
|
},
|
|
229
277
|
};
|
|
278
|
+
// A custom `keyGenerator` may legitimately be unable to answer in `preBody` —
|
|
279
|
+
// typically because it reads state a `beforeHandle` layer resolves, such as
|
|
280
|
+
// `session()`. Without a second attempt that request gets no identity, so
|
|
281
|
+
// `onSend` finds no key and records no strike: the ban silently never arms.
|
|
282
|
+
// That is a worse failure than the ordering hazard the phase move closed, so
|
|
283
|
+
// retry in `beforeHandle` when, and only when, `preBody` came up empty.
|
|
284
|
+
//
|
|
285
|
+
// The default forwarded resolver never needs this — `onUnresolvedIdentity`
|
|
286
|
+
// already falls back to the TCP peer — so the hook is registered only for a
|
|
287
|
+
// custom generator and the common path pays nothing.
|
|
288
|
+
//
|
|
289
|
+
// Residual, deliberately accepted: a request enforced by this fallback IS
|
|
290
|
+
// order-sensitive again, because `beforeHandle` is the phase a
|
|
291
|
+
// `responseCache()` hit short-circuits. It applies solely to requests whose
|
|
292
|
+
// identity could not be resolved earlier, and enforcing late beats not
|
|
293
|
+
// enforcing at all. Resolve identity from headers/params/query where you can
|
|
294
|
+
// and `preBody` handles it, immune to mount order.
|
|
295
|
+
if (opts.keyGenerator) {
|
|
296
|
+
hooks.beforeHandle = async (ctx) => {
|
|
297
|
+
const state = ctx.state;
|
|
298
|
+
if (state[STATE_KEY] !== undefined)
|
|
299
|
+
return undefined; // preBody had it
|
|
300
|
+
await enforce(ctx);
|
|
301
|
+
return undefined;
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return hooks;
|
|
230
305
|
}
|
package/dist/bot-guard.d.ts
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* @module
|
|
36
36
|
* @since 0.37.0
|
|
37
37
|
*/
|
|
38
|
-
import type {
|
|
38
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
39
39
|
/**
|
|
40
40
|
* Pluggable DNS resolver used to verify declared crawlers. The default
|
|
41
41
|
* implementation lazily imports `node:dns/promises`; provide your own on
|
|
@@ -150,7 +150,7 @@ export interface BotGuardOptions {
|
|
|
150
150
|
/**
|
|
151
151
|
* Custom client-IP resolver. Overrides {@link BotGuardOptions.trustProxyHeaders}.
|
|
152
152
|
*/
|
|
153
|
-
resolveIp?: (ctx:
|
|
153
|
+
resolveIp?: (ctx: IdentityGateContext) => string | undefined;
|
|
154
154
|
/**
|
|
155
155
|
* Custom DNS resolver for crawler verification. Defaults to a lazy
|
|
156
156
|
* `node:dns/promises`-backed resolver.
|
package/dist/bot-guard.js
CHANGED
|
@@ -260,7 +260,12 @@ export function botGuard(opts = {}) {
|
|
|
260
260
|
throw new ForbiddenError(message);
|
|
261
261
|
};
|
|
262
262
|
return {
|
|
263
|
-
|
|
263
|
+
// `preBody`, not `beforeHandle`: a bot gate that short-circuits from
|
|
264
|
+
// `beforeHandle` loses to any earlier `beforeHandle` middleware that returns
|
|
265
|
+
// a Response first — a `responseCache()` HIT mounted above it would hand a
|
|
266
|
+
// blocked scraper the cached body. `preBody` always precedes `beforeHandle`,
|
|
267
|
+
// so the gate holds regardless of mount order.
|
|
268
|
+
async preBody(ctx) {
|
|
264
269
|
const ua = ctx.request.headers.get("user-agent") ?? "";
|
|
265
270
|
// Allowlist wins over every other rule.
|
|
266
271
|
if (allowed.length > 0 && matchesUserAgent(ua, allowed))
|
package/dist/geo-block.d.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* @module
|
|
25
25
|
* @since 0.37.0
|
|
26
26
|
*/
|
|
27
|
-
import type {
|
|
27
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
28
28
|
/**
|
|
29
29
|
* Why a request was (or would have been) blocked by {@link geoBlock}.
|
|
30
30
|
*
|
|
@@ -57,7 +57,7 @@ export interface GeoBlockDecision {
|
|
|
57
57
|
*
|
|
58
58
|
* @since 0.37.0
|
|
59
59
|
*/
|
|
60
|
-
export type CountryFromContext = (ctx:
|
|
60
|
+
export type CountryFromContext = (ctx: IdentityGateContext) => string | undefined | null | Promise<string | undefined | null>;
|
|
61
61
|
/**
|
|
62
62
|
* Operator-supplied IP → country lookup (e.g. a MaxMind reader). Return
|
|
63
63
|
* `undefined`/`null`/`""` when the IP cannot be mapped to a country.
|
|
@@ -110,7 +110,7 @@ export interface GeoBlockOptions {
|
|
|
110
110
|
* default Daloy fails closed because Web-standard `Request` objects do not
|
|
111
111
|
* expose the peer address. Ignored when `resolveCountry` is used.
|
|
112
112
|
*/
|
|
113
|
-
resolveIp?: (ctx:
|
|
113
|
+
resolveIp?: (ctx: IdentityGateContext) => string | undefined;
|
|
114
114
|
/**
|
|
115
115
|
* Read `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Defaults
|
|
116
116
|
* to `false` because those headers are client-spoofable unless every
|
package/dist/geo-block.js
CHANGED
|
@@ -107,7 +107,13 @@ export function geoBlock(opts) {
|
|
|
107
107
|
const hops = resolveForwardedTrust("geoBlock()", opts);
|
|
108
108
|
const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
|
|
109
109
|
return {
|
|
110
|
-
|
|
110
|
+
// Runs in `preBody`, not `beforeHandle`. A `beforeHandle` hook that returns
|
|
111
|
+
// a Response ends the chain, so a country gate in that phase is preempted by
|
|
112
|
+
// any earlier `beforeHandle` middleware that short-circuits — a
|
|
113
|
+
// `responseCache()` HIT above it would serve a denied country the cached
|
|
114
|
+
// body. `preBody` always precedes `beforeHandle`, which makes the gate
|
|
115
|
+
// immune to mount order. See {@link geoBlock}'s security note.
|
|
116
|
+
async preBody(ctx) {
|
|
111
117
|
let ip;
|
|
112
118
|
let rawCountry;
|
|
113
119
|
if (resolveCountry) {
|
package/dist/idempotency.d.ts
CHANGED
|
@@ -164,17 +164,71 @@ export interface IdempotencyOptions {
|
|
|
164
164
|
* unauthenticated idempotent writes). Returning a stable per-user id is
|
|
165
165
|
* preferable to the raw credential when tokens rotate between retries.
|
|
166
166
|
*
|
|
167
|
+
* **Supply this whenever `Authorization` is not per-user.** The default assumes
|
|
168
|
+
* that header names one caller. If it is shared — a per-tenant API key, a
|
|
169
|
+
* service token, a gateway credential — with end users distinguished some other
|
|
170
|
+
* way (a session cookie, a subject claim your app reads), then the default
|
|
171
|
+
* partitions per *tenant* and every user inside one tenant shares a namespace.
|
|
172
|
+
* A caller who knows another's `Idempotency-Key` can then replay their stored
|
|
173
|
+
* response. This is not detectable from the header alone, which is why it is
|
|
174
|
+
* your call rather than a framework guard: a resolvable-but-coarse scope looks
|
|
175
|
+
* identical to a correctly per-user one. The cookie-only case *is* guarded —
|
|
176
|
+
* see {@link allowUnscopedCallers}.
|
|
177
|
+
*
|
|
178
|
+
* Independently of scoping, a replay never re-issues `Set-Cookie`, so a coarse
|
|
179
|
+
* namespace cannot escalate from disclosing a response body into handing over a
|
|
180
|
+
* live session.
|
|
181
|
+
*
|
|
167
182
|
* @since 0.40.0
|
|
168
183
|
*/
|
|
169
184
|
scope?: (ctx: BaseContext<any, any>) => string | undefined | Promise<string | undefined>;
|
|
185
|
+
/**
|
|
186
|
+
* Accept callers the default {@link scope} resolver cannot identify, letting
|
|
187
|
+
* them share one idempotency namespace. Default `false`.
|
|
188
|
+
*
|
|
189
|
+
* The guard this disables exists because a cookie-authenticated request
|
|
190
|
+
* carries no `Authorization` header, so the default resolver returns
|
|
191
|
+
* `undefined` and the namespace collapses. The retry fingerprint (method +
|
|
192
|
+
* path + body) is then all that separates two users — and two users
|
|
193
|
+
* submitting the same payload fingerprint identically, so one replays the
|
|
194
|
+
* other's stored response (CWE-524). Rather than share the namespace
|
|
195
|
+
* silently, a cookie-bearing request with no resolvable scope throws with an
|
|
196
|
+
* actionable message.
|
|
197
|
+
*
|
|
198
|
+
* Set this to `true` only when unscoped callers are genuinely interchangeable
|
|
199
|
+
* — a public, unauthenticated idempotent write where no response body is
|
|
200
|
+
* caller-specific. Supplying {@link scope} is almost always the right answer
|
|
201
|
+
* instead. A custom `scope` bypasses the guard entirely, including when it
|
|
202
|
+
* returns `undefined`, because an explicit resolver owns its own posture.
|
|
203
|
+
*
|
|
204
|
+
* @since 1.0.0-rc.8
|
|
205
|
+
*/
|
|
206
|
+
allowUnscopedCallers?: boolean;
|
|
170
207
|
}
|
|
171
208
|
/**
|
|
172
209
|
* In-memory {@link IdempotencyStore}. Suitable for tests and single-process
|
|
173
|
-
* deployments. Expired records are dropped on access
|
|
174
|
-
*
|
|
210
|
+
* deployments. Expired records are dropped on access.
|
|
211
|
+
*
|
|
212
|
+
* Growth is bounded by {@link maxEntries}: an expiry sweep runs first, and if the
|
|
213
|
+
* store is still at the cap the oldest surviving record is evicted. The sweep
|
|
214
|
+
* alone is not a bound — it only drops records that have *expired*, so a stream
|
|
215
|
+
* of unique keys inside the TTL grew the map linearly no matter how often it ran,
|
|
216
|
+
* with each entry pinning a stored response body.
|
|
217
|
+
*
|
|
218
|
+
* Evicting a live record can only cost exactly-once semantics for a retry that
|
|
219
|
+
* arrives after the eviction — it re-executes rather than replaying. That is the
|
|
220
|
+
* right trade against unbounded memory, but it is a reason to supply a shared
|
|
221
|
+
* (e.g. Redis) store for any deployment where the key volume approaches the cap.
|
|
175
222
|
*/
|
|
176
223
|
export declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
177
224
|
private readonly map;
|
|
225
|
+
private readonly maxEntries;
|
|
226
|
+
/**
|
|
227
|
+
* @param maxEntries - Maximum live records retained. Must be a positive
|
|
228
|
+
* integer. Default {@link DEFAULT_MAX_IDEMPOTENCY_ENTRIES} (10 000).
|
|
229
|
+
* @throws Error when `maxEntries` is not a positive integer.
|
|
230
|
+
*/
|
|
231
|
+
constructor(maxEntries?: number);
|
|
178
232
|
/**
|
|
179
233
|
* @inheritDoc
|
|
180
234
|
* `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
|
package/dist/idempotency.js
CHANGED
|
@@ -55,13 +55,44 @@ export function _resetSharedIdempotencyStoresForTests() {
|
|
|
55
55
|
SHARED_IDEMPOTENCY_STORES.clear();
|
|
56
56
|
}
|
|
57
57
|
// ---------- Default store ----------
|
|
58
|
+
/**
|
|
59
|
+
* Default cap on live records held by {@link MemoryIdempotencyStore}.
|
|
60
|
+
*
|
|
61
|
+
* Each record can hold a base64 response body up to
|
|
62
|
+
* {@link IdempotencyOptions.maxResponseBytes} (1 MiB by default), so the cap is
|
|
63
|
+
* what actually bounds this store's footprint. Matches the size at which the
|
|
64
|
+
* store already attempted an expiry sweep.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_MAX_IDEMPOTENCY_ENTRIES = 10_000;
|
|
58
67
|
/**
|
|
59
68
|
* In-memory {@link IdempotencyStore}. Suitable for tests and single-process
|
|
60
|
-
* deployments. Expired records are dropped on access
|
|
61
|
-
*
|
|
69
|
+
* deployments. Expired records are dropped on access.
|
|
70
|
+
*
|
|
71
|
+
* Growth is bounded by {@link maxEntries}: an expiry sweep runs first, and if the
|
|
72
|
+
* store is still at the cap the oldest surviving record is evicted. The sweep
|
|
73
|
+
* alone is not a bound — it only drops records that have *expired*, so a stream
|
|
74
|
+
* of unique keys inside the TTL grew the map linearly no matter how often it ran,
|
|
75
|
+
* with each entry pinning a stored response body.
|
|
76
|
+
*
|
|
77
|
+
* Evicting a live record can only cost exactly-once semantics for a retry that
|
|
78
|
+
* arrives after the eviction — it re-executes rather than replaying. That is the
|
|
79
|
+
* right trade against unbounded memory, but it is a reason to supply a shared
|
|
80
|
+
* (e.g. Redis) store for any deployment where the key volume approaches the cap.
|
|
62
81
|
*/
|
|
63
82
|
export class MemoryIdempotencyStore {
|
|
64
83
|
map = new Map();
|
|
84
|
+
maxEntries;
|
|
85
|
+
/**
|
|
86
|
+
* @param maxEntries - Maximum live records retained. Must be a positive
|
|
87
|
+
* integer. Default {@link DEFAULT_MAX_IDEMPOTENCY_ENTRIES} (10 000).
|
|
88
|
+
* @throws Error when `maxEntries` is not a positive integer.
|
|
89
|
+
*/
|
|
90
|
+
constructor(maxEntries = DEFAULT_MAX_IDEMPOTENCY_ENTRIES) {
|
|
91
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
92
|
+
throw new Error(`MemoryIdempotencyStore: maxEntries must be a positive integer; got ${String(maxEntries)}.`);
|
|
93
|
+
}
|
|
94
|
+
this.maxEntries = maxEntries;
|
|
95
|
+
}
|
|
65
96
|
/**
|
|
66
97
|
* @inheritDoc
|
|
67
98
|
* `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
|
|
@@ -71,9 +102,19 @@ export class MemoryIdempotencyStore {
|
|
|
71
102
|
const existing = this.read(key);
|
|
72
103
|
if (existing)
|
|
73
104
|
return existing;
|
|
74
|
-
this.map.
|
|
75
|
-
if (this.map.size > 10_000)
|
|
105
|
+
if (this.map.size >= this.maxEntries) {
|
|
76
106
|
this.prune();
|
|
107
|
+
// Still full: every record is live, so drop the oldest. `Map` iterates in
|
|
108
|
+
// insertion order, and `complete()` overwrites in place rather than
|
|
109
|
+
// re-inserting, so the first key is the least recently reserved.
|
|
110
|
+
while (this.map.size >= this.maxEntries) {
|
|
111
|
+
const oldest = this.map.keys().next();
|
|
112
|
+
if (oldest.done)
|
|
113
|
+
break;
|
|
114
|
+
this.map.delete(oldest.value);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
this.map.set(key, record);
|
|
77
118
|
return null;
|
|
78
119
|
}
|
|
79
120
|
/** @inheritDoc */
|
|
@@ -185,20 +226,69 @@ function validateKey(key, headerName, maxLen) {
|
|
|
185
226
|
throw new BadRequestError(`${headerName} header contains invalid characters.`);
|
|
186
227
|
}
|
|
187
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Response headers that must never be stored for replay.
|
|
231
|
+
*
|
|
232
|
+
* `set-cookie` is the security-critical entry. A stored response was replayed
|
|
233
|
+
* with every header the original produced, so a `Set-Cookie` issued to the first
|
|
234
|
+
* caller was re-issued to whoever replayed the record. Combined with any
|
|
235
|
+
* coarse-scoped namespace (a shared tenant `Authorization`, or an explicit
|
|
236
|
+
* `allowUnscopedCallers: true`) that promotes a body disclosure into handing over
|
|
237
|
+
* a live session — account takeover rather than data leakage. Even for a
|
|
238
|
+
* correctly scoped, genuinely same-caller retry it is wrong: the replay would
|
|
239
|
+
* resurrect a cookie the handler set once, silently rolling back a session
|
|
240
|
+
* rotation performed at login or on a privilege change.
|
|
241
|
+
*
|
|
242
|
+
* Unlike `responseCache()`, which refuses to store a `Set-Cookie` response at
|
|
243
|
+
* all, idempotency strips and stores: declining to store would release the
|
|
244
|
+
* reservation and let a retry re-execute the handler, which for a payment or
|
|
245
|
+
* order endpoint is the double-charge this middleware exists to prevent.
|
|
246
|
+
* Stripping keeps exactly-once semantics and drops only the header that must not
|
|
247
|
+
* be replayed.
|
|
248
|
+
*
|
|
249
|
+
* The remainder are hop-by-hop or per-request fields (RFC 9110 §7.6.1) that
|
|
250
|
+
* describe the one connection or the one request that populated the record;
|
|
251
|
+
* replaying `x-request-id` in particular hands every later caller a correlation
|
|
252
|
+
* id belonging to someone else's request.
|
|
253
|
+
*/
|
|
254
|
+
const NEVER_REPLAYED_HEADERS = new Set([
|
|
255
|
+
"set-cookie",
|
|
256
|
+
"set-cookie2",
|
|
257
|
+
"age",
|
|
258
|
+
"connection",
|
|
259
|
+
"keep-alive",
|
|
260
|
+
"proxy-authenticate",
|
|
261
|
+
"proxy-authorization",
|
|
262
|
+
"te",
|
|
263
|
+
"trailer",
|
|
264
|
+
"transfer-encoding",
|
|
265
|
+
"upgrade",
|
|
266
|
+
"x-request-id",
|
|
267
|
+
]);
|
|
188
268
|
async function captureResponse(res, maxBytes) {
|
|
189
269
|
const buf = new Uint8Array(await res.clone().arrayBuffer());
|
|
190
270
|
if (buf.byteLength > maxBytes)
|
|
191
271
|
return null;
|
|
192
272
|
const headers = [];
|
|
193
273
|
res.headers.forEach((value, name) => {
|
|
194
|
-
|
|
274
|
+
// Filter on capture, not on replay, so a credential never reaches the store
|
|
275
|
+
// in the first place — a shared (Redis) store would otherwise persist one
|
|
276
|
+
// caller's session cookie for the whole TTL.
|
|
277
|
+
if (!NEVER_REPLAYED_HEADERS.has(name.toLowerCase()))
|
|
278
|
+
headers.push([name, value]);
|
|
195
279
|
});
|
|
196
280
|
return { status: res.status, headers, body: buf.byteLength ? bytesToBase64(buf) : "" };
|
|
197
281
|
}
|
|
198
282
|
function buildReplayResponse(stored, replayHeaderName) {
|
|
199
283
|
const headers = new Headers();
|
|
200
|
-
for (const [name, value] of stored.headers)
|
|
284
|
+
for (const [name, value] of stored.headers) {
|
|
285
|
+
// Filtered on capture already; re-checked here so a record written by an
|
|
286
|
+
// older build — or by any other writer sharing the same Redis store — cannot
|
|
287
|
+
// replay a credential either.
|
|
288
|
+
if (NEVER_REPLAYED_HEADERS.has(name.toLowerCase()))
|
|
289
|
+
continue;
|
|
201
290
|
headers.set(name, value);
|
|
291
|
+
}
|
|
202
292
|
headers.set(replayHeaderName, "true");
|
|
203
293
|
const body = stored.body ? base64ToBytes(stored.body) : null;
|
|
204
294
|
return markSchemaValidatedResponse(new Response(body, { status: stored.status, headers }));
|
|
@@ -252,6 +342,7 @@ export function idempotency(opts = {}) {
|
|
|
252
342
|
const replayHeaderName = (opts.replayHeaderName ?? "idempotency-replayed").toLowerCase();
|
|
253
343
|
const methods = new Set((opts.methods ?? ["POST", "PUT", "PATCH", "DELETE"]).map((m) => m.toUpperCase()));
|
|
254
344
|
const requireKey = opts.requireKey === true;
|
|
345
|
+
const allowUnscopedCallers = opts.allowUnscopedCallers === true;
|
|
255
346
|
const cacheableStatus = opts.cacheableStatus ?? ((status) => status < 500);
|
|
256
347
|
const ttlMs = ttlSeconds * 1_000;
|
|
257
348
|
let store;
|
|
@@ -291,6 +382,44 @@ export function idempotency(opts = {}) {
|
|
|
291
382
|
const scopeRaw = opts.scope
|
|
292
383
|
? await opts.scope(ctx)
|
|
293
384
|
: (ctx.request.headers.get("authorization") ?? undefined);
|
|
385
|
+
// A credentialed request the default resolver cannot see is the dangerous
|
|
386
|
+
// case: cookie-session auth sends no `Authorization`, so `scopeRaw` is
|
|
387
|
+
// undefined, the namespace collapses to the shared one, and the retry
|
|
388
|
+
// fingerprint (method + path + body) becomes the only thing separating two
|
|
389
|
+
// users. Two users legitimately submit the same payload fingerprint
|
|
390
|
+
// identically — so client B replays client A's stored response, which is
|
|
391
|
+
// exactly the CWE-524 disclosure `scope` exists to prevent.
|
|
392
|
+
//
|
|
393
|
+
// Fail loudly rather than share the namespace. This mirrors
|
|
394
|
+
// `responseCache()`, which treats `Cookie` as a credential alongside
|
|
395
|
+
// `Authorization` for the same reason. Only fires when a cookie is present
|
|
396
|
+
// *and* nothing resolved, so the documented bearer-token path is untouched
|
|
397
|
+
// and a genuinely anonymous caller still shares the unscoped namespace.
|
|
398
|
+
//
|
|
399
|
+
// Deliberately NOT widened to "any cookie-bearing request". A resolvable
|
|
400
|
+
// scope can still be too coarse — a per-tenant API key with
|
|
401
|
+
// cookie-identified end users partitions per tenant while every user inside
|
|
402
|
+
// one shares a namespace — but that is indistinguishable from the far more
|
|
403
|
+
// common shape of a *per-user* bearer token arriving alongside incidental
|
|
404
|
+
// browser cookies (analytics, consent, CSRF), where the default is already
|
|
405
|
+
// correct. Keying the guard on the cookie's presence rejects that setup with
|
|
406
|
+
// a 500, so the check stays where the default is provably useless rather
|
|
407
|
+
// than merely possibly coarse. Callers whose `Authorization` is shared
|
|
408
|
+
// across users must pass `scope` — see the TSDoc on
|
|
409
|
+
// {@link IdempotencyOptions.scope}. Independently of scoping, `Set-Cookie`
|
|
410
|
+
// is never stored or replayed (see {@link NEVER_REPLAYED_HEADERS}), so a
|
|
411
|
+
// coarse namespace cannot escalate into handing over a live session.
|
|
412
|
+
if (!opts.scope &&
|
|
413
|
+
scopeRaw === undefined &&
|
|
414
|
+
!allowUnscopedCallers &&
|
|
415
|
+
ctx.request.headers.has("cookie")) {
|
|
416
|
+
throw new Error("idempotency(): cannot determine the calling principal for a cookie-bearing request. " +
|
|
417
|
+
"The default scope reads the Authorization header, which this request does not carry, " +
|
|
418
|
+
"so every cookie-authenticated caller would share one idempotency namespace and could " +
|
|
419
|
+
"replay another caller's stored response (CWE-524). Pass " +
|
|
420
|
+
"`scope: (ctx) => ctx.state.session?.id` (or another stable per-caller id), or set " +
|
|
421
|
+
"`allowUnscopedCallers: true` if these callers are genuinely interchangeable.");
|
|
422
|
+
}
|
|
294
423
|
const scopeTag = scopeRaw ? `${await sha256Hex(scopeRaw)}:` : "";
|
|
295
424
|
const storeKey = `${keyPrefix}${scopeTag}${key}`;
|
|
296
425
|
const now = Date.now();
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DA
|
|
|
12
12
|
export type { SubdomainsOptions, SubdomainsResult } from "./subdomains.js";
|
|
13
13
|
export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
|
|
14
14
|
export type { DependencyHooks, DependencyOptions } from "./dependency.js";
|
|
15
|
-
export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, PreBodyContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
|
|
15
|
+
export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, PreBodyContext, IdentityGateContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
|
|
16
16
|
export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
|
|
17
17
|
export type { ProblemDetails, ProblemRenderOptions, HttpErrorOptions } from "./errors.js";
|
|
18
18
|
export type { StandardSchemaV1 } from "./schema.js";
|
|
@@ -101,5 +101,5 @@ export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACI
|
|
|
101
101
|
export type { OtelTracingOptions, TracingAttributes, TracingAttributeValue, TracingSpan, TracingStartSpanOptions, TracingTracer, } from "./tracing.js";
|
|
102
102
|
export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
|
|
103
103
|
export type { TenancyOptions, TenantResolver, TenantScopeOptions, SubdomainTenantOptions, PathPrefixTenantOptions, ClaimTenantOptions, UnresolvedStatus, InvalidStatus, } from "./tenancy.js";
|
|
104
|
-
export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
|
|
104
|
+
export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, isValidWireCloseCode, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
|
|
105
105
|
export type { WebSocketConnection, WebSocketContext, WebSocketHandler, WebSocketMeta, WebSocketRouteEntry, NormalizedWebSocketOptions, WebSocketBeforeUpgrade, HandshakeResult, ParsedFrame, MessageEvent as WebSocketMessageEvent, FrameSinkEvents, } from "./websocket.js";
|
package/dist/index.js
CHANGED
|
@@ -51,4 +51,4 @@ export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATI
|
|
|
51
51
|
export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
|
|
52
52
|
export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
|
|
53
53
|
export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
|
|
54
|
-
export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
|
|
54
|
+
export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, isValidWireCloseCode, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
|
package/dist/ip-reputation.d.ts
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* @module
|
|
45
45
|
* @since 0.37.0
|
|
46
46
|
*/
|
|
47
|
-
import type {
|
|
47
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
48
48
|
/**
|
|
49
49
|
* A pluggable source of abusive IP / CIDR entries. Implementations return the
|
|
50
50
|
* raw list each refresh; parsing, validation, and de-duplication are handled by
|
|
@@ -105,7 +105,7 @@ export interface IpReputationOptions {
|
|
|
105
105
|
* Custom client-IP resolver. Overrides {@link IpReputationOptions.trustProxyHeaders}.
|
|
106
106
|
* Defaults to failing open (no IP → not blocked).
|
|
107
107
|
*/
|
|
108
|
-
resolveIp?: (ctx:
|
|
108
|
+
resolveIp?: (ctx: IdentityGateContext) => string | undefined;
|
|
109
109
|
/**
|
|
110
110
|
* Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
|
|
111
111
|
* enable behind a trusted proxy that overwrites these headers.
|
package/dist/ip-reputation.js
CHANGED
|
@@ -222,7 +222,12 @@ export function ipReputation(opts) {
|
|
|
222
222
|
const ready = opts.loadOnStart === false ? Promise.resolve() : refresh();
|
|
223
223
|
return {
|
|
224
224
|
hooks: {
|
|
225
|
-
beforeHandle
|
|
225
|
+
// `preBody`, not `beforeHandle`: a denylist gate that short-circuits from
|
|
226
|
+
// `beforeHandle` is preempted by any earlier `beforeHandle` middleware that
|
|
227
|
+
// returns a Response first — a `responseCache()` HIT mounted above it would
|
|
228
|
+
// serve a denylisted address the cached body. `preBody` always runs first,
|
|
229
|
+
// so the feed holds regardless of mount order.
|
|
230
|
+
preBody(ctx) {
|
|
226
231
|
const ip = resolveIp(ctx);
|
|
227
232
|
if (!ip)
|
|
228
233
|
return undefined; // fail-open on unresolved IP
|
package/dist/ip-restriction.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @since 0.19.0
|
|
9
9
|
*/
|
|
10
|
-
import type {
|
|
10
|
+
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
11
11
|
/**
|
|
12
12
|
* Options for {@link ipRestriction}. At least one of `allow` or `deny` must
|
|
13
13
|
* be provided; supplying both runs deny-first then allow-otherwise (deny
|
|
@@ -36,7 +36,7 @@ export interface IpRestrictionOptions {
|
|
|
36
36
|
* Provide a function to read adapter connection metadata or a trusted
|
|
37
37
|
* custom header (e.g. a CDN-specific identifier).
|
|
38
38
|
*/
|
|
39
|
-
resolveIp?: (ctx:
|
|
39
|
+
resolveIp?: (ctx: IdentityGateContext) => string | undefined;
|
|
40
40
|
/**
|
|
41
41
|
* Read `X-Forwarded-For` / `X-Real-IP` in the default resolver. Defaults
|
|
42
42
|
* to `false` because those headers are client-spoofable unless every
|
|
@@ -101,7 +101,7 @@ export interface IpMatcher {
|
|
|
101
101
|
*
|
|
102
102
|
* @param opts Allow/deny lists plus IP-resolution options; see
|
|
103
103
|
* {@link IpRestrictionOptions}. Deny matches always win over allow.
|
|
104
|
-
* @returns A {@link Hooks} object whose `
|
|
104
|
+
* @returns A {@link Hooks} object whose `preBody` hook enforces the lists,
|
|
105
105
|
* failing closed (403) when the client IP cannot be resolved or parsed.
|
|
106
106
|
* @throws Error at setup time when neither `allow` nor `deny` is provided,
|
|
107
107
|
* or when a pattern is not a valid IP/CIDR.
|
package/dist/ip-restriction.js
CHANGED
|
@@ -29,7 +29,7 @@ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js"
|
|
|
29
29
|
*
|
|
30
30
|
* @param opts Allow/deny lists plus IP-resolution options; see
|
|
31
31
|
* {@link IpRestrictionOptions}. Deny matches always win over allow.
|
|
32
|
-
* @returns A {@link Hooks} object whose `
|
|
32
|
+
* @returns A {@link Hooks} object whose `preBody` hook enforces the lists,
|
|
33
33
|
* failing closed (403) when the client IP cannot be resolved or parsed.
|
|
34
34
|
* @throws Error at setup time when neither `allow` nor `deny` is provided,
|
|
35
35
|
* or when a pattern is not a valid IP/CIDR.
|
|
@@ -45,7 +45,12 @@ export function ipRestriction(opts) {
|
|
|
45
45
|
const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
|
|
46
46
|
const message = opts.message ?? "IP address not permitted";
|
|
47
47
|
return {
|
|
48
|
-
beforeHandle
|
|
48
|
+
// `preBody`, not `beforeHandle`: a gate that returns a Response from
|
|
49
|
+
// `beforeHandle` can be preempted by any earlier `beforeHandle` middleware
|
|
50
|
+
// that short-circuits first — a `responseCache()` HIT mounted above it would
|
|
51
|
+
// serve a deny-listed address the cached body. `preBody` always runs first,
|
|
52
|
+
// so the allow/deny lists hold regardless of mount order.
|
|
53
|
+
preBody(ctx) {
|
|
49
54
|
const raw = resolveIp(ctx);
|
|
50
55
|
if (!raw)
|
|
51
56
|
throw new ForbiddenError(message);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -35,32 +35,24 @@ export declare const MCP_PROTOCOL_VERSIONS: readonly string[];
|
|
|
35
35
|
*
|
|
36
36
|
* @since 1.0.0
|
|
37
37
|
*/
|
|
38
|
-
export declare const MCP_META_KEYS:
|
|
39
|
-
/** Protocol version for this request. Required on every modern request. */
|
|
38
|
+
export declare const MCP_META_KEYS: {
|
|
40
39
|
readonly protocolVersion: "io.modelcontextprotocol/protocolVersion";
|
|
41
|
-
/** Self-reported client name/version. Advisory only; never a security input. */
|
|
42
40
|
readonly clientInfo: "io.modelcontextprotocol/clientInfo";
|
|
43
|
-
/** Client capabilities relevant to this request. Required on every modern request. */
|
|
44
41
|
readonly clientCapabilities: "io.modelcontextprotocol/clientCapabilities";
|
|
45
|
-
/** Minimum log level the server should emit for this request. */
|
|
46
42
|
readonly logLevel: "io.modelcontextprotocol/logLevel";
|
|
47
|
-
/** Self-reported server name/version, returned in each modern result's `_meta`. */
|
|
48
43
|
readonly serverInfo: "io.modelcontextprotocol/serverInfo";
|
|
49
|
-
}
|
|
44
|
+
};
|
|
50
45
|
/**
|
|
51
46
|
* JSON-RPC error codes defined by the MCP specification in its reserved
|
|
52
47
|
* `-32020`..`-32099` sub-range.
|
|
53
48
|
*
|
|
54
49
|
* @since 1.0.0
|
|
55
50
|
*/
|
|
56
|
-
export declare const MCP_ERROR_CODES:
|
|
57
|
-
/** HTTP headers disagree with the request body, or a required header is missing. */
|
|
51
|
+
export declare const MCP_ERROR_CODES: {
|
|
58
52
|
readonly headerMismatch: -32020;
|
|
59
|
-
/** The request needs a client capability the client did not declare. */
|
|
60
53
|
readonly missingRequiredClientCapability: -32021;
|
|
61
|
-
/** The requested protocol version is not implemented by this server. */
|
|
62
54
|
readonly unsupportedProtocolVersion: -32022;
|
|
63
|
-
}
|
|
55
|
+
};
|
|
64
56
|
/**
|
|
65
57
|
* Default maximum accepted JSON-RPC request body for a DaloyJS MCP endpoint.
|
|
66
58
|
* The cap is intentionally small because MCP calls should carry parameters,
|
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",
|