@daloyjs/core 1.0.0 → 1.1.0
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 +4 -3
- package/dist/adapters/node.d.ts +6 -1
- package/dist/adapters/node.js +54 -0
- package/dist/auto-ban.d.ts +21 -6
- package/dist/auto-ban.js +10 -8
- package/dist/bot-guard.d.ts +12 -2
- package/dist/bot-guard.js +7 -5
- package/dist/concurrency-limit.d.ts +10 -0
- package/dist/concurrency-limit.js +15 -6
- package/dist/conn-info.d.ts +54 -9
- package/dist/conn-info.js +142 -14
- package/dist/fetch-guard.js +1 -1
- package/dist/geo-block.d.ts +11 -0
- package/dist/geo-block.js +6 -4
- package/dist/ip-match.d.ts +62 -0
- package/dist/ip-match.js +171 -0
- package/dist/ip-reputation.d.ts +10 -0
- package/dist/ip-reputation.js +7 -5
- package/dist/ip-restriction.d.ts +13 -48
- package/dist/ip-restriction.js +10 -161
- package/dist/jwt.d.ts +15 -1
- package/dist/jwt.js +35 -1
- package/dist/middleware.d.ts +27 -2
- package/dist/middleware.js +26 -7
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +1 -1
package/dist/conn-info.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* @since 0.24.0
|
|
20
20
|
*/
|
|
21
|
+
import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-match.js";
|
|
21
22
|
const CONN_INFO_SYMBOL = Symbol.for("daloyjs.connInfo");
|
|
22
23
|
/**
|
|
23
24
|
* @internal Adapter helper — attach {@link ConnInfo} to a `Request`. Called
|
|
@@ -74,6 +75,16 @@ export function assertBehindProxy(cfg) {
|
|
|
74
75
|
if (typeof c !== "string" || c.length === 0) {
|
|
75
76
|
throw new Error("behindProxy.cidrs entries must be non-empty strings.");
|
|
76
77
|
}
|
|
78
|
+
// Refuse to boot on an invalid CIDR — a typo'd range must never
|
|
79
|
+
// silently become "trust nobody" (fail-closed) or, worse, be ignored
|
|
80
|
+
// (fail-open). Compile once here; request-time resolution reuses the
|
|
81
|
+
// cached matchers, so the throw surface is construction-only.
|
|
82
|
+
try {
|
|
83
|
+
compileCidrMatcher(c);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error(`behindProxy.cidrs: invalid IP/CIDR entry ${JSON.stringify(c)}.`);
|
|
87
|
+
}
|
|
77
88
|
}
|
|
78
89
|
return;
|
|
79
90
|
}
|
|
@@ -122,22 +133,36 @@ export function pickForwardedForByHops(header, hops) {
|
|
|
122
133
|
* `behindProxy.hops` range: the floor of one exists because a middleware that
|
|
123
134
|
* trusts zero proxy hops has no business reading forwarding headers at all.
|
|
124
135
|
*
|
|
136
|
+
* `trustedProxies` (a CIDR allowlist of proxy peer addresses) also enables
|
|
137
|
+
* forwarded-header trust, defaulting to one hop when `trustedHops` is not
|
|
138
|
+
* set: declaring WHO your proxies are is meaningless unless their headers
|
|
139
|
+
* are then read. Pair it with {@link resolveTrustedProxyMatchers} and pass
|
|
140
|
+
* the compiled matchers to {@link resolveForwardedClientIp} so the forwarded
|
|
141
|
+
* identity is honoured only when the immediate TCP peer is a verified proxy.
|
|
142
|
+
*
|
|
125
143
|
* @param name - Middleware function name used in error messages.
|
|
126
|
-
* @param opts - The middleware's options object; only the
|
|
144
|
+
* @param opts - The middleware's options object; only the trust fields are
|
|
127
145
|
* read, so any middleware option type is structurally acceptable.
|
|
128
146
|
* @returns The number of trusted proxy hops when forwarded-header trust is
|
|
129
|
-
* enabled — `trustedHops` verbatim,
|
|
130
|
-
* `trustProxyHeaders: true
|
|
131
|
-
*
|
|
147
|
+
* enabled — `trustedHops` verbatim, `1` for a bare
|
|
148
|
+
* `trustProxyHeaders: true`, or `1` when `trustedProxies` is declared
|
|
149
|
+
* without an explicit hop count — or `undefined` when trust is off and the
|
|
150
|
+
* caller must not read forwarding headers at all.
|
|
132
151
|
* @throws Error when `trustedHops` is not an integer in [1, 64], or when
|
|
133
|
-
* `trustProxyHeaders: false` is combined with a `trustedHops` value
|
|
134
|
-
*
|
|
135
|
-
* of trust — meaning an explicit
|
|
152
|
+
* `trustProxyHeaders: false` is combined with a `trustedHops` value or a
|
|
153
|
+
* `trustedProxies` list. Those pairings are contradictions, and the first
|
|
154
|
+
* previously resolved silently in favour of trust — meaning an explicit
|
|
155
|
+
* opt-out was ignored.
|
|
136
156
|
* @internal
|
|
137
157
|
*/
|
|
138
158
|
export function resolveForwardedTrust(name, opts) {
|
|
139
159
|
const hops = opts.trustedHops;
|
|
140
160
|
const trust = opts.trustProxyHeaders;
|
|
161
|
+
const proxies = opts.trustedProxies;
|
|
162
|
+
if (trust === false && proxies !== undefined) {
|
|
163
|
+
throw new Error(`${name}: trustProxyHeaders: false contradicts trustedProxies. ` +
|
|
164
|
+
"trustedProxies implies proxy-header trust; drop whichever one you did not mean.");
|
|
165
|
+
}
|
|
141
166
|
if (hops !== undefined) {
|
|
142
167
|
if (!Number.isInteger(hops) || hops < 1 || hops > 64) {
|
|
143
168
|
throw new Error(`${name}: trustedHops must be an integer in [1, 64]; got ${String(hops)}.`);
|
|
@@ -148,8 +173,70 @@ export function resolveForwardedTrust(name, opts) {
|
|
|
148
173
|
}
|
|
149
174
|
return hops;
|
|
150
175
|
}
|
|
176
|
+
if (proxies !== undefined)
|
|
177
|
+
return 1;
|
|
151
178
|
return trust === true ? 1 : undefined;
|
|
152
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Validate and compile a middleware's `trustedProxies` CIDR allowlist into
|
|
182
|
+
* matchers usable with {@link resolveForwardedClientIp}. Called once at
|
|
183
|
+
* construction so the per-request cost of peer verification is a handful of
|
|
184
|
+
* byte comparisons, never string parsing.
|
|
185
|
+
*
|
|
186
|
+
* The allowlist answers the question `trustedHops` alone cannot: not "how
|
|
187
|
+
* many proxies are in front of me" but "is the socket actually talking to me
|
|
188
|
+
* one of MY proxies". Without it, any client that can reach the origin
|
|
189
|
+
* directly can claim any `X-Forwarded-For` identity — the victim-IP framing
|
|
190
|
+
* and ban-evasion classes documented on {@link resolveForwardedClientIp}.
|
|
191
|
+
*
|
|
192
|
+
* @param name - Middleware function name used in error messages.
|
|
193
|
+
* @param opts - The middleware's options object; only `trustedProxies` is read.
|
|
194
|
+
* @returns The compiled matchers, or `undefined` when `trustedProxies` is not
|
|
195
|
+
* declared (no peer verification — the pre-existing posture).
|
|
196
|
+
* @throws Error when the list is empty (a silent "trust nobody" foot-gun) or
|
|
197
|
+
* any entry is not a valid IP/CIDR. Both are refuse-at-construction
|
|
198
|
+
* misconfigurations, never request-time surprises.
|
|
199
|
+
* @internal
|
|
200
|
+
*/
|
|
201
|
+
export function resolveTrustedProxyMatchers(name, opts) {
|
|
202
|
+
const proxies = opts.trustedProxies;
|
|
203
|
+
if (proxies === undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
if (!Array.isArray(proxies) || proxies.length === 0) {
|
|
206
|
+
throw new Error(`${name}: trustedProxies must be a non-empty IP/CIDR string array.`);
|
|
207
|
+
}
|
|
208
|
+
return proxies.map((entry) => {
|
|
209
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
210
|
+
throw new Error(`${name}: trustedProxies entries must be non-empty strings.`);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return compileCidrMatcher(entry);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new Error(`${name}: trustedProxies — invalid IP/CIDR entry ${JSON.stringify(entry)}.`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Test whether the immediate TCP peer of `request` is inside the compiled
|
|
222
|
+
* `trustedProxies` allowlist. Returns `false` when the adapter attached no
|
|
223
|
+
* connection metadata (pure edge delegators expose no peer socket) — peer
|
|
224
|
+
* verification fails closed by design.
|
|
225
|
+
*
|
|
226
|
+
* @param request - Incoming request whose adapter-attached peer is checked.
|
|
227
|
+
* @param trustedPeers - Compiled matchers from {@link resolveTrustedProxyMatchers}.
|
|
228
|
+
* @returns `true` only when a peer address exists and matches the allowlist.
|
|
229
|
+
* @internal
|
|
230
|
+
*/
|
|
231
|
+
function isTrustedPeer(request, trustedPeers) {
|
|
232
|
+
const peer = getConnInfo(request)?.remoteAddress;
|
|
233
|
+
if (!peer)
|
|
234
|
+
return false;
|
|
235
|
+
const parsed = parseIp(peer);
|
|
236
|
+
if (!parsed)
|
|
237
|
+
return false;
|
|
238
|
+
return trustedPeers.some((m) => matchesMatcher(parsed, m));
|
|
239
|
+
}
|
|
153
240
|
/**
|
|
154
241
|
* Resolve the client IP from the proxy-set forwarding headers, walking a
|
|
155
242
|
* declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
|
|
@@ -171,17 +258,30 @@ export function resolveForwardedTrust(name, opts) {
|
|
|
171
258
|
* Security note: this resolver is only meaningful when every request reaches
|
|
172
259
|
* the app through a proxy chain you control that appends (or overwrites)
|
|
173
260
|
* these headers. With no proxy in front, any forwarded-header trust is
|
|
174
|
-
* attacker-controlled by definition.
|
|
261
|
+
* attacker-controlled by definition. `trustedPeers` closes that gap at the
|
|
262
|
+
* framework layer: when supplied, the forwarded identity is honoured only
|
|
263
|
+
* when the immediate TCP peer — the socket actually talking to the adapter,
|
|
264
|
+
* which a remote client cannot spoof — is inside the declared proxy
|
|
265
|
+
* allowlist. A direct-to-origin attacker then gets `undefined` (no
|
|
266
|
+
* identity), so spoofed headers can neither frame a victim nor rotate away
|
|
267
|
+
* strikes. When conn metadata is absent (peer-less edge platforms),
|
|
268
|
+
* verification fails closed.
|
|
175
269
|
*
|
|
176
270
|
* @param request - Incoming request whose forwarding headers are read.
|
|
177
271
|
* @param hops - Number of trusted proxy hops; `1` (default) reads the
|
|
178
272
|
* rightmost entry — the one your immediate proxy appended.
|
|
273
|
+
* @param trustedPeers - Optional compiled allowlist from
|
|
274
|
+
* {@link resolveTrustedProxyMatchers}. When supplied, forwarded headers
|
|
275
|
+
* are honoured only if the immediate peer matches; otherwise `undefined`.
|
|
179
276
|
* @returns The resolved client IP, or `undefined` when no forwarded identity
|
|
180
277
|
* is available. Callers decide their own posture for `undefined`
|
|
181
278
|
* (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
|
|
182
279
|
* @since 1.0.0-rc.7
|
|
183
280
|
*/
|
|
184
|
-
export function resolveForwardedClientIp(request, hops = 1) {
|
|
281
|
+
export function resolveForwardedClientIp(request, hops = 1, trustedPeers) {
|
|
282
|
+
if (trustedPeers !== undefined && !isTrustedPeer(request, trustedPeers)) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
185
285
|
const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), hops);
|
|
186
286
|
if (picked)
|
|
187
287
|
return picked;
|
|
@@ -200,6 +300,16 @@ export function resolveForwardedClientIp(request, hops = 1) {
|
|
|
200
300
|
return undefined;
|
|
201
301
|
return request.headers.get("x-real-ip") ?? undefined;
|
|
202
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Compiled-CIDR cache for {@link resolveClientIp}'s `{ cidrs }` branch. Keyed
|
|
305
|
+
* by the config array's identity (stable per `App` instance) so the per-
|
|
306
|
+
* request hot path never re-parses strings — one compile per App, then byte
|
|
307
|
+
* comparisons. `App` construction validates via {@link assertBehindProxy}; the
|
|
308
|
+
* request path still fails closed (returns peer, never trusts XFF) if a
|
|
309
|
+
* direct caller of the exported {@link resolveClientIp} passes unvalidated
|
|
310
|
+
* CIDRs and compilation throws.
|
|
311
|
+
*/
|
|
312
|
+
const behindProxyCidrCache = new WeakMap();
|
|
203
313
|
/**
|
|
204
314
|
* Resolve the client IP for this request using the configured
|
|
205
315
|
* {@link BehindProxyConfig}. Returns `undefined` when no trusted source is
|
|
@@ -231,11 +341,29 @@ export function resolveClientIp(request, cfg) {
|
|
|
231
341
|
const xff = request.headers.get("x-forwarded-for");
|
|
232
342
|
return pickForwardedForByHops(xff, cfg.hops) ?? peer;
|
|
233
343
|
}
|
|
234
|
-
// { cidrs } —
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
344
|
+
// { cidrs } — honour the forwarded identity only when the immediate peer
|
|
345
|
+
// (the socket a remote client cannot spoof) sits inside one of the declared
|
|
346
|
+
// proxy ranges. A direct-to-origin caller gets their real peer address and
|
|
347
|
+
// their spoofed XFF is ignored: no victim-IP framing, no ban evasion.
|
|
348
|
+
let matchers = behindProxyCidrCache.get(cfg.cidrs);
|
|
349
|
+
if (!matchers) {
|
|
350
|
+
try {
|
|
351
|
+
matchers = cfg.cidrs.map(compileCidrMatcher);
|
|
352
|
+
behindProxyCidrCache.set(cfg.cidrs, matchers);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// Unvalidated direct call: never trust XFF on a broken allowlist.
|
|
356
|
+
return peer;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (peer) {
|
|
360
|
+
const parsed = parseIp(peer);
|
|
361
|
+
if (parsed && matchers.some((m) => matchesMatcher(parsed, m))) {
|
|
362
|
+
const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), 1);
|
|
363
|
+
if (picked)
|
|
364
|
+
return picked;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
239
367
|
return peer;
|
|
240
368
|
}
|
|
241
369
|
/**
|
package/dist/fetch-guard.js
CHANGED
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
* @since 0.34.0
|
|
89
89
|
* @module
|
|
90
90
|
*/
|
|
91
|
-
import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-
|
|
91
|
+
import { compileCidrMatcher, matchesMatcher, parseIp, } from "./ip-match.js";
|
|
92
92
|
/**
|
|
93
93
|
* Thrown by {@link fetchGuard} when an outbound request is refused. Never
|
|
94
94
|
* thrown for ordinary network failures — those bubble through unchanged
|
package/dist/geo-block.d.ts
CHANGED
|
@@ -133,6 +133,17 @@ export interface GeoBlockOptions {
|
|
|
133
133
|
* `resolveCountry` is used or a custom `resolveIp` is supplied.
|
|
134
134
|
*/
|
|
135
135
|
trustedHops?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
|
|
138
|
+
* peer's address. Forwarded headers are honoured only when the TCP socket
|
|
139
|
+
* actually talking to the adapter matches the list — the one property a
|
|
140
|
+
* remote client cannot spoof — so a direct-to-origin attacker cannot claim
|
|
141
|
+
* an allowed-country IP. Implies proxy-header trust at one hop unless
|
|
142
|
+
* {@link trustedHops} says otherwise; validated and compiled at
|
|
143
|
+
* construction. On peer-less edge platforms verification fails closed.
|
|
144
|
+
* Ignored when `resolveCountry` is used or a custom `resolveIp` is supplied.
|
|
145
|
+
*/
|
|
146
|
+
trustedProxies?: readonly string[];
|
|
136
147
|
/**
|
|
137
148
|
* What to do when the country cannot be resolved. Defaults to `false` when
|
|
138
149
|
* an `allow` list is configured (fail closed — an unknown country is not on
|
package/dist/geo-block.js
CHANGED
|
@@ -25,7 +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
|
+
import { resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
|
|
29
29
|
/** @internal Validate + normalise a configured country code, or throw. */
|
|
30
30
|
function normalizeConfiguredCode(input) {
|
|
31
31
|
const code = input.trim().toUpperCase();
|
|
@@ -45,8 +45,8 @@ function noIpResolver(_ctx) {
|
|
|
45
45
|
* by the operator's own proxy chain and is therefore the spoof-resistant
|
|
46
46
|
* side — see {@link resolveForwardedClientIp}.
|
|
47
47
|
*/
|
|
48
|
-
function forwardedIpResolver(hops) {
|
|
49
|
-
return (ctx) => resolveForwardedClientIp(ctx.request, hops);
|
|
48
|
+
function forwardedIpResolver(hops, trustedPeers) {
|
|
49
|
+
return (ctx) => resolveForwardedClientIp(ctx.request, hops, trustedPeers);
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Block or allow requests by client country. Daloy ships no GeoIP database;
|
|
@@ -105,7 +105,9 @@ export function geoBlock(opts) {
|
|
|
105
105
|
const lookupCountry = opts.lookupCountry;
|
|
106
106
|
const resolveCountry = opts.resolveCountry;
|
|
107
107
|
const hops = resolveForwardedTrust("geoBlock()", opts);
|
|
108
|
-
const
|
|
108
|
+
const proxyMatchers = resolveTrustedProxyMatchers("geoBlock()", opts);
|
|
109
|
+
const resolveIp = opts.resolveIp ??
|
|
110
|
+
(hops !== undefined ? forwardedIpResolver(hops, proxyMatchers) : noIpResolver);
|
|
109
111
|
return {
|
|
110
112
|
// Runs in `preBody`, not `beforeHandle`. A `beforeHandle` hook that returns
|
|
111
113
|
// a Response ends the chain, so a country gate in that phase is preempted by
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaf IP/CIDR matching primitives shared by network-identity middleware,
|
|
3
|
+
* `fetchGuard()`, and peer-verified proxy trust.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of imports from other framework modules so security-critical
|
|
6
|
+
* consumers (`conn-info`, `ip-restriction`, `fetch-guard`) can share the
|
|
7
|
+
* matcher without circular dependencies.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
* @since 1.1.0
|
|
11
|
+
*/
|
|
12
|
+
/** Parsed IP address (shared with `fetchGuard()` and peer trust). */
|
|
13
|
+
export interface ParsedIp {
|
|
14
|
+
/** Big-endian address bytes: 4 bytes for IPv4, 16 for IPv6. */
|
|
15
|
+
bytes: Uint8Array;
|
|
16
|
+
/** Address family: `4` for IPv4, `6` for IPv6. */
|
|
17
|
+
family: 4 | 6;
|
|
18
|
+
}
|
|
19
|
+
/** Compiled CIDR matcher (shared with `fetchGuard()` and peer trust). */
|
|
20
|
+
export interface IpMatcher {
|
|
21
|
+
/** Address family the matcher applies to: `4` or `6`. */
|
|
22
|
+
family: 4 | 6;
|
|
23
|
+
/** CIDR prefix length in bits (0-32 for IPv4, 0-128 for IPv6). */
|
|
24
|
+
prefix: number;
|
|
25
|
+
/** Network address bytes with all host bits masked to zero. */
|
|
26
|
+
bytes: Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Test whether a parsed IP falls inside a compiled CIDR matcher, comparing
|
|
30
|
+
* only the matcher's prefix bits. IPv4-mapped IPv6 addresses
|
|
31
|
+
* (`::ffff:a.b.c.d`) are normalized so they match IPv4 matchers.
|
|
32
|
+
*
|
|
33
|
+
* @param ip Parsed client address from {@link parseIp}.
|
|
34
|
+
* @param m Compiled matcher from {@link compileCidrMatcher}.
|
|
35
|
+
* @returns `true` when the address is within the matcher's range.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
export declare function matchesMatcher(ip: ParsedIp, m: IpMatcher): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Compile an IP or CIDR pattern (e.g. `"10.0.0.0/8"`, `"::1"`) into an
|
|
41
|
+
* {@link IpMatcher}. A bare address gets a full-length prefix (/32 or /128);
|
|
42
|
+
* host bits beyond the prefix are masked to zero.
|
|
43
|
+
*
|
|
44
|
+
* Error message prefix stays `ipRestriction():` for historical compatibility
|
|
45
|
+
* with callers that match the string (the matcher was born there).
|
|
46
|
+
*
|
|
47
|
+
* @param input IPv4/IPv6 address, optionally with a `/prefix` suffix.
|
|
48
|
+
* @returns The compiled matcher used by {@link matchesMatcher}.
|
|
49
|
+
* @throws Error when the address or CIDR prefix is invalid.
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
export declare function compileCidrMatcher(input: string): IpMatcher;
|
|
53
|
+
/**
|
|
54
|
+
* Parse an IPv4 or IPv6 address string into raw bytes. Supports IPv6 `::`
|
|
55
|
+
* compression and IPv4-mapped tails (`::ffff:1.2.3.4`).
|
|
56
|
+
*
|
|
57
|
+
* @param input Address string; surrounding whitespace is trimmed.
|
|
58
|
+
* @returns The parsed address, or `undefined` when the input is not a valid
|
|
59
|
+
* IP (callers treat unparseable addresses as a rejection, failing closed).
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export declare function parseIp(input: string): ParsedIp | undefined;
|
package/dist/ip-match.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaf IP/CIDR matching primitives shared by network-identity middleware,
|
|
3
|
+
* `fetchGuard()`, and peer-verified proxy trust.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of imports from other framework modules so security-critical
|
|
6
|
+
* consumers (`conn-info`, `ip-restriction`, `fetch-guard`) can share the
|
|
7
|
+
* matcher without circular dependencies.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
* @since 1.1.0
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Test whether a parsed IP falls inside a compiled CIDR matcher, comparing
|
|
14
|
+
* only the matcher's prefix bits. IPv4-mapped IPv6 addresses
|
|
15
|
+
* (`::ffff:a.b.c.d`) are normalized so they match IPv4 matchers.
|
|
16
|
+
*
|
|
17
|
+
* @param ip Parsed client address from {@link parseIp}.
|
|
18
|
+
* @param m Compiled matcher from {@link compileCidrMatcher}.
|
|
19
|
+
* @returns `true` when the address is within the matcher's range.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export function matchesMatcher(ip, m) {
|
|
23
|
+
const candidate = normalizeFamily(ip, m.family);
|
|
24
|
+
if (!candidate)
|
|
25
|
+
return false;
|
|
26
|
+
const expected = m.bytes;
|
|
27
|
+
const totalBits = candidate.length * 8;
|
|
28
|
+
const prefix = Math.min(m.prefix, totalBits);
|
|
29
|
+
const fullBytes = prefix >> 3;
|
|
30
|
+
for (let i = 0; i < fullBytes; i++) {
|
|
31
|
+
if (candidate[i] !== expected[i])
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const remaining = prefix - fullBytes * 8;
|
|
35
|
+
if (remaining === 0)
|
|
36
|
+
return true;
|
|
37
|
+
const mask = 0xff << (8 - remaining);
|
|
38
|
+
return ((candidate[fullBytes] ^ expected[fullBytes]) & mask) === 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compile an IP or CIDR pattern (e.g. `"10.0.0.0/8"`, `"::1"`) into an
|
|
42
|
+
* {@link IpMatcher}. A bare address gets a full-length prefix (/32 or /128);
|
|
43
|
+
* host bits beyond the prefix are masked to zero.
|
|
44
|
+
*
|
|
45
|
+
* Error message prefix stays `ipRestriction():` for historical compatibility
|
|
46
|
+
* with callers that match the string (the matcher was born there).
|
|
47
|
+
*
|
|
48
|
+
* @param input IPv4/IPv6 address, optionally with a `/prefix` suffix.
|
|
49
|
+
* @returns The compiled matcher used by {@link matchesMatcher}.
|
|
50
|
+
* @throws Error when the address or CIDR prefix is invalid.
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
export function compileCidrMatcher(input) {
|
|
54
|
+
let addr = input;
|
|
55
|
+
let prefixStr;
|
|
56
|
+
if (input.includes("/")) {
|
|
57
|
+
const slash = input.indexOf("/");
|
|
58
|
+
addr = input.slice(0, slash);
|
|
59
|
+
prefixStr = input.slice(slash + 1);
|
|
60
|
+
}
|
|
61
|
+
const parsed = parseIp(addr);
|
|
62
|
+
if (!parsed) {
|
|
63
|
+
throw new Error(`ipRestriction(): invalid IP address ${JSON.stringify(input)}.`);
|
|
64
|
+
}
|
|
65
|
+
const totalBits = parsed.family === 4 ? 32 : 128;
|
|
66
|
+
let prefix = totalBits;
|
|
67
|
+
if (prefixStr !== undefined) {
|
|
68
|
+
if (!/^\d+$/.test(prefixStr)) {
|
|
69
|
+
throw new Error(`ipRestriction(): invalid CIDR prefix in ${JSON.stringify(input)}.`);
|
|
70
|
+
}
|
|
71
|
+
prefix = Number.parseInt(prefixStr, 10);
|
|
72
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > totalBits) {
|
|
73
|
+
throw new Error(`ipRestriction(): invalid CIDR prefix in ${JSON.stringify(input)}.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { family: parsed.family, prefix, bytes: applyPrefixMask(parsed.bytes, prefix) };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse an IPv4 or IPv6 address string into raw bytes. Supports IPv6 `::`
|
|
80
|
+
* compression and IPv4-mapped tails (`::ffff:1.2.3.4`).
|
|
81
|
+
*
|
|
82
|
+
* @param input Address string; surrounding whitespace is trimmed.
|
|
83
|
+
* @returns The parsed address, or `undefined` when the input is not a valid
|
|
84
|
+
* IP (callers treat unparseable addresses as a rejection, failing closed).
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
export function parseIp(input) {
|
|
88
|
+
const trimmed = input.trim();
|
|
89
|
+
if (trimmed.includes(":"))
|
|
90
|
+
return parseIPv6(trimmed);
|
|
91
|
+
return parseIPv4(trimmed);
|
|
92
|
+
}
|
|
93
|
+
function normalizeFamily(ip, family) {
|
|
94
|
+
if (ip.family === family)
|
|
95
|
+
return ip.bytes;
|
|
96
|
+
if (ip.family === 6 && family === 4) {
|
|
97
|
+
// IPv4-mapped IPv6 (::ffff:a.b.c.d) — accept as IPv4.
|
|
98
|
+
const b = ip.bytes;
|
|
99
|
+
const isMapped = b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff;
|
|
100
|
+
if (isMapped)
|
|
101
|
+
return b.slice(12);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
function applyPrefixMask(bytes, prefix) {
|
|
107
|
+
const out = new Uint8Array(bytes);
|
|
108
|
+
const fullBytes = prefix >> 3;
|
|
109
|
+
const remaining = prefix - fullBytes * 8;
|
|
110
|
+
if (remaining > 0 && fullBytes < out.length) {
|
|
111
|
+
const mask = 0xff << (8 - remaining);
|
|
112
|
+
out[fullBytes] = out[fullBytes] & mask;
|
|
113
|
+
}
|
|
114
|
+
for (let i = fullBytes + (remaining > 0 ? 1 : 0); i < out.length; i++) {
|
|
115
|
+
out[i] = 0;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function parseIPv4(input) {
|
|
120
|
+
const parts = input.split(".");
|
|
121
|
+
if (parts.length !== 4)
|
|
122
|
+
return undefined;
|
|
123
|
+
const bytes = new Uint8Array(4);
|
|
124
|
+
for (let i = 0; i < 4; i++) {
|
|
125
|
+
const part = parts[i];
|
|
126
|
+
if (!/^\d{1,3}$/.test(part))
|
|
127
|
+
return undefined;
|
|
128
|
+
const n = Number.parseInt(part, 10);
|
|
129
|
+
if (n < 0 || n > 255)
|
|
130
|
+
return undefined;
|
|
131
|
+
bytes[i] = n;
|
|
132
|
+
}
|
|
133
|
+
return { bytes, family: 4 };
|
|
134
|
+
}
|
|
135
|
+
function parseIPv6(input) {
|
|
136
|
+
// Support IPv4-mapped tail (::ffff:1.2.3.4).
|
|
137
|
+
let working = input;
|
|
138
|
+
const lastColon = working.lastIndexOf(":");
|
|
139
|
+
if (lastColon !== -1 && working.slice(lastColon + 1).includes(".")) {
|
|
140
|
+
const v4 = parseIPv4(working.slice(lastColon + 1));
|
|
141
|
+
if (!v4)
|
|
142
|
+
return undefined;
|
|
143
|
+
const hi = (v4.bytes[0] << 8) | v4.bytes[1];
|
|
144
|
+
const lo = (v4.bytes[2] << 8) | v4.bytes[3];
|
|
145
|
+
working = working.slice(0, lastColon + 1) + hi.toString(16) + ":" + lo.toString(16);
|
|
146
|
+
}
|
|
147
|
+
const parts = working.split("::");
|
|
148
|
+
if (parts.length > 2)
|
|
149
|
+
return undefined;
|
|
150
|
+
const headGroups = parts[0] === "" ? [] : parts[0].split(":");
|
|
151
|
+
const tailGroups = parts.length === 2 && parts[1] !== "" ? parts[1].split(":") : [];
|
|
152
|
+
const explicit = headGroups.length + tailGroups.length;
|
|
153
|
+
if (explicit > 8)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (parts.length === 1 && explicit !== 8)
|
|
156
|
+
return undefined;
|
|
157
|
+
const missing = parts.length === 2 ? 8 - explicit : 0;
|
|
158
|
+
const groups = [...headGroups, ...Array.from({ length: missing }, () => "0"), ...tailGroups];
|
|
159
|
+
if (groups.length !== 8)
|
|
160
|
+
return undefined;
|
|
161
|
+
const bytes = new Uint8Array(16);
|
|
162
|
+
for (let index = 0; index < 8; index++) {
|
|
163
|
+
const group = groups[index];
|
|
164
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group))
|
|
165
|
+
return undefined;
|
|
166
|
+
const n = Number.parseInt(group, 16);
|
|
167
|
+
bytes[index * 2] = (n >> 8) & 0xff;
|
|
168
|
+
bytes[index * 2 + 1] = n & 0xff;
|
|
169
|
+
}
|
|
170
|
+
return { bytes, family: 6 };
|
|
171
|
+
}
|
package/dist/ip-reputation.d.ts
CHANGED
|
@@ -125,6 +125,16 @@ export interface IpReputationOptions {
|
|
|
125
125
|
* [1, 64]; validated at construction.
|
|
126
126
|
*/
|
|
127
127
|
trustedHops?: number;
|
|
128
|
+
/**
|
|
129
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
|
|
130
|
+
* peer's address. Forwarded headers are honoured only when the TCP socket
|
|
131
|
+
* actually talking to the adapter matches the list, so a direct-to-origin
|
|
132
|
+
* attacker cannot dodge the denylist with a spoofed XFF. Implies
|
|
133
|
+
* proxy-header trust at one hop unless {@link trustedHops} says otherwise;
|
|
134
|
+
* validated and compiled at construction. On peer-less edge platforms
|
|
135
|
+
* verification fails closed (forwarded identity ignored).
|
|
136
|
+
*/
|
|
137
|
+
trustedProxies?: readonly string[];
|
|
128
138
|
/**
|
|
129
139
|
* `"block"` (default) throws a {@link ForbiddenError} on a match; `"log"`
|
|
130
140
|
* only invokes {@link IpReputationOptions.onMatch} and lets the request
|
package/dist/ip-reputation.js
CHANGED
|
@@ -46,8 +46,8 @@
|
|
|
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";
|
|
50
|
-
import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-
|
|
49
|
+
import { resolveForwardedClientIp, resolveForwardedTrust, resolveTrustedProxyMatchers, } from "./conn-info.js";
|
|
50
|
+
import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-match.js";
|
|
51
51
|
const DEFAULT_REFRESH_MS = 60 * 60_000;
|
|
52
52
|
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
53
53
|
const DEFAULT_MESSAGE = "IP address not permitted";
|
|
@@ -119,8 +119,8 @@ export function urlFeed(url, opts = {}) {
|
|
|
119
119
|
* `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
|
|
120
120
|
* of the header; see {@link resolveForwardedClientIp}.
|
|
121
121
|
*/
|
|
122
|
-
function forwardedIpResolver(hops) {
|
|
123
|
-
return (ctx) => resolveForwardedClientIp(ctx.request, hops);
|
|
122
|
+
function forwardedIpResolver(hops, trustedPeers) {
|
|
123
|
+
return (ctx) => resolveForwardedClientIp(ctx.request, hops, trustedPeers);
|
|
124
124
|
}
|
|
125
125
|
function noIpResolver(_ctx) {
|
|
126
126
|
return undefined;
|
|
@@ -158,7 +158,9 @@ export function ipReputation(opts) {
|
|
|
158
158
|
}
|
|
159
159
|
const message = opts.message ?? DEFAULT_MESSAGE;
|
|
160
160
|
const hops = resolveForwardedTrust("ipReputation()", opts);
|
|
161
|
-
const
|
|
161
|
+
const proxyMatchers = resolveTrustedProxyMatchers("ipReputation()", opts);
|
|
162
|
+
const resolveIp = opts.resolveIp ??
|
|
163
|
+
(hops !== undefined ? forwardedIpResolver(hops, proxyMatchers) : noIpResolver);
|
|
162
164
|
// Last-known-good compiled denylist, one entry per feed so a single feed's
|
|
163
165
|
// failed refresh doesn't drop the others.
|
|
164
166
|
let compiled = opts.feeds.map((f) => ({ name: f.name, v4: [], v6: [] }));
|
package/dist/ip-restriction.d.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @since 0.19.0
|
|
9
9
|
*/
|
|
10
10
|
import type { Hooks, IdentityGateContext } from "./types.js";
|
|
11
|
+
export type { IpMatcher, ParsedIp } from "./ip-match.js";
|
|
12
|
+
export { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-match.js";
|
|
11
13
|
/**
|
|
12
14
|
* Options for {@link ipRestriction}. At least one of `allow` or `deny` must
|
|
13
15
|
* be provided; supplying both runs deny-first then allow-otherwise (deny
|
|
@@ -58,6 +60,17 @@ export interface IpRestrictionOptions {
|
|
|
58
60
|
* [1, 64]; validated at construction.
|
|
59
61
|
*/
|
|
60
62
|
trustedHops?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Declare WHICH proxies are yours: an IP/CIDR allowlist for the immediate
|
|
65
|
+
* peer's address. Forwarded headers are honoured only when the TCP socket
|
|
66
|
+
* actually talking to the adapter matches the list, so a direct-to-origin
|
|
67
|
+
* attacker cannot spoof an allow-listed IP or dodge a deny entry. Implies
|
|
68
|
+
* proxy-header trust at one hop unless {@link trustedHops} says otherwise;
|
|
69
|
+
* validated and compiled at construction. On peer-less edge platforms
|
|
70
|
+
* verification fails closed (the request is rejected, since no trustworthy
|
|
71
|
+
* IP remains).
|
|
72
|
+
*/
|
|
73
|
+
trustedProxies?: readonly string[];
|
|
61
74
|
/**
|
|
62
75
|
* Response message when a request is rejected. Defaults to
|
|
63
76
|
* `"IP address not permitted"`. Avoid echoing the client IP back —
|
|
@@ -65,22 +78,6 @@ export interface IpRestrictionOptions {
|
|
|
65
78
|
*/
|
|
66
79
|
message?: string;
|
|
67
80
|
}
|
|
68
|
-
/** @internal Parsed IP address (shared with `fetchGuard()`). */
|
|
69
|
-
export interface ParsedIp {
|
|
70
|
-
/** Big-endian address bytes: 4 bytes for IPv4, 16 for IPv6. */
|
|
71
|
-
bytes: Uint8Array;
|
|
72
|
-
/** Address family: `4` for IPv4, `6` for IPv6. */
|
|
73
|
-
family: 4 | 6;
|
|
74
|
-
}
|
|
75
|
-
/** @internal Compiled CIDR matcher (shared with `fetchGuard()`). */
|
|
76
|
-
export interface IpMatcher {
|
|
77
|
-
/** Address family the matcher applies to: `4` or `6`. */
|
|
78
|
-
family: 4 | 6;
|
|
79
|
-
/** CIDR prefix length in bits (0-32 for IPv4, 0-128 for IPv6). */
|
|
80
|
-
prefix: number;
|
|
81
|
-
/** Network address bytes with all host bits masked to zero. */
|
|
82
|
-
bytes: Uint8Array;
|
|
83
|
-
}
|
|
84
81
|
/**
|
|
85
82
|
* Block or allow requests by source IP / CIDR range. In direct Web-standard
|
|
86
83
|
* runtimes, pass `resolveIp` from the adapter-specific connection metadata.
|
|
@@ -108,35 +105,3 @@ export interface IpMatcher {
|
|
|
108
105
|
* @since 0.19.0
|
|
109
106
|
*/
|
|
110
107
|
export declare function ipRestriction(opts: IpRestrictionOptions): Hooks;
|
|
111
|
-
/**
|
|
112
|
-
* Test whether a parsed IP falls inside a compiled CIDR matcher, comparing
|
|
113
|
-
* only the matcher's prefix bits. IPv4-mapped IPv6 addresses
|
|
114
|
-
* (`::ffff:a.b.c.d`) are normalized so they match IPv4 matchers.
|
|
115
|
-
*
|
|
116
|
-
* @param ip Parsed client address from {@link parseIp}.
|
|
117
|
-
* @param m Compiled matcher from {@link compileCidrMatcher}.
|
|
118
|
-
* @returns `true` when the address is within the matcher's range.
|
|
119
|
-
* @internal
|
|
120
|
-
*/
|
|
121
|
-
export declare function matchesMatcher(ip: ParsedIp, m: IpMatcher): boolean;
|
|
122
|
-
/**
|
|
123
|
-
* Compile an IP or CIDR pattern (e.g. `"10.0.0.0/8"`, `"::1"`) into an
|
|
124
|
-
* {@link IpMatcher}. A bare address gets a full-length prefix (/32 or /128);
|
|
125
|
-
* host bits beyond the prefix are masked to zero.
|
|
126
|
-
*
|
|
127
|
-
* @param input IPv4/IPv6 address, optionally with a `/prefix` suffix.
|
|
128
|
-
* @returns The compiled matcher used by {@link matchesMatcher}.
|
|
129
|
-
* @throws Error when the address or CIDR prefix is invalid.
|
|
130
|
-
* @internal
|
|
131
|
-
*/
|
|
132
|
-
export declare function compileCidrMatcher(input: string): IpMatcher;
|
|
133
|
-
/**
|
|
134
|
-
* Parse an IPv4 or IPv6 address string into raw bytes. Supports IPv6 `::`
|
|
135
|
-
* compression and IPv4-mapped tails (`::ffff:1.2.3.4`).
|
|
136
|
-
*
|
|
137
|
-
* @param input Address string; surrounding whitespace is trimmed.
|
|
138
|
-
* @returns The parsed address, or `undefined` when the input is not a valid
|
|
139
|
-
* IP (callers treat unparseable addresses as a rejection, failing closed).
|
|
140
|
-
* @internal
|
|
141
|
-
*/
|
|
142
|
-
export declare function parseIp(input: string): ParsedIp | undefined;
|