@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/conn-info.js
CHANGED
|
@@ -106,6 +106,100 @@ export function pickForwardedForByHops(header, hops) {
|
|
|
106
106
|
// typically lives at parts[parts.length - hops].
|
|
107
107
|
return parts[parts.length - hops];
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Validate a middleware's forwarded-header trust options and resolve them into
|
|
111
|
+
* a single hop count. Every middleware that keys on client IP calls this once
|
|
112
|
+
* at construction, so the trust policy lives in exactly one place instead of
|
|
113
|
+
* being re-derived at each call site.
|
|
114
|
+
*
|
|
115
|
+
* That single-source property is the point: the spoofable-IP vulnerability this
|
|
116
|
+
* module now guards against existed in nine independent copies of the same
|
|
117
|
+
* leftmost-`X-Forwarded-For` read, which meant nine separate places to get it
|
|
118
|
+
* wrong and nine separate fixes. Keeping the decision here means a future
|
|
119
|
+
* change to the trust rules lands everywhere at once.
|
|
120
|
+
*
|
|
121
|
+
* `trustedHops` must be an integer in [1, 64], mirroring the
|
|
122
|
+
* `behindProxy.hops` range: the floor of one exists because a middleware that
|
|
123
|
+
* trusts zero proxy hops has no business reading forwarding headers at all.
|
|
124
|
+
*
|
|
125
|
+
* @param name - Middleware function name used in error messages.
|
|
126
|
+
* @param opts - The middleware's options object; only the two trust fields are
|
|
127
|
+
* read, so any middleware option type is structurally acceptable.
|
|
128
|
+
* @returns The number of trusted proxy hops when forwarded-header trust is
|
|
129
|
+
* enabled — `trustedHops` verbatim, or `1` for a bare
|
|
130
|
+
* `trustProxyHeaders: true` — or `undefined` when trust is off and the caller
|
|
131
|
+
* must not read forwarding headers at all.
|
|
132
|
+
* @throws Error when `trustedHops` is not an integer in [1, 64], or when
|
|
133
|
+
* `trustProxyHeaders: false` is combined with a `trustedHops` value. That
|
|
134
|
+
* pairing is a contradiction, and it previously resolved silently in favour
|
|
135
|
+
* of trust — meaning an explicit opt-out was ignored.
|
|
136
|
+
* @internal
|
|
137
|
+
*/
|
|
138
|
+
export function resolveForwardedTrust(name, opts) {
|
|
139
|
+
const hops = opts.trustedHops;
|
|
140
|
+
const trust = opts.trustProxyHeaders;
|
|
141
|
+
if (hops !== undefined) {
|
|
142
|
+
if (!Number.isInteger(hops) || hops < 1 || hops > 64) {
|
|
143
|
+
throw new Error(`${name}: trustedHops must be an integer in [1, 64]; got ${String(hops)}.`);
|
|
144
|
+
}
|
|
145
|
+
if (trust === false) {
|
|
146
|
+
throw new Error(`${name}: trustProxyHeaders: false contradicts trustedHops: ${hops}. ` +
|
|
147
|
+
"trustedHops implies proxy-header trust; drop whichever one you did not mean.");
|
|
148
|
+
}
|
|
149
|
+
return hops;
|
|
150
|
+
}
|
|
151
|
+
return trust === true ? 1 : undefined;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the client IP from the proxy-set forwarding headers, walking a
|
|
155
|
+
* declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
|
|
156
|
+
*
|
|
157
|
+
* The right side is the spoof-resistant side: each proxy in the chain appends
|
|
158
|
+
* the address of the peer it actually observed, so the last `hops` entries
|
|
159
|
+
* were written by infrastructure you control, while anything further left is
|
|
160
|
+
* attacker-influenceable. Reading the leftmost entry (the historic
|
|
161
|
+
* `split(",")[0]` pattern) trusts the MOST attacker-controllable slot and
|
|
162
|
+
* enabled both rate-limit/ban evasion (rotate a spoofed left entry) and
|
|
163
|
+
* victim-IP framing (spoof a victim's address to get them banned or blocked).
|
|
164
|
+
*
|
|
165
|
+
* Falls back to `X-Real-IP` **only for a single declared hop**. That header
|
|
166
|
+
* carries exactly one hop of information, so it can stand in for a one-proxy
|
|
167
|
+
* declaration (the common nginx `X-Real-IP`-only setup) but cannot possibly
|
|
168
|
+
* satisfy a two-or-more-hop one. With 2+ declared hops and a chain shorter
|
|
169
|
+
* than the declaration, this returns `undefined` rather than guessing.
|
|
170
|
+
*
|
|
171
|
+
* Security note: this resolver is only meaningful when every request reaches
|
|
172
|
+
* the app through a proxy chain you control that appends (or overwrites)
|
|
173
|
+
* these headers. With no proxy in front, any forwarded-header trust is
|
|
174
|
+
* attacker-controlled by definition.
|
|
175
|
+
*
|
|
176
|
+
* @param request - Incoming request whose forwarding headers are read.
|
|
177
|
+
* @param hops - Number of trusted proxy hops; `1` (default) reads the
|
|
178
|
+
* rightmost entry — the one your immediate proxy appended.
|
|
179
|
+
* @returns The resolved client IP, or `undefined` when no forwarded identity
|
|
180
|
+
* is available. Callers decide their own posture for `undefined`
|
|
181
|
+
* (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
|
|
182
|
+
* @since 1.0.0-rc.7
|
|
183
|
+
*/
|
|
184
|
+
export function resolveForwardedClientIp(request, hops = 1) {
|
|
185
|
+
const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), hops);
|
|
186
|
+
if (picked)
|
|
187
|
+
return picked;
|
|
188
|
+
// Fail closed past one hop. A chain that produced fewer than `hops` entries
|
|
189
|
+
// means the request never traversed the declared topology — a direct-to-origin
|
|
190
|
+
// request that skipped the CDN, say — so no forwarded value it carries is
|
|
191
|
+
// trustworthy, `X-Real-IP` least of all. Trusting it here would hand back the
|
|
192
|
+
// rotating-identity evasion and victim-IP framing that reading from the right
|
|
193
|
+
// exists to prevent.
|
|
194
|
+
//
|
|
195
|
+
// An attacker inside the chain cannot reach this path: conforming proxies
|
|
196
|
+
// append, so prepending entries only ever lengthens the header. Reaching it
|
|
197
|
+
// requires bypassing the declared chain, and the safe answer there is "no
|
|
198
|
+
// identity", not "the identity the caller asked me to believe".
|
|
199
|
+
if (hops !== 1)
|
|
200
|
+
return undefined;
|
|
201
|
+
return request.headers.get("x-real-ip") ?? undefined;
|
|
202
|
+
}
|
|
109
203
|
/**
|
|
110
204
|
* Resolve the client IP for this request using the configured
|
|
111
205
|
* {@link BehindProxyConfig}. Returns `undefined` when no trusted source is
|
|
@@ -125,10 +219,11 @@ export function resolveClientIp(request, cfg) {
|
|
|
125
219
|
return peer;
|
|
126
220
|
if (cfg === "loopback") {
|
|
127
221
|
if (peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1") {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
222
|
+
// The same-host proxy is the single trusted hop, so read the slot IT
|
|
223
|
+
// appended (rightmost), never the attacker-influenceable leftmost one.
|
|
224
|
+
const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), 1);
|
|
225
|
+
if (picked)
|
|
226
|
+
return picked;
|
|
132
227
|
}
|
|
133
228
|
return peer;
|
|
134
229
|
}
|
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,14 +110,29 @@ 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
|
|
117
117
|
* request reaches Daloy through a proxy chain you control. Ignored when
|
|
118
118
|
* `resolveCountry` is used or a custom `resolveIp` is supplied.
|
|
119
|
+
*
|
|
120
|
+
* When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
|
|
121
|
+
* entry — the one your immediate proxy appended — never the
|
|
122
|
+
* attacker-influenceable leftmost one. Behind more than one proxy hop, set
|
|
123
|
+
* {@link trustedHops} instead.
|
|
119
124
|
*/
|
|
120
125
|
trustProxyHeaders?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Declare exactly how many proxy hops sit between Daloy and the public
|
|
128
|
+
* internet. Implies proxy-header trust and reads the client IP that many
|
|
129
|
+
* entries from the right of `X-Forwarded-For` via
|
|
130
|
+
* {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
|
|
131
|
+
* entries on the left cannot spoof an allowed-country IP. Must be an
|
|
132
|
+
* integer in [1, 64]; validated at construction. Ignored when
|
|
133
|
+
* `resolveCountry` is used or a custom `resolveIp` is supplied.
|
|
134
|
+
*/
|
|
135
|
+
trustedHops?: number;
|
|
121
136
|
/**
|
|
122
137
|
* What to do when the country cannot be resolved. Defaults to `false` when
|
|
123
138
|
* an `allow` list is configured (fail closed — an unknown country is not on
|
package/dist/geo-block.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* @since 0.37.0
|
|
26
26
|
*/
|
|
27
27
|
import { ForbiddenError } from "./errors.js";
|
|
28
|
+
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
28
29
|
/** @internal Validate + normalise a configured country code, or throw. */
|
|
29
30
|
function normalizeConfiguredCode(input) {
|
|
30
31
|
const code = input.trim().toUpperCase();
|
|
@@ -38,16 +39,14 @@ function normalizeConfiguredCode(input) {
|
|
|
38
39
|
function noIpResolver(_ctx) {
|
|
39
40
|
return undefined;
|
|
40
41
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
return ctx.request.headers.get("x-real-ip") ?? undefined;
|
|
42
|
+
/**
|
|
43
|
+
* @internal Read the client IP `hops` entries from the right of
|
|
44
|
+
* `X-Forwarded-For` (falling back to `X-Real-IP`). The right side is written
|
|
45
|
+
* by the operator's own proxy chain and is therefore the spoof-resistant
|
|
46
|
+
* side — see {@link resolveForwardedClientIp}.
|
|
47
|
+
*/
|
|
48
|
+
function forwardedIpResolver(hops) {
|
|
49
|
+
return (ctx) => resolveForwardedClientIp(ctx.request, hops);
|
|
51
50
|
}
|
|
52
51
|
/**
|
|
53
52
|
* Block or allow requests by client country. Daloy ships no GeoIP database;
|
|
@@ -105,9 +104,16 @@ export function geoBlock(opts) {
|
|
|
105
104
|
const onBlock = opts.onBlock;
|
|
106
105
|
const lookupCountry = opts.lookupCountry;
|
|
107
106
|
const resolveCountry = opts.resolveCountry;
|
|
108
|
-
const
|
|
107
|
+
const hops = resolveForwardedTrust("geoBlock()", opts);
|
|
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
|
@@ -6,21 +6,21 @@ export { _resetCrashHandlersForTests } from "./app.js";
|
|
|
6
6
|
export { _resetInsecureDefaultsLogForTests } from "./app.js";
|
|
7
7
|
export { _resetIndeterminateEnvWarningForTests } from "./app.js";
|
|
8
8
|
export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, AsyncAPIRouteOptions, HealthRouteOptions, CspReportRouteOptions, MetricsRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
|
|
9
|
-
export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
|
|
9
|
+
export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, resolveForwardedClientIp, } from "./conn-info.js";
|
|
10
10
|
export type { BehindProxyConfig, ConnInfo } from "./conn-info.js";
|
|
11
11
|
export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
|
|
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";
|
|
19
19
|
export { validate, isStandardSchema } from "./schema.js";
|
|
20
20
|
export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
|
|
21
21
|
export type { ChangeSeverity, OpenAPIChange, OpenAPIDiffResult } from "./openapi-diff.js";
|
|
22
|
-
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
|
|
23
|
-
export type { McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
|
|
22
|
+
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_ERROR_CODES, MCP_MAX_REQUEST_STATE_LENGTH, MCP_META_KEYS, MCP_MODERN_ERA_MIN_VERSION, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, isModernProtocolVersion, mcpRoutes, validateMcpInput, } from "./mcp.js";
|
|
23
|
+
export type { McpCacheHints, McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpImplementation, McpInputRequest, McpInputRequests, McpInputRequiredResult, McpInputResponses, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpProtocolEra, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
|
|
24
24
|
export { readBodyLimited, safeJsonParse, safeJsonParseLimited, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
|
|
25
25
|
export type { WebhookHmacAlgorithm } from "./security.js";
|
|
26
26
|
export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.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
|
@@ -5,13 +5,13 @@ export { findRoutesMissingResponseBodySchema } from "./app.js";
|
|
|
5
5
|
export { _resetCrashHandlersForTests } from "./app.js";
|
|
6
6
|
export { _resetInsecureDefaultsLogForTests } from "./app.js";
|
|
7
7
|
export { _resetIndeterminateEnvWarningForTests } from "./app.js";
|
|
8
|
-
export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
|
|
8
|
+
export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, resolveForwardedClientIp, } from "./conn-info.js";
|
|
9
9
|
export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
|
|
10
10
|
export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
|
|
11
11
|
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";
|
|
12
12
|
export { validate, isStandardSchema } from "./schema.js";
|
|
13
13
|
export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
|
|
14
|
-
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
|
|
14
|
+
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_ERROR_CODES, MCP_MAX_REQUEST_STATE_LENGTH, MCP_META_KEYS, MCP_MODERN_ERA_MIN_VERSION, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, isModernProtocolVersion, mcpRoutes, validateMcpInput, } from "./mcp.js";
|
|
15
15
|
export { readBodyLimited, safeJsonParse, safeJsonParseLimited, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
|
|
16
16
|
export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
|
|
17
17
|
export { etag } from "./etag.js";
|
|
@@ -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,12 +105,26 @@ 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.
|
|
112
|
+
*
|
|
113
|
+
* When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
|
|
114
|
+
* entry — the one your immediate proxy appended — never the
|
|
115
|
+
* attacker-influenceable leftmost one. Behind more than one proxy hop, set
|
|
116
|
+
* {@link trustedHops} instead.
|
|
112
117
|
*/
|
|
113
118
|
trustProxyHeaders?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Declare exactly how many proxy hops sit between Daloy and the public
|
|
121
|
+
* internet. Implies proxy-header trust and reads the client IP that many
|
|
122
|
+
* entries from the right of `X-Forwarded-For` via
|
|
123
|
+
* {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
|
|
124
|
+
* entries on the left cannot dodge the denylist. Must be an integer in
|
|
125
|
+
* [1, 64]; validated at construction.
|
|
126
|
+
*/
|
|
127
|
+
trustedHops?: number;
|
|
114
128
|
/**
|
|
115
129
|
* `"block"` (default) throws a {@link ForbiddenError} on a match; `"log"`
|
|
116
130
|
* only invokes {@link IpReputationOptions.onMatch} and lets the request
|
package/dist/ip-reputation.js
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
*/
|
|
47
47
|
import { ForbiddenError } from "./errors.js";
|
|
48
48
|
import { fetchGuard } from "./fetch-guard.js";
|
|
49
|
+
import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
|
|
49
50
|
import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-restriction.js";
|
|
50
51
|
const DEFAULT_REFRESH_MS = 60 * 60_000;
|
|
51
52
|
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
@@ -113,15 +114,13 @@ export function urlFeed(url, opts = {}) {
|
|
|
113
114
|
},
|
|
114
115
|
};
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
return ctx.request.headers.get("x-real-ip") ?? undefined;
|
|
117
|
+
/**
|
|
118
|
+
* @internal Read the client IP `hops` entries from the right of
|
|
119
|
+
* `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
|
|
120
|
+
* of the header; see {@link resolveForwardedClientIp}.
|
|
121
|
+
*/
|
|
122
|
+
function forwardedIpResolver(hops) {
|
|
123
|
+
return (ctx) => resolveForwardedClientIp(ctx.request, hops);
|
|
125
124
|
}
|
|
126
125
|
function noIpResolver(_ctx) {
|
|
127
126
|
return undefined;
|
|
@@ -158,7 +157,8 @@ export function ipReputation(opts) {
|
|
|
158
157
|
throw new Error("ipReputation(): fetchTimeoutMs must be a positive integer.");
|
|
159
158
|
}
|
|
160
159
|
const message = opts.message ?? DEFAULT_MESSAGE;
|
|
161
|
-
const
|
|
160
|
+
const hops = resolveForwardedTrust("ipReputation()", opts);
|
|
161
|
+
const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
|
|
162
162
|
// Last-known-good compiled denylist, one entry per feed so a single feed's
|
|
163
163
|
// failed refresh doesn't drop the others.
|
|
164
164
|
let compiled = opts.feeds.map((f) => ({ name: f.name, v4: [], v6: [] }));
|
|
@@ -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
|