@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.
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { detectClaimedCrawler, GOOGLE_CRAWLER_TOKENS } from '../ua.js';
3
+
4
+ describe('detectClaimedCrawler', () => {
5
+ describe('Googlebot desktop', () => {
6
+ it('detects standard Googlebot UA', () => {
7
+ const ua = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
8
+ expect(detectClaimedCrawler(ua)).toBe('Googlebot');
9
+ });
10
+
11
+ it('detects Googlebot with extra context', () => {
12
+ const ua = 'Googlebot/2.1 (+http://www.google.com/bot.html)';
13
+ expect(detectClaimedCrawler(ua)).toBe('Googlebot');
14
+ });
15
+ });
16
+
17
+ describe('Googlebot mobile', () => {
18
+ it('detects Googlebot-Mobile token', () => {
19
+ const ua =
20
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)';
21
+ expect(detectClaimedCrawler(ua)).toBe('Googlebot-Mobile');
22
+ });
23
+ });
24
+
25
+ describe('Googlebot-Image', () => {
26
+ it('returns Googlebot-Image (more specific than Googlebot)', () => {
27
+ const ua = 'Googlebot-Image/1.0';
28
+ expect(detectClaimedCrawler(ua)).toBe('Googlebot-Image');
29
+ });
30
+ });
31
+
32
+ describe('AdsBot-Google', () => {
33
+ it('detects AdsBot-Google desktop UA', () => {
34
+ const ua = 'AdsBot-Google (+http://www.google.com/adsbot.html)';
35
+ expect(detectClaimedCrawler(ua)).toBe('AdsBot-Google');
36
+ });
37
+
38
+ it('detects AdsBot-Google-Mobile (more specific)', () => {
39
+ const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AdsBot-Google-Mobile/1.0';
40
+ expect(detectClaimedCrawler(ua)).toBe('AdsBot-Google-Mobile');
41
+ });
42
+ });
43
+
44
+ describe('Google-InspectionTool', () => {
45
+ it('detects Google-InspectionTool', () => {
46
+ const ua = 'Mozilla/5.0 (compatible; Google-InspectionTool/1.0; +https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers)';
47
+ expect(detectClaimedCrawler(ua)).toBe('Google-InspectionTool');
48
+ });
49
+ });
50
+
51
+ describe('Mediapartners-Google', () => {
52
+ it('detects Mediapartners-Google', () => {
53
+ const ua = 'Mediapartners-Google';
54
+ expect(detectClaimedCrawler(ua)).toBe('Mediapartners-Google');
55
+ });
56
+ });
57
+
58
+ describe('GoogleOther', () => {
59
+ it('detects GoogleOther standalone', () => {
60
+ const ua = 'GoogleOther';
61
+ expect(detectClaimedCrawler(ua)).toBe('GoogleOther');
62
+ });
63
+
64
+ it('detects GoogleOther inside parentheses — close-paren boundary (regression)', () => {
65
+ // The token is terminated by ')' — the old character-class regex missed this
66
+ const ua =
67
+ 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GoogleOther) Chrome/148.0 Safari/537.36';
68
+ expect(detectClaimedCrawler(ua)).toBe('GoogleOther');
69
+ });
70
+ });
71
+
72
+ describe('paren-terminated tokens (close-paren boundary fix)', () => {
73
+ it('detects Googlebot when token ends with ")"', () => {
74
+ // Simulates a UA string where the token is the last word before the closing paren
75
+ const ua = 'Mozilla/5.0 (compatible; Googlebot)';
76
+ expect(detectClaimedCrawler(ua)).toBe('Googlebot');
77
+ });
78
+
79
+ it('detects Googlebot-Image with slash suffix (regression guard)', () => {
80
+ const ua = 'Googlebot-Image/1.0';
81
+ expect(detectClaimedCrawler(ua)).toBe('Googlebot-Image');
82
+ });
83
+
84
+ it('detects Googlebot inside standard paren UA (regression guard)', () => {
85
+ const ua = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
86
+ expect(detectClaimedCrawler(ua)).toBe('Googlebot');
87
+ });
88
+ });
89
+
90
+ describe('non-Google browsers → null', () => {
91
+ it('returns null for Chrome desktop UA', () => {
92
+ const ua =
93
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
94
+ expect(detectClaimedCrawler(ua)).toBeNull();
95
+ });
96
+
97
+ it('returns null for Safari UA', () => {
98
+ const ua =
99
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
100
+ expect(detectClaimedCrawler(ua)).toBeNull();
101
+ });
102
+
103
+ it('returns null for Firefox UA', () => {
104
+ const ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0';
105
+ expect(detectClaimedCrawler(ua)).toBeNull();
106
+ });
107
+
108
+ it('returns null for empty string', () => {
109
+ expect(detectClaimedCrawler('')).toBeNull();
110
+ });
111
+
112
+ it('returns null for a UA that merely mentions google.com', () => {
113
+ const ua = 'Mozilla/5.0 (compatible; MyCrawler/1.0; see http://www.google.com/)';
114
+ expect(detectClaimedCrawler(ua)).toBeNull();
115
+ });
116
+
117
+ it('returns null when token appears as substring of a longer word', () => {
118
+ // "notagooglebot-thing" contains "Googlebot" but embedded — must not match
119
+ const ua = 'SomeBrowser/1.0 (notagooglebot-thing)';
120
+ expect(detectClaimedCrawler(ua)).toBeNull();
121
+ });
122
+
123
+ it('returns null for normal Chrome UA (no Google crawler tokens)', () => {
124
+ const ua =
125
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
126
+ expect(detectClaimedCrawler(ua)).toBeNull();
127
+ });
128
+ });
129
+
130
+ describe('GOOGLE_CRAWLER_TOKENS export', () => {
131
+ it('is a non-empty readonly array', () => {
132
+ expect(GOOGLE_CRAWLER_TOKENS.length).toBeGreaterThan(0);
133
+ });
134
+
135
+ it('contains Googlebot', () => {
136
+ expect(GOOGLE_CRAWLER_TOKENS).toContain('Googlebot');
137
+ });
138
+
139
+ it('contains AdsBot-Google-Mobile before AdsBot-Google', () => {
140
+ const mobileIdx = GOOGLE_CRAWLER_TOKENS.indexOf('AdsBot-Google-Mobile');
141
+ const desktopIdx = GOOGLE_CRAWLER_TOKENS.indexOf('AdsBot-Google');
142
+ expect(mobileIdx).toBeLessThan(desktopIdx);
143
+ });
144
+ });
145
+ });
@@ -0,0 +1,259 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createBotVerifier } from '../verify.js';
3
+ import { parsePrefixes } from '../ranges.js';
4
+
5
+ // Load the bundled data to pick sample IPs — same data the production store uses
6
+ import googlebotData from '../data/googlebot.json' assert { type: 'json' };
7
+ import specialCrawlersData from '../data/special-crawlers.json' assert { type: 'json' };
8
+ import userTriggeredData from '../data/user-triggered-fetchers.json' assert { type: 'json' };
9
+
10
+ // ─── Helpers ────────────────────────────────────────────────────────────────
11
+
12
+ const GOOGLEBOT_UA = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
13
+ const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36';
14
+ const SPOOF_IP = '45.39.15.48'; // Not a Google IP
15
+
16
+ /** Creates a verifier that will never touch the network */
17
+ function createTestVerifier(overrides?: Parameters<typeof createBotVerifier>[0]) {
18
+ return createBotVerifier({
19
+ fetchImpl: () => { throw new Error('network disabled in tests'); },
20
+ rdns: false,
21
+ ...overrides,
22
+ });
23
+ }
24
+
25
+ // ─── Sample IPs from each bundled file ──────────────────────────────────────
26
+
27
+ const googlebotPrefixes = parsePrefixes(googlebotData);
28
+ const specialPrefixes = parsePrefixes(specialCrawlersData);
29
+ const userTriggeredPrefixes = parsePrefixes(userTriggeredData);
30
+
31
+ /** Pick the first IP from a /N CIDR prefix (the network address + 1 for hosts) */
32
+ function firstIpInPrefix(cidr: string): string {
33
+ const [base] = cidr.split('/');
34
+ if (base === undefined) throw new Error(`Invalid CIDR: ${cidr}`);
35
+ // For IPv4, increment the last octet by 1 to get a host address
36
+ if (!base.includes(':')) {
37
+ const parts = base.split('.');
38
+ const last = parts[3];
39
+ if (last !== undefined) {
40
+ const n = parseInt(last, 10);
41
+ parts[3] = String(n + 1);
42
+ return parts.join('.');
43
+ }
44
+ }
45
+ // For IPv6, append ::1
46
+ return `${base}1`;
47
+ }
48
+
49
+ // A few sample IPs from each file
50
+ const googlebotIpv4 = (() => {
51
+ const p = googlebotPrefixes.find((c) => !c.includes(':'));
52
+ if (!p) throw new Error('No IPv4 prefix in googlebot.json');
53
+ return firstIpInPrefix(p);
54
+ })();
55
+
56
+ const googlebotIpv6 = (() => {
57
+ const p = googlebotPrefixes.find((c) => c.includes(':'));
58
+ if (!p) throw new Error('No IPv6 prefix in googlebot.json');
59
+ return firstIpInPrefix(p);
60
+ })();
61
+
62
+ const specialIpv4 = (() => {
63
+ const p = specialPrefixes.find((c) => !c.includes(':'));
64
+ if (!p) throw new Error('No IPv4 prefix in special-crawlers.json');
65
+ return firstIpInPrefix(p);
66
+ })();
67
+
68
+ const userTriggeredIpv4 = (() => {
69
+ const p = userTriggeredPrefixes.find((c) => !c.includes(':'));
70
+ if (!p) throw new Error('No IPv4 prefix in user-triggered-fetchers.json');
71
+ return firstIpInPrefix(p);
72
+ })();
73
+
74
+ // ─── Tests ──────────────────────────────────────────────────────────────────
75
+
76
+ describe('createBotVerifier', () => {
77
+ describe('not-a-claim (non-Google UA)', () => {
78
+ it('returns not-a-claim for a normal browser UA', async () => {
79
+ const verifier = createTestVerifier();
80
+ const result = await verifier.verify({ userAgent: CHROME_UA, ip: SPOOF_IP });
81
+ expect(result.verdict).toBe('not-a-claim');
82
+ expect(result.claimedBot).toBeNull();
83
+ expect(result.method).toBe('none');
84
+ });
85
+ });
86
+
87
+ describe('uncertain (missing/invalid IP)', () => {
88
+ it('returns uncertain when ip is null', async () => {
89
+ const verifier = createTestVerifier({ rdns: true });
90
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: null });
91
+ expect(result.verdict).toBe('uncertain');
92
+ expect(result.claimedBot).toBe('Googlebot');
93
+ });
94
+
95
+ it('returns uncertain when ip is empty string', async () => {
96
+ const verifier = createTestVerifier({ rdns: true });
97
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: '' });
98
+ expect(result.verdict).toBe('uncertain');
99
+ });
100
+
101
+ it('returns uncertain when ip is not a valid address', async () => {
102
+ const verifier = createTestVerifier({ rdns: true });
103
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: 'not-an-ip' });
104
+ expect(result.verdict).toBe('uncertain');
105
+ });
106
+ });
107
+
108
+ describe('verified via ip-range', () => {
109
+ it('verifies a real Googlebot IPv4 from googlebot.json', async () => {
110
+ const verifier = createTestVerifier();
111
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: googlebotIpv4 });
112
+ expect(result.verdict).toBe('verified');
113
+ expect(result.method).toBe('ip-range');
114
+ });
115
+
116
+ it('verifies a real Googlebot IPv6 from googlebot.json', async () => {
117
+ const verifier = createTestVerifier();
118
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: googlebotIpv6 });
119
+ expect(result.verdict).toBe('verified');
120
+ expect(result.method).toBe('ip-range');
121
+ });
122
+
123
+ it('verifies a real IP from special-crawlers.json', async () => {
124
+ const verifier = createTestVerifier();
125
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: specialIpv4 });
126
+ expect(result.verdict).toBe('verified');
127
+ expect(result.method).toBe('ip-range');
128
+ });
129
+
130
+ it('verifies a real IP from user-triggered-fetchers.json', async () => {
131
+ const verifier = createTestVerifier();
132
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: userTriggeredIpv4 });
133
+ expect(result.verdict).toBe('verified');
134
+ expect(result.method).toBe('ip-range');
135
+ });
136
+ });
137
+
138
+ describe('spoofed (rdns: false)', () => {
139
+ it('returns spoofed for a non-Google IP when rdns is disabled', async () => {
140
+ const verifier = createTestVerifier({ rdns: false });
141
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: SPOOF_IP });
142
+ expect(result.verdict).toBe('spoofed');
143
+ expect(result.method).toBe('ip-range');
144
+ expect(result.claimedBot).toBe('Googlebot');
145
+ });
146
+ });
147
+
148
+ describe('rdns outcomes', () => {
149
+ it('returns verified when rdnsImpl confirms', async () => {
150
+ const verifier = createTestVerifier({
151
+ rdns: true,
152
+ rdnsImpl: async () => 'confirmed',
153
+ });
154
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: SPOOF_IP });
155
+ expect(result.verdict).toBe('verified');
156
+ expect(result.method).toBe('rdns');
157
+ });
158
+
159
+ it('returns spoofed when rdnsImpl returns failed', async () => {
160
+ const verifier = createTestVerifier({
161
+ rdns: true,
162
+ rdnsImpl: async () => 'failed',
163
+ });
164
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: SPOOF_IP });
165
+ expect(result.verdict).toBe('spoofed');
166
+ expect(result.method).toBe('rdns');
167
+ });
168
+
169
+ it('returns uncertain when rdnsImpl returns error (fail-open)', async () => {
170
+ const verifier = createTestVerifier({
171
+ rdns: true,
172
+ rdnsImpl: async () => 'error',
173
+ });
174
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: SPOOF_IP });
175
+ expect(result.verdict).toBe('uncertain');
176
+ expect(result.method).toBe('rdns');
177
+ });
178
+ });
179
+
180
+ describe('INVARIANT: no real Google IP is ever classified as spoofed', () => {
181
+ it('verifies multiple sample IPs from each bundled file', async () => {
182
+ const verifier = createTestVerifier({ rdns: false });
183
+
184
+ // Pick a few v4 and v6 samples from each file
185
+ const allPrefixes = [
186
+ ...googlebotPrefixes.slice(0, 3),
187
+ ...specialPrefixes.slice(0, 3),
188
+ ...userTriggeredPrefixes.slice(0, 3),
189
+ ];
190
+
191
+ for (const prefix of allPrefixes) {
192
+ const ip = firstIpInPrefix(prefix);
193
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip });
194
+ expect(result.verdict, `Expected verified for ${ip} (${prefix})`).toBe('verified');
195
+ }
196
+ });
197
+ });
198
+
199
+ describe('fail-open: empty range store + rdns error → uncertain (never spoofed)', () => {
200
+ it('returns uncertain, not spoofed, when ranges are empty and rDNS errors', async () => {
201
+ const verifier = createBotVerifier({
202
+ initialRanges: [],
203
+ fetchImpl: () => { throw new Error('no network'); },
204
+ rdns: true,
205
+ rdnsImpl: async () => 'error',
206
+ });
207
+ const result = await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: SPOOF_IP });
208
+ expect(result.verdict).toBe('uncertain');
209
+ expect(result.verdict).not.toBe('spoofed');
210
+ });
211
+ });
212
+
213
+ describe('logger', () => {
214
+ it('calls the logger with the result', async () => {
215
+ const loggedResults: unknown[] = [];
216
+ const verifier = createTestVerifier({
217
+ logger: (r) => loggedResults.push(r),
218
+ });
219
+ await verifier.verify({ userAgent: GOOGLEBOT_UA, ip: SPOOF_IP });
220
+ expect(loggedResults).toHaveLength(1);
221
+ });
222
+
223
+ it('does not propagate logger errors', async () => {
224
+ const verifier = createTestVerifier({
225
+ logger: () => { throw new Error('logger exploded'); },
226
+ });
227
+ await expect(
228
+ verifier.verify({ userAgent: GOOGLEBOT_UA, ip: SPOOF_IP }),
229
+ ).resolves.not.toThrow();
230
+ });
231
+ });
232
+
233
+ describe('refreshRanges', () => {
234
+ it('returns false when fetch fails', async () => {
235
+ const verifier = createTestVerifier();
236
+ const ok = await verifier.refreshRanges();
237
+ expect(ok).toBe(false);
238
+ });
239
+
240
+ it('returns true when fetch succeeds with valid data', async () => {
241
+ const samplePayload = {
242
+ prefixes: [
243
+ { ipv4Prefix: '192.0.2.0/24' },
244
+ ],
245
+ };
246
+ const mockFetch = vi.fn(async () => ({
247
+ ok: true,
248
+ json: async () => samplePayload,
249
+ } as unknown as Response));
250
+
251
+ const verifier = createBotVerifier({
252
+ fetchImpl: mockFetch as typeof fetch,
253
+ rdns: false,
254
+ });
255
+ const ok = await verifier.refreshRanges();
256
+ expect(ok).toBe(true);
257
+ });
258
+ });
259
+ });
package/src/cidr.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * IP address and CIDR matching utilities using ipaddr.js.
3
+ *
4
+ * All functions are fail-open: malformed input returns `false` / `null`
5
+ * rather than throwing.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import ipaddr from 'ipaddr.js';
11
+
12
+ /**
13
+ * Normalizes an IP address string to a canonical form:
14
+ * - IPv4: standard dotted-decimal, no trailing zeros
15
+ * - IPv6: lowercase compressed form (RFC 5952); v4-mapped addresses (`::ffff:1.2.3.4`)
16
+ * are unwrapped to their plain IPv4 form
17
+ *
18
+ * Returns `null` for any input that is not a valid IP address.
19
+ *
20
+ * @param ip - Raw IP string (may include port brackets like `[::1]`)
21
+ */
22
+ export function normalizeIp(ip: string): string | null {
23
+ const stripped = stripBracketsAndPort(ip);
24
+ if (!stripped) {
25
+ return null;
26
+ }
27
+
28
+ try {
29
+ const parsed = ipaddr.parse(stripped);
30
+
31
+ if (parsed.kind() === 'ipv6') {
32
+ const v6 = parsed as ipaddr.IPv6;
33
+ if (v6.isIPv4MappedAddress()) {
34
+ // Unwrap ::ffff:x.x.x.x → plain IPv4
35
+ return v6.toIPv4Address().toString();
36
+ }
37
+ // toRFC5952String() produces compressed lowercase form (e.g. "::1")
38
+ return v6.toRFC5952String();
39
+ }
40
+
41
+ return parsed.toString();
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Tests whether `ip` falls within the given `cidr` range.
49
+ *
50
+ * Handles v4, v6, and v4-mapped-v6 by normalizing before comparison.
51
+ * Different address families never match each other.
52
+ * Returns `false` for any malformed input rather than throwing.
53
+ *
54
+ * @param ip - The IP address to test
55
+ * @param cidr - A CIDR string such as `'66.249.64.0/19'` or `'2001:4860:4800::/32'`
56
+ */
57
+ export function ipInCidr(ip: string, cidr: string): boolean {
58
+ try {
59
+ const normalizedIpStr = normalizeIp(ip);
60
+ if (normalizedIpStr === null) {
61
+ return false;
62
+ }
63
+
64
+ const addr = ipaddr.parse(normalizedIpStr);
65
+ const [network, prefixLen] = ipaddr.parseCIDR(cidr);
66
+
67
+ // Different kinds (v4 vs v6) never match
68
+ if (addr.kind() !== network.kind()) {
69
+ return false;
70
+ }
71
+
72
+ return addr.match([network, prefixLen]);
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Tests whether `ip` falls within any of the given `cidrs`.
80
+ *
81
+ * Short-circuits on first match. Returns `false` if the list is empty.
82
+ */
83
+ export function ipInAnyCidr(ip: string, cidrs: readonly string[]): boolean {
84
+ for (const cidr of cidrs) {
85
+ if (ipInCidr(ip, cidr)) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Internal helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Strips IPv6 bracket notation and optional port from an IP string.
98
+ * e.g. `[::1]:8080` → `::1`, `1.2.3.4:80` (plain IPv4 with port) stays as-is
99
+ * since ipaddr.js handles plain dotted IPv4 without ports.
100
+ */
101
+ function stripBracketsAndPort(raw: string): string {
102
+ const trimmed = raw.trim();
103
+
104
+ // IPv6 bracket form: [addr] or [addr]:port
105
+ if (trimmed.startsWith('[')) {
106
+ const end = trimmed.indexOf(']');
107
+ if (end === -1) {
108
+ return '';
109
+ }
110
+ return trimmed.slice(1, end);
111
+ }
112
+
113
+ return trimmed;
114
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Secure client IP extraction from HTTP headers.
3
+ *
4
+ * SECURITY MODEL:
5
+ * - The leftmost X-Forwarded-For entry is ALWAYS attacker-controlled and
6
+ * must never be trusted as the real client IP without a trusted proxy chain.
7
+ * - Only a header set by a proxy we control (`trustedHeader`) or the rightmost
8
+ * N entries in XFF (where N = trusted proxy count) are safe to use.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import { normalizeIp } from './cidr.js';
14
+
15
+ /** Options for {@link getClientIp}. */
16
+ export interface ClientIpOptions {
17
+ /**
18
+ * Name of a header (lowercase) that a trusted reverse-proxy overwrites with
19
+ * the real client IP, e.g. `'x-real-ip'`. When present and valid, this
20
+ * header is returned verbatim without inspecting X-Forwarded-For.
21
+ *
22
+ * Only use this if the proxy unconditionally overwrites the header (not
23
+ * merely appends to it), so it cannot be spoofed by the client.
24
+ */
25
+ trustedHeader?: string;
26
+ /**
27
+ * Number of trusted proxies in the infrastructure chain, counted from the
28
+ * RIGHT of the X-Forwarded-For list. Default `0` (only the rightmost entry,
29
+ * added by the edge proxy, is trusted).
30
+ *
31
+ * Example: `"clientIP, proxy1, proxy2"` with `xffTrustedProxyCount: 1`
32
+ * means `proxy2` is trusted, so the real client IP is `proxy1`.
33
+ *
34
+ * NEVER set this higher than the number of proxies you control.
35
+ */
36
+ xffTrustedProxyCount?: number;
37
+ }
38
+
39
+ /**
40
+ * Extracts the best-available client IP from the request headers.
41
+ *
42
+ * Priority:
43
+ * 1. `trustedHeader` (if configured and contains a valid IP)
44
+ * 2. X-Forwarded-For, counting `xffTrustedProxyCount` proxies from the right
45
+ *
46
+ * Returns `null` when no valid IP can be determined.
47
+ *
48
+ * IMPORTANT: The leftmost X-Forwarded-For entry is attacker-controlled.
49
+ * A forged Google IP in position 0 does NOT affect the result when
50
+ * `xffTrustedProxyCount` is `0` (the default).
51
+ *
52
+ * @param headers - The request Headers object
53
+ * @param opts - IP extraction options
54
+ */
55
+ export function getClientIp(headers: Headers, opts?: ClientIpOptions): string | null {
56
+ if (opts?.trustedHeader) {
57
+ const headerValue = headers.get(opts.trustedHeader);
58
+ if (headerValue) {
59
+ const normalized = normalizeIp(headerValue.trim());
60
+ if (normalized !== null) {
61
+ return normalized;
62
+ }
63
+ }
64
+ }
65
+
66
+ const xffHeader = headers.get('x-forwarded-for');
67
+ if (!xffHeader) {
68
+ return null;
69
+ }
70
+
71
+ const entries = xffHeader
72
+ .split(',')
73
+ .map((s) => s.trim())
74
+ .filter((s) => s.length > 0);
75
+
76
+ if (entries.length === 0) {
77
+ return null;
78
+ }
79
+
80
+ const k = opts?.xffTrustedProxyCount ?? 0;
81
+ // The entry at `len - 1 - k` is the first untrusted entry from the right
82
+ const targetIndex = entries.length - 1 - k;
83
+
84
+ if (targetIndex < 0 || targetIndex >= entries.length) {
85
+ return null;
86
+ }
87
+
88
+ const candidate = entries[targetIndex];
89
+ if (candidate === undefined) {
90
+ return null;
91
+ }
92
+
93
+ return normalizeIp(candidate);
94
+ }