@sprqvntrs/bot-verify 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 SPRQVNTRS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @sprqvntrs/bot-verify
2
+
3
+ Verify search-engine crawlers (Googlebot and the wider Google crawler family) against their **officially published IP ranges** and **reverse DNS**, and detect requests that *spoof* a crawler User-Agent from non-crawler IPs.
4
+
5
+ Ships as raw TypeScript (bundled by the consuming app). Framework-agnostic core plus an optional React Router 7 middleware adapter.
6
+
7
+ ## Why
8
+
9
+ Bots set `User-Agent: Googlebot` to bypass bot protection while originating from IPs that aren't Google's. This package answers, authoritatively, "is this request really from Google?" — and is built so it will **never** classify a *real* Google crawler as spoofed. When it can't be sure, it returns `uncertain` (callers treat that as pass-through).
10
+
11
+ ## Verdicts
12
+
13
+ `verify({ userAgent, ip })` resolves to one of:
14
+
15
+ | verdict | meaning | typical caller action |
16
+ |---|---|---|
17
+ | `not-a-claim` | UA doesn't claim a Google crawler | pass through |
18
+ | `verified` | claims a crawler **and** IP is in Google's ranges (or rDNS-confirmed) | pass through |
19
+ | `spoofed` | claims a crawler but IP is **not** Google's and rDNS does not confirm | block / log / ban |
20
+ | `uncertain` | claims a crawler but client IP unknown, or rDNS inconclusive (DNS error) | **pass through** (never block) |
21
+
22
+ Decision order: UA claim → valid client IP → IP in published ranges → reverse-DNS forward-confirm. Ranges are seeded from a bundled snapshot (so the store is never empty) and refreshed from Google daily; a failed refresh keeps the last-good list (fail-open).
23
+
24
+ ## Core usage
25
+
26
+ ```ts
27
+ import { createBotVerifier } from '@sprqvntrs/bot-verify';
28
+
29
+ const verifier = createBotVerifier({
30
+ rdns: true, // reverse-DNS confirm on a range miss (default true)
31
+ rdnsTimeoutMs: 1500,
32
+ logger: (r) => myLogger.debug('bot-verify', r),
33
+ });
34
+
35
+ const result = await verifier.verify({ userAgent, ip });
36
+ if (result.verdict === 'spoofed') { /* ... */ }
37
+ ```
38
+
39
+ Other exports: `detectClaimedCrawler`, `getClientIp`, `ipInCidr` / `ipInAnyCidr` / `normalizeIp`, `RangeStore`, `GOOGLE_RANGE_URLS`, `reverseDnsVerify`.
40
+
41
+ ## React Router 7 middleware
42
+
43
+ `react-router` is an optional peer dependency; only this subpath imports it.
44
+
45
+ ```ts
46
+ // app/root.tsx
47
+ import { createVerifiedBotMiddleware } from '@sprqvntrs/bot-verify/react-router';
48
+
49
+ export const middleware = [
50
+ createVerifiedBotMiddleware({
51
+ mode: process.env.BOT_VERIFY_MODE === 'enforce' ? 'enforce' : 'monitor', // default monitor
52
+ clientIp: { trustedHeader: 'x-real-ip' }, // trust a header your edge proxy overwrites
53
+ onSpoof: (e) => {
54
+ // e.userAgent is ATTACKER-CONTROLLED — strip CR/LF before logging to any parser
55
+ const ua = e.userAgent.replace(/[\r\n]/g, ' ');
56
+ logger.warn(`SPOOFED_GOOGLEBOT ip=${e.ip} bot=${e.claimedBot} path=${e.path} ua="${ua}"`);
57
+ },
58
+ }),
59
+ // ...other middleware
60
+ ];
61
+ ```
62
+
63
+ - `mode: 'monitor'` (default) logs via `onSpoof` and lets the request through — use this first to confirm zero false positives.
64
+ - `mode: 'enforce'` returns `403 Forbidden` for spoofed requests.
65
+
66
+ ### Client IP — read this
67
+
68
+ The leftmost `X-Forwarded-For` entry is attacker-controlled and is **never** trusted by default. Configure one of:
69
+
70
+ - `clientIp: { trustedHeader: 'x-real-ip' }` — when a proxy you control (e.g. nginx with `real_ip_header`) **overwrites** that header with the true client IP. Preferred.
71
+ - `clientIp: { xffTrustedProxyCount: N }` — trust `N` proxies counted from the **right** of `X-Forwarded-For`.
72
+
73
+ If the client IP can't be resolved, `verify` returns `uncertain` (never blocks).
74
+
75
+ ### Banning via CrowdSec (note)
76
+
77
+ This package detects and (optionally) returns 403 in-app. For durable firewall-level bans the recommended pattern is to keep enforcement in your IDS: emit a structured `onSpoof` log line and let CrowdSec own remediation — and remember `userAgent` is untrusted (strip CR/LF) and the **ban key must be `event.ip`** (the verifier-derived IP), never anything parsed out of the UA.
78
+
79
+ ## Tests
80
+
81
+ ```sh
82
+ pnpm --filter @sprqvntrs/bot-verify test
83
+ ```
84
+
85
+ ## Raw TypeScript
86
+
87
+ This package ships raw TypeScript (`main` and `types` point at `index.ts`), so a Vite
88
+ consumer (Vite, React Router, Remix) must add the scope to `ssr.noExternal`:
89
+ `ssr: { noExternal: [/^@sprqvntrs\//] }`.
90
+
91
+ ## License
92
+
93
+ MIT
package/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @sprqvntrs/bot-verify
3
+ *
4
+ * Detect spoofed search-engine crawlers by verifying the source IP against
5
+ * Google's published CIDR ranges and/or reverse-DNS.
6
+ *
7
+ * Framework-agnostic core. The React Router 7 middleware adapter is available
8
+ * at the `@sprqvntrs/bot-verify/react-router` subpath.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ export type { BotVerdict, VerifyInput, VerifyResult } from './src/types.js';
18
+
19
+ // =============================================================================
20
+ // User-Agent detection
21
+ // =============================================================================
22
+
23
+ export { detectClaimedCrawler, GOOGLE_CRAWLER_TOKENS } from './src/ua.js';
24
+ export type { GoogleCrawlerToken } from './src/ua.js';
25
+
26
+ // =============================================================================
27
+ // CIDR / IP utilities
28
+ // =============================================================================
29
+
30
+ export { ipInCidr, ipInAnyCidr, normalizeIp } from './src/cidr.js';
31
+
32
+ // =============================================================================
33
+ // IP range store
34
+ // =============================================================================
35
+
36
+ export { parsePrefixes, RangeStore, GOOGLE_RANGE_URLS } from './src/ranges.js';
37
+ export type { RangeStoreOptions } from './src/ranges.js';
38
+
39
+ // =============================================================================
40
+ // Reverse-DNS verification
41
+ // =============================================================================
42
+
43
+ export { reverseDnsVerify } from './src/rdns.js';
44
+ export type { ReverseDnsOptions } from './src/rdns.js';
45
+
46
+ // =============================================================================
47
+ // Core verifier
48
+ // =============================================================================
49
+
50
+ export { createBotVerifier } from './src/verify.js';
51
+ export type { BotVerifier, BotVerifierOptions } from './src/verify.js';
52
+
53
+ // =============================================================================
54
+ // Client IP extraction
55
+ // =============================================================================
56
+
57
+ export { getClientIp } from './src/client-ip.js';
58
+ export type { ClientIpOptions } from './src/client-ip.js';
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@sprqvntrs/bot-verify",
3
+ "version": "0.1.1",
4
+ "description": "Verify search-engine crawlers (Googlebot et al.) against their published IP ranges + reverse-DNS, and detect spoofed bots. Framework-agnostic core plus a React Router 7 middleware adapter.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "exports": {
9
+ ".": "./index.ts",
10
+ "./react-router": "./react-router/index.ts",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/SPRQVNTRS/platform.git",
17
+ "directory": "packages/bot-verify"
18
+ },
19
+ "files": [
20
+ "src/**/*",
21
+ "react-router/**/*",
22
+ "index.ts",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "dependencies": {
31
+ "ipaddr.js": "^2.2.0"
32
+ },
33
+ "peerDependencies": {
34
+ "react-router": ">=7.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "react-router": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "react-router": "^7.9.5",
44
+ "typescript": "^5.6.0",
45
+ "vitest": "^3.2.4"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "homepage": "https://github.com/SPRQVNTRS/platform/tree/main/packages/bot-verify#readme",
51
+ "bugs": {
52
+ "url": "https://github.com/SPRQVNTRS/platform/issues"
53
+ }
54
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * React Router 7 middleware adapter for `@sprqvntrs/bot-verify`.
3
+ *
4
+ * This is the ONLY file in this package that imports from `react-router`.
5
+ * All other modules are framework-agnostic.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { MiddlewareFunction } from 'react-router';
11
+ import { getClientIp } from '../src/client-ip.js';
12
+ import { createBotVerifier } from '../src/verify.js';
13
+ import type { BotVerifier, BotVerifierOptions } from '../src/verify.js';
14
+ import type { ClientIpOptions } from '../src/client-ip.js';
15
+
16
+ export type { BotVerifier, BotVerifierOptions } from '../src/verify.js';
17
+ export type { ClientIpOptions } from '../src/client-ip.js';
18
+
19
+ /** How the middleware should respond when it detects a spoofed bot. */
20
+ export type EnforcementMode = 'monitor' | 'enforce';
21
+
22
+ /**
23
+ * Structured event emitted when a spoofed bot is detected.
24
+ *
25
+ * SECURITY NOTE: `userAgent` is attacker-controlled. Before writing it to any
26
+ * log line that a downstream parser (e.g. CrowdSec, Fail2Ban, Splunk) consumes,
27
+ * you MUST sanitize it — strip CR (`\r`) and LF (`\n`) characters to prevent
28
+ * log-injection attacks. The `ip` field is derived from the verifier-resolved
29
+ * client IP and is safe to use directly as a ban key.
30
+ */
31
+ export interface SpoofEvent {
32
+ /** Verifier-derived client IP (safe to use as a ban key). */
33
+ ip: string | null;
34
+ /**
35
+ * Raw User-Agent value — ATTACKER-CONTROLLED. Strip CR/LF before logging
36
+ * to any structured log parser.
37
+ */
38
+ userAgent: string;
39
+ /** The Google crawler token that was falsely claimed. */
40
+ claimedBot: string | null;
41
+ /** URL pathname of the spoofed request. */
42
+ path: string;
43
+ /** HTTP method of the spoofed request (GET, POST, …). */
44
+ httpMethod: string;
45
+ /** How the spoof verdict was reached. */
46
+ detectionMethod: 'ip-range' | 'rdns' | 'none';
47
+ /** Enforcement mode active when the spoof was detected. */
48
+ mode: EnforcementMode;
49
+ }
50
+
51
+ /** Options for {@link createVerifiedBotMiddleware}. */
52
+ export interface VerifiedBotMiddlewareOptions {
53
+ /**
54
+ * Whether to block spoofed requests or merely observe them.
55
+ * - `'monitor'` (default): logs the event, lets the request through
56
+ * - `'enforce'`: returns a 403 Forbidden response
57
+ */
58
+ mode?: EnforcementMode;
59
+ /** Options passed to the client IP extractor. */
60
+ clientIp?: ClientIpOptions;
61
+ /**
62
+ * Pre-created verifier instance. Takes precedence over `verifierOptions`.
63
+ * Useful for sharing a single verifier across multiple middleware instances.
64
+ */
65
+ verifier?: BotVerifier;
66
+ /**
67
+ * Options passed to {@link createBotVerifier} when no `verifier` is supplied.
68
+ */
69
+ verifierOptions?: BotVerifierOptions;
70
+ /**
71
+ * Called when a spoofed bot is detected, in both `monitor` and `enforce` modes.
72
+ * Errors thrown by this callback are swallowed to prevent spoofed traffic from
73
+ * crashing legitimate request handling.
74
+ *
75
+ * The `event.userAgent` field is ATTACKER-CONTROLLED — sanitize it (strip
76
+ * CR/LF) before passing it to any structured log parser.
77
+ */
78
+ onSpoof?: (event: SpoofEvent) => void;
79
+ /**
80
+ * Factory for the 403 response returned in `enforce` mode.
81
+ * Defaults to `new Response('Forbidden', { status: 403 })`.
82
+ */
83
+ forbiddenResponse?: () => Response;
84
+ /**
85
+ * Optional pre-filter. When provided and returns `false`, the middleware
86
+ * skips bot verification entirely and calls `next()`. Useful for excluding
87
+ * static assets, health-check endpoints, etc.
88
+ */
89
+ shouldCheck?: (request: Request) => boolean;
90
+ }
91
+
92
+ /**
93
+ * Creates a React Router 7 middleware that detects and optionally blocks
94
+ * spoofed Google crawler requests.
95
+ *
96
+ * Usage in a route file:
97
+ * ```typescript
98
+ * import { createVerifiedBotMiddleware } from '@sprqvntrs/bot-verify/react-router';
99
+ *
100
+ * export const middleware = [
101
+ * createVerifiedBotMiddleware({ mode: 'enforce', onSpoof: (e) => console.warn(e) }),
102
+ * ];
103
+ * ```
104
+ *
105
+ * @param opts - Middleware configuration
106
+ */
107
+ export function createVerifiedBotMiddleware(
108
+ opts?: VerifiedBotMiddlewareOptions,
109
+ ): MiddlewareFunction {
110
+ const mode = opts?.mode ?? 'monitor';
111
+ const verifier = opts?.verifier ?? createBotVerifier(opts?.verifierOptions);
112
+ const forbiddenResponse = opts?.forbiddenResponse ?? (() => new Response('Forbidden', { status: 403 }));
113
+ const onSpoof = opts?.onSpoof;
114
+ const shouldCheck = opts?.shouldCheck;
115
+ const clientIpOpts = opts?.clientIp;
116
+
117
+ return async (args, next) => {
118
+ const { request } = args;
119
+
120
+ // Pre-filter: skip verification if caller says so
121
+ if (shouldCheck && !shouldCheck(request)) {
122
+ return next();
123
+ }
124
+
125
+ const userAgent = request.headers.get('user-agent') ?? '';
126
+ const ip = getClientIp(request.headers, clientIpOpts);
127
+
128
+ const result = await verifier.verify({ userAgent, ip });
129
+
130
+ if (result.verdict !== 'spoofed') {
131
+ return next();
132
+ }
133
+
134
+ const url = new URL(request.url);
135
+ const event: SpoofEvent = {
136
+ ip: result.ip,
137
+ userAgent,
138
+ claimedBot: result.claimedBot,
139
+ path: url.pathname,
140
+ httpMethod: request.method,
141
+ detectionMethod: result.method,
142
+ mode,
143
+ };
144
+
145
+ if (onSpoof) {
146
+ try {
147
+ onSpoof(event);
148
+ } catch {
149
+ // Swallow logger errors — do not let spoofed traffic crash request handling
150
+ }
151
+ }
152
+
153
+ if (mode === 'enforce') {
154
+ return forbiddenResponse();
155
+ }
156
+
157
+ return next();
158
+ };
159
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ipInCidr, ipInAnyCidr, normalizeIp } from '../cidr.js';
3
+
4
+ describe('ipInCidr', () => {
5
+ describe('IPv4', () => {
6
+ it('matches an IP inside a /19 block', () => {
7
+ // 192.178.4.0/27 — IPs .0 through .31 are in range
8
+ expect(ipInCidr('192.178.4.1', '192.178.4.0/27')).toBe(true);
9
+ });
10
+
11
+ it('does not match an IP outside a /27 block', () => {
12
+ // .32 is the start of the next /27
13
+ expect(ipInCidr('192.178.4.32', '192.178.4.0/27')).toBe(false);
14
+ });
15
+
16
+ it('matches the network address itself', () => {
17
+ expect(ipInCidr('66.249.64.0', '66.249.64.0/19')).toBe(true);
18
+ });
19
+
20
+ it('does not match an IP from a different /19', () => {
21
+ expect(ipInCidr('66.249.96.1', '66.249.64.0/19')).toBe(false);
22
+ });
23
+ });
24
+
25
+ describe('IPv6', () => {
26
+ // From googlebot.json
27
+ const v6Cidr = '2001:4860:4801:10::/64';
28
+
29
+ it('matches a v6 address inside the prefix', () => {
30
+ expect(ipInCidr('2001:4860:4801:10::1', v6Cidr)).toBe(true);
31
+ });
32
+
33
+ it('does not match a v6 address outside the prefix', () => {
34
+ expect(ipInCidr('2001:4860:4801:11::1', v6Cidr)).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe('v4-mapped in v6', () => {
39
+ it('matches an unwrapped v4-mapped address against an IPv4 CIDR', () => {
40
+ // ::ffff:66.249.64.1 should unwrap to 66.249.64.1
41
+ expect(ipInCidr('::ffff:66.249.64.1', '66.249.64.0/19')).toBe(true);
42
+ });
43
+
44
+ it('does not match a v6 CIDR when the IP unwraps to v4', () => {
45
+ // After unwrapping to v4, kinds differ → false
46
+ expect(ipInCidr('::ffff:66.249.64.1', '2001:4860:4801:10::/64')).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('malformed input', () => {
51
+ it('returns false for an invalid IP', () => {
52
+ expect(ipInCidr('not-an-ip', '66.249.64.0/19')).toBe(false);
53
+ });
54
+
55
+ it('returns false for an invalid CIDR', () => {
56
+ expect(ipInCidr('66.249.64.1', 'not-a-cidr')).toBe(false);
57
+ });
58
+
59
+ it('returns false for empty strings', () => {
60
+ expect(ipInCidr('', '')).toBe(false);
61
+ });
62
+ });
63
+ });
64
+
65
+ describe('ipInAnyCidr', () => {
66
+ it('returns true when IP matches at least one CIDR', () => {
67
+ const cidrs = ['10.0.0.0/8', '192.178.4.0/27'];
68
+ expect(ipInAnyCidr('192.178.4.5', cidrs)).toBe(true);
69
+ });
70
+
71
+ it('returns false when IP does not match any CIDR', () => {
72
+ const cidrs = ['10.0.0.0/8', '192.178.4.0/27'];
73
+ expect(ipInAnyCidr('1.2.3.4', cidrs)).toBe(false);
74
+ });
75
+
76
+ it('returns false for empty CIDR list', () => {
77
+ expect(ipInAnyCidr('66.249.64.1', [])).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe('normalizeIp', () => {
82
+ it('normalizes a plain IPv4 address', () => {
83
+ expect(normalizeIp('66.249.64.1')).toBe('66.249.64.1');
84
+ });
85
+
86
+ it('normalizes an IPv6 address to lowercase', () => {
87
+ const result = normalizeIp('2001:4860:4801:0010::0001');
88
+ expect(result).toBe('2001:4860:4801:10::1');
89
+ });
90
+
91
+ it('unwraps v4-mapped IPv6 to plain IPv4', () => {
92
+ expect(normalizeIp('::ffff:66.249.64.1')).toBe('66.249.64.1');
93
+ });
94
+
95
+ it('unwraps v4-mapped IPv6 in hex form', () => {
96
+ // ::ffff:42f9:4001 = ::ffff:66.249.64.1
97
+ expect(normalizeIp('::ffff:42f9:4001')).toBe('66.249.64.1');
98
+ });
99
+
100
+ it('returns null for a hostname', () => {
101
+ expect(normalizeIp('googlebot.com')).toBeNull();
102
+ });
103
+
104
+ it('returns null for an empty string', () => {
105
+ expect(normalizeIp('')).toBeNull();
106
+ });
107
+
108
+ it('returns null for junk input', () => {
109
+ expect(normalizeIp('not.an.ip.address')).toBeNull();
110
+ });
111
+
112
+ it('handles bracketed IPv6 (strips brackets)', () => {
113
+ expect(normalizeIp('[::1]')).toBe('::1');
114
+ });
115
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getClientIp } from '../client-ip.js';
3
+
4
+ function makeHeaders(entries: Record<string, string>): Headers {
5
+ return new Headers(entries);
6
+ }
7
+
8
+ describe('getClientIp', () => {
9
+ describe('trustedHeader', () => {
10
+ it('returns the trusted header value when present and valid', () => {
11
+ const headers = makeHeaders({
12
+ 'x-real-ip': '1.2.3.4',
13
+ 'x-forwarded-for': '5.6.7.8',
14
+ });
15
+ expect(getClientIp(headers, { trustedHeader: 'x-real-ip' })).toBe('1.2.3.4');
16
+ });
17
+
18
+ it('falls back to XFF when trusted header is missing', () => {
19
+ const headers = makeHeaders({ 'x-forwarded-for': '5.6.7.8' });
20
+ expect(getClientIp(headers, { trustedHeader: 'x-real-ip' })).toBe('5.6.7.8');
21
+ });
22
+
23
+ it('falls back to XFF when trusted header contains invalid IP', () => {
24
+ const headers = makeHeaders({
25
+ 'x-real-ip': 'not-an-ip',
26
+ 'x-forwarded-for': '5.6.7.8',
27
+ });
28
+ expect(getClientIp(headers, { trustedHeader: 'x-real-ip' })).toBe('5.6.7.8');
29
+ });
30
+ });
31
+
32
+ describe('XFF with xffTrustedProxyCount', () => {
33
+ it('returns rightmost entry with count 0 (default)', () => {
34
+ const headers = makeHeaders({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' });
35
+ expect(getClientIp(headers, { xffTrustedProxyCount: 0 })).toBe('5.6.7.8');
36
+ });
37
+
38
+ it('returns second-from-right with count 1', () => {
39
+ const headers = makeHeaders({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' });
40
+ expect(getClientIp(headers, { xffTrustedProxyCount: 1 })).toBe('1.2.3.4');
41
+ });
42
+
43
+ it('handles three entries with count 1', () => {
44
+ const headers = makeHeaders({
45
+ 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3',
46
+ });
47
+ expect(getClientIp(headers, { xffTrustedProxyCount: 1 })).toBe('2.2.2.2');
48
+ });
49
+ });
50
+
51
+ describe('SECURITY: forged leftmost entry', () => {
52
+ it('does NOT return a forged Google IP at position 0 when count is 0', () => {
53
+ // Attacker forges "66.249.79.2" as leftmost to impersonate Googlebot.
54
+ // Real edge proxy appended "9.9.9.9". Count=0 → rightmost → 9.9.9.9.
55
+ const headers = makeHeaders({
56
+ 'x-forwarded-for': '66.249.79.2, 9.9.9.9',
57
+ });
58
+ const ip = getClientIp(headers, { xffTrustedProxyCount: 0 });
59
+ expect(ip).toBe('9.9.9.9');
60
+ expect(ip).not.toBe('66.249.79.2');
61
+ });
62
+
63
+ it('returns the forged IP only when count explicitly trusts it', () => {
64
+ // With count=1 we say proxy 9.9.9.9 is trusted and look one step left.
65
+ // In a real setup you'd only do this if you KNOW 9.9.9.9 is your proxy.
66
+ const headers = makeHeaders({
67
+ 'x-forwarded-for': '66.249.79.2, 9.9.9.9',
68
+ });
69
+ const ip = getClientIp(headers, { xffTrustedProxyCount: 1 });
70
+ expect(ip).toBe('66.249.79.2');
71
+ });
72
+ });
73
+
74
+ describe('edge cases', () => {
75
+ it('returns null when no XFF header', () => {
76
+ const headers = makeHeaders({});
77
+ expect(getClientIp(headers)).toBeNull();
78
+ });
79
+
80
+ it('returns null for an invalid IP in XFF', () => {
81
+ const headers = makeHeaders({ 'x-forwarded-for': 'not-an-ip' });
82
+ expect(getClientIp(headers)).toBeNull();
83
+ });
84
+
85
+ it('returns null when count exceeds list length', () => {
86
+ const headers = makeHeaders({ 'x-forwarded-for': '1.2.3.4' });
87
+ expect(getClientIp(headers, { xffTrustedProxyCount: 5 })).toBeNull();
88
+ });
89
+
90
+ it('normalizes an IPv6 XFF entry', () => {
91
+ const headers = makeHeaders({
92
+ 'x-forwarded-for': '2001:4860:4801:10::1',
93
+ });
94
+ expect(getClientIp(headers)).toBe('2001:4860:4801:10::1');
95
+ });
96
+
97
+ it('normalizes v4-mapped IPv6 in XFF to plain IPv4', () => {
98
+ const headers = makeHeaders({
99
+ 'x-forwarded-for': '::ffff:1.2.3.4',
100
+ });
101
+ expect(getClientIp(headers)).toBe('1.2.3.4');
102
+ });
103
+ });
104
+ });