@daloyjs/core 1.0.0 → 1.1.1

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/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 two trust fields are
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, 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.
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. That
134
- * pairing is a contradiction, and it previously resolved silently in favour
135
- * of trust — meaning an explicit opt-out was ignored.
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 } — out of scope for the trim implementation; falls back to peer.
235
- // The CIDR matcher is reused from src/ip-restriction.ts; consumers that
236
- // need the full check can compose ipRestriction({ allow: cfg.cidrs }) into
237
- // the resolver. We honour the header only if the peer matches one of the
238
- // declared CIDRs.
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
  /**
@@ -88,7 +88,7 @@
88
88
  * @since 0.34.0
89
89
  * @module
90
90
  */
91
- import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-restriction.js";
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
@@ -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 resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
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;
@@ -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
+ }
@@ -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
@@ -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-restriction.js";
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 resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
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: [] }));
@@ -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;