@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/src/ranges.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Google crawler IP range store.
3
+ *
4
+ * Holds the union of all three Google published CIDR lists and supports
5
+ * background refresh. Never empty: seeds from bundled JSON at construction
6
+ * and falls back to the last-good list on any refresh failure (fail-open).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import googlebotData from './data/googlebot.json';
12
+ import specialCrawlersData from './data/special-crawlers.json';
13
+ import userTriggeredData from './data/user-triggered-fetchers.json';
14
+ import { ipInAnyCidr } from './cidr.js';
15
+
16
+ /** Official URLs for Google's published crawler IP ranges. */
17
+ export const GOOGLE_RANGE_URLS = [
18
+ 'https://developers.google.com/static/search/apis/ipranges/googlebot.json',
19
+ 'https://developers.google.com/static/search/apis/ipranges/special-crawlers.json',
20
+ 'https://developers.google.com/static/search/apis/ipranges/user-triggered-fetchers.json',
21
+ ] as const;
22
+
23
+ /**
24
+ * Extracts CIDR strings from a Google IP range JSON payload.
25
+ * Handles unknown input defensively — never throws.
26
+ */
27
+ export function parsePrefixes(data: unknown): string[] {
28
+ if (typeof data !== 'object' || data === null || !('prefixes' in data)) {
29
+ return [];
30
+ }
31
+
32
+ const prefixes = (data as { prefixes: unknown }).prefixes;
33
+ if (!Array.isArray(prefixes)) {
34
+ return [];
35
+ }
36
+
37
+ const result: string[] = [];
38
+ for (const entry of prefixes) {
39
+ if (typeof entry !== 'object' || entry === null) {
40
+ continue;
41
+ }
42
+
43
+ const e = entry as Record<string, unknown>;
44
+ if (typeof e['ipv4Prefix'] === 'string') {
45
+ result.push(e['ipv4Prefix']);
46
+ } else if (typeof e['ipv6Prefix'] === 'string') {
47
+ result.push(e['ipv6Prefix']);
48
+ }
49
+ }
50
+
51
+ return result;
52
+ }
53
+
54
+ /** The bundled (fail-safe) union of all three Google range files. */
55
+ const BUNDLED_CIDRS: readonly string[] = [
56
+ ...parsePrefixes(googlebotData),
57
+ ...parsePrefixes(specialCrawlersData),
58
+ ...parsePrefixes(userTriggeredData),
59
+ ];
60
+
61
+ /** Options for constructing a {@link RangeStore}. */
62
+ export interface RangeStoreOptions {
63
+ /**
64
+ * Override the initial CIDR list (useful in tests; pass `[]` to simulate
65
+ * empty ranges). Defaults to the bundled union.
66
+ */
67
+ initialRanges?: string[];
68
+ /**
69
+ * Injectable clock for testability. Defaults to `Date.now`.
70
+ */
71
+ now?: () => number;
72
+ }
73
+
74
+ /**
75
+ * Holds the current union of Google crawler CIDR ranges and provides
76
+ * background refresh from the official Google endpoints.
77
+ *
78
+ * Construction is synchronous and never leaves the store empty (seeds from
79
+ * bundled data immediately). Refresh is async and fail-open.
80
+ */
81
+ export class RangeStore {
82
+ private cidrs: string[];
83
+ private readonly now: () => number;
84
+ lastRefreshedAt: number;
85
+
86
+ constructor(opts?: RangeStoreOptions) {
87
+ this.cidrs = opts?.initialRanges !== undefined ? [...opts.initialRanges] : [...BUNDLED_CIDRS];
88
+ this.now = opts?.now ?? Date.now;
89
+ this.lastRefreshedAt = this.now();
90
+ }
91
+
92
+ /**
93
+ * Returns true if `ip` is contained within the current CIDR union.
94
+ */
95
+ contains(ip: string): boolean {
96
+ return ipInAnyCidr(ip, this.cidrs);
97
+ }
98
+
99
+ /**
100
+ * Fetches all three Google range URLs and atomically replaces the current
101
+ * CIDR union only if ALL fetches succeed.
102
+ *
103
+ * On any failure (network error, parse error, unexpected shape), keeps the
104
+ * last-good list and returns `false`. Never throws, never empties the store.
105
+ *
106
+ * @param fetchImpl - Fetch implementation (injectable for tests)
107
+ */
108
+ async refresh(fetchImpl: typeof fetch = fetch): Promise<boolean> {
109
+ try {
110
+ const responses = await Promise.all(
111
+ GOOGLE_RANGE_URLS.map((url) => fetchImpl(url)),
112
+ );
113
+
114
+ // Fail if any response was not OK
115
+ for (const resp of responses) {
116
+ if (!resp.ok) {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ const bodies = await Promise.all(responses.map((r) => r.json() as Promise<unknown>));
122
+
123
+ const newCidrs: string[] = [];
124
+ for (const body of bodies) {
125
+ const prefixes = parsePrefixes(body);
126
+ if (prefixes.length === 0) {
127
+ // Unexpected shape — bail out to keep last-good
128
+ return false;
129
+ }
130
+ newCidrs.push(...prefixes);
131
+ }
132
+
133
+ // Atomic replace
134
+ this.cidrs = newCidrs;
135
+ this.lastRefreshedAt = this.now();
136
+ return true;
137
+ } catch {
138
+ // Network errors, JSON parse errors, etc. — keep last-good list
139
+ return false;
140
+ }
141
+ }
142
+ }
package/src/rdns.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Reverse-DNS verification for Google crawlers.
3
+ *
4
+ * Implements the two-step check recommended by Google:
5
+ * 1. PTR lookup on the IP → hostname
6
+ * 2. Forward A/AAAA lookup on that hostname → must contain the original IP
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import dns from 'node:dns/promises';
12
+ import { normalizeIp } from './cidr.js';
13
+
14
+ const DNS_ERROR_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEOUT', 'ESERVFAIL', 'ENONAME']);
15
+
16
+ /** Allowed domains for reverse-DNS confirmation. */
17
+ const DEFAULT_ALLOWED_DOMAINS = ['googlebot.com', 'google.com', 'googleusercontent.com'] as const;
18
+
19
+ /** Options for {@link reverseDnsVerify}. */
20
+ export interface ReverseDnsOptions {
21
+ /**
22
+ * Hostname suffixes that are accepted as legitimate Google crawler domains.
23
+ * Defaults to `['googlebot.com', 'google.com', 'googleusercontent.com']`.
24
+ */
25
+ allowedDomains?: string[];
26
+ /**
27
+ * Maximum time in milliseconds to wait for DNS lookups.
28
+ * Defaults to `1500`.
29
+ */
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ /**
34
+ * Performs a two-step reverse-DNS verification for a Google crawler IP.
35
+ *
36
+ * Returns:
37
+ * - `'confirmed'` — PTR resolves to a Google domain AND forward A/AAAA
38
+ * resolves back to the original IP
39
+ * - `'failed'` — DNS resolves successfully but does NOT confirm Google
40
+ * (no PTR, non-Google hostname, or forward mismatch)
41
+ * - `'error'` — DNS infrastructure failure (timeout, SERVFAIL, ECONNREFUSED)
42
+ *
43
+ * A timeout is treated as `'error'` (uncertain), not `'failed'` (spoofed),
44
+ * because we must not block legitimate bots due to DNS unreachability.
45
+ */
46
+ export async function reverseDnsVerify(
47
+ ip: string,
48
+ opts?: ReverseDnsOptions,
49
+ ): Promise<'confirmed' | 'failed' | 'error'> {
50
+ const allowedDomains = opts?.allowedDomains ?? DEFAULT_ALLOWED_DOMAINS;
51
+ const timeoutMs = opts?.timeoutMs ?? 1500;
52
+
53
+ return Promise.race([
54
+ doVerify(ip, allowedDomains),
55
+ timeout(timeoutMs),
56
+ ]);
57
+ }
58
+
59
+ async function doVerify(
60
+ ip: string,
61
+ allowedDomains: readonly string[],
62
+ ): Promise<'confirmed' | 'failed' | 'error'> {
63
+ const normalizedOriginal = normalizeIp(ip);
64
+ if (normalizedOriginal === null) {
65
+ return 'failed';
66
+ }
67
+
68
+ // Step 1: PTR lookup
69
+ let hostnames: string[];
70
+ try {
71
+ hostnames = await dns.reverse(ip);
72
+ } catch (err) {
73
+ return classifyDnsError(err);
74
+ }
75
+
76
+ if (hostnames.length === 0) {
77
+ return 'failed';
78
+ }
79
+
80
+ // Step 2: Forward lookup on each hostname that matches an allowed domain
81
+ for (const hostname of hostnames) {
82
+ if (!isAllowedDomain(hostname, allowedDomains)) {
83
+ continue;
84
+ }
85
+
86
+ const forwardConfirmed = await forwardLookup(hostname, normalizedOriginal);
87
+ if (forwardConfirmed === 'confirmed') {
88
+ return 'confirmed';
89
+ }
90
+ if (forwardConfirmed === 'error') {
91
+ return 'error';
92
+ }
93
+ // 'failed' → try next hostname
94
+ }
95
+
96
+ return 'failed';
97
+ }
98
+
99
+ async function forwardLookup(
100
+ hostname: string,
101
+ originalIp: string,
102
+ ): Promise<'confirmed' | 'failed' | 'error'> {
103
+ try {
104
+ const [ipv4Results, ipv6Results] = await Promise.allSettled([
105
+ dns.resolve4(hostname),
106
+ dns.resolve6(hostname),
107
+ ]);
108
+
109
+ const addresses: string[] = [];
110
+
111
+ if (ipv4Results.status === 'fulfilled') {
112
+ addresses.push(...ipv4Results.value);
113
+ } else if (isInfraError(ipv4Results.reason)) {
114
+ return 'error';
115
+ }
116
+
117
+ if (ipv6Results.status === 'fulfilled') {
118
+ addresses.push(...ipv6Results.value);
119
+ } else if (isInfraError(ipv6Results.reason)) {
120
+ return 'error';
121
+ }
122
+
123
+ for (const addr of addresses) {
124
+ const normalized = normalizeIp(addr);
125
+ if (normalized !== null && normalized === originalIp) {
126
+ return 'confirmed';
127
+ }
128
+ }
129
+
130
+ return 'failed';
131
+ } catch (err) {
132
+ return isInfraError(err) ? 'error' : 'failed';
133
+ }
134
+ }
135
+
136
+ function isAllowedDomain(hostname: string, allowedDomains: readonly string[]): boolean {
137
+ const lower = hostname.toLowerCase();
138
+ for (const domain of allowedDomains) {
139
+ if (lower === domain || lower.endsWith(`.${domain}`)) {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
146
+ function classifyDnsError(err: unknown): 'failed' | 'error' {
147
+ if (isInfraError(err)) {
148
+ return 'error';
149
+ }
150
+ // ENOTFOUND, ENODATA, NXDOMAIN, ENONAME → legitimately no record → failed
151
+ return 'failed';
152
+ }
153
+
154
+ function isInfraError(err: unknown): boolean {
155
+ if (typeof err !== 'object' || err === null) {
156
+ return false;
157
+ }
158
+ const code = (err as { code?: string }).code;
159
+ if (code === undefined) {
160
+ return false;
161
+ }
162
+ return DNS_ERROR_CODES.has(code);
163
+ }
164
+
165
+ function timeout(ms: number): Promise<'error'> {
166
+ return new Promise((resolve) => setTimeout(() => resolve('error'), ms));
167
+ }
package/src/types.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Core types for bot verification.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ /**
8
+ * The outcome of a bot verification check.
9
+ *
10
+ * - `'verified'` — IP confirmed as a legitimate Google crawler
11
+ * - `'spoofed'` — IP does NOT belong to Google; the UA claim is fraudulent
12
+ * - `'uncertain'` — could not confirm or deny (e.g., DNS error, missing IP)
13
+ * - `'not-a-claim'` — the User-Agent does not claim to be a Google crawler
14
+ */
15
+ export type BotVerdict = 'verified' | 'spoofed' | 'uncertain' | 'not-a-claim';
16
+
17
+ /** Input supplied to the verifier for a single request. */
18
+ export interface VerifyInput {
19
+ /** Raw User-Agent header value. */
20
+ userAgent: string;
21
+ /** Client IP address (may be null if unavailable). */
22
+ ip: string | null;
23
+ }
24
+
25
+ /** Result of a single verification. */
26
+ export interface VerifyResult {
27
+ /** Final verdict for this request. */
28
+ verdict: BotVerdict;
29
+ /**
30
+ * The Google crawler token that was claimed in the UA, e.g. `'Googlebot'`.
31
+ * `null` when `verdict` is `'not-a-claim'`.
32
+ */
33
+ claimedBot: string | null;
34
+ /** The client IP that was evaluated (may be null when not available). */
35
+ ip: string | null;
36
+ /**
37
+ * The method used to reach the verdict.
38
+ * - `'ip-range'` — checked against Google's published CIDR lists
39
+ * - `'rdns'` — verified (or failed) via reverse-DNS lookup
40
+ * - `'none'` — no IP/network check was performed
41
+ */
42
+ method: 'ip-range' | 'rdns' | 'none';
43
+ /** Short human-readable explanation of the verdict. */
44
+ reason: string;
45
+ }
package/src/ua.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * User-Agent crawler detection for Google's crawler family.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ /**
8
+ * All Google crawler tokens whose presence in a UA string constitutes a
9
+ * verifiable claim. Ordered from most-specific to least-specific so the
10
+ * detection loop returns the best match.
11
+ */
12
+ export const GOOGLE_CRAWLER_TOKENS = [
13
+ 'AdsBot-Google-Mobile',
14
+ 'AdsBot-Google',
15
+ 'Googlebot-Image',
16
+ 'Googlebot-News',
17
+ 'Googlebot-Video',
18
+ 'Googlebot-Mobile',
19
+ 'Google-InspectionTool',
20
+ 'Storebot-Google',
21
+ 'Google-Read-Aloud',
22
+ 'Google-Site-Verification',
23
+ 'FeedFetcher-Google',
24
+ 'Mediapartners-Google',
25
+ 'GoogleOther',
26
+ 'APIs-Google',
27
+ 'Googlebot',
28
+ ] as const;
29
+
30
+ export type GoogleCrawlerToken = (typeof GOOGLE_CRAWLER_TOKENS)[number];
31
+
32
+ /**
33
+ * Detects whether a User-Agent string claims to be a Google crawler.
34
+ *
35
+ * Uses word-boundary matching (not a loose substring) to avoid false positives
36
+ * from UAs that merely contain the word "google" incidentally (e.g. a normal
37
+ * Chrome browser whose referrer is embedded in the UA).
38
+ *
39
+ * Returns the most specific matched token (e.g. `'Googlebot-Image'` rather
40
+ * than `'Googlebot'`), or `null` if no Google crawler token is present.
41
+ *
42
+ * @param userAgent - The raw User-Agent header value
43
+ * @returns The matched crawler token or `null`
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * detectClaimedCrawler('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)')
48
+ * // => 'Googlebot'
49
+ *
50
+ * detectClaimedCrawler('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ...')
51
+ * // => null
52
+ * ```
53
+ */
54
+ export function detectClaimedCrawler(userAgent: string): GoogleCrawlerToken | null {
55
+ if (!userAgent) {
56
+ return null;
57
+ }
58
+
59
+ for (const token of GOOGLE_CRAWLER_TOKENS) {
60
+ // Alphanumeric/hyphen lookarounds: the token must not be immediately
61
+ // preceded or followed by A-Z, a-z, 0-9, or hyphen. This handles
62
+ // close-parens, dots, slashes, end-of-string, etc. as valid boundaries
63
+ // while still preventing embedded matches inside longer identifiers.
64
+ const pattern = new RegExp(`(?<![A-Za-z0-9-])${escapeRegExp(token)}(?![A-Za-z0-9-])`, 'i');
65
+ if (pattern.test(userAgent)) {
66
+ return token;
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ function escapeRegExp(s: string): string {
74
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
75
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Core bot verification logic.
3
+ *
4
+ * Creates a `BotVerifier` that checks whether a request claiming to be a
5
+ * Google crawler actually originates from Google's published IP ranges or can
6
+ * be confirmed via reverse-DNS.
7
+ *
8
+ * OVERRIDING CORRECTNESS RULE: NEVER classify a real Google crawler as
9
+ * `'spoofed'`. When in doubt (missing IP, DNS errors, range refresh failure),
10
+ * return `'uncertain'` so callers treat the request as pass-through.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { detectClaimedCrawler } from './ua.js';
16
+ import { normalizeIp } from './cidr.js';
17
+ import { RangeStore } from './ranges.js';
18
+ import { reverseDnsVerify } from './rdns.js';
19
+ import type { VerifyInput, VerifyResult } from './types.js';
20
+
21
+ /** Options for {@link createBotVerifier}. */
22
+ export interface BotVerifierOptions {
23
+ /**
24
+ * How often to refresh the Google IP ranges from the official URLs.
25
+ * Defaults to 24 hours.
26
+ */
27
+ rangeRefreshTtlMs?: number;
28
+ /**
29
+ * Whether to perform a reverse-DNS lookup when the IP is not in the
30
+ * published ranges. Defaults to `true`.
31
+ *
32
+ * When `false`, any IP not in the published ranges is immediately classified
33
+ * as `'spoofed'`.
34
+ */
35
+ rdns?: boolean;
36
+ /**
37
+ * Timeout for rDNS lookups in milliseconds. Defaults to `1500`.
38
+ */
39
+ rdnsTimeoutMs?: number;
40
+ /**
41
+ * Injectable fetch implementation (useful in tests to avoid network calls).
42
+ * Defaults to the global `fetch`.
43
+ */
44
+ fetchImpl?: typeof fetch;
45
+ /**
46
+ * Injectable rDNS implementation (useful in tests).
47
+ * Defaults to {@link reverseDnsVerify} with `rdnsTimeoutMs` applied.
48
+ */
49
+ rdnsImpl?: (ip: string) => Promise<'confirmed' | 'failed' | 'error'>;
50
+ /**
51
+ * Injectable clock for testability. Defaults to `Date.now`.
52
+ */
53
+ now?: () => number;
54
+ /**
55
+ * Override the initial CIDR list (useful in tests; pass `[]` to simulate
56
+ * empty ranges). Defaults to the bundled union of all three Google files.
57
+ */
58
+ initialRanges?: string[];
59
+ /**
60
+ * Optional structured logger called after every verification.
61
+ * Errors thrown by the logger are swallowed so they never affect the result.
62
+ */
63
+ logger?: (result: VerifyResult) => void;
64
+ }
65
+
66
+ /** Public interface for a bot verifier instance. */
67
+ export interface BotVerifier {
68
+ /**
69
+ * Verifies whether the request described by `input` is a legitimate
70
+ * Google crawler or a spoof attempt.
71
+ */
72
+ verify(input: VerifyInput): Promise<VerifyResult>;
73
+ /**
74
+ * Manually triggers a refresh of the Google IP ranges.
75
+ * Returns `true` on success, `false` if the refresh failed (ranges unchanged).
76
+ */
77
+ refreshRanges(): Promise<boolean>;
78
+ }
79
+
80
+ /**
81
+ * Creates a new {@link BotVerifier} instance.
82
+ *
83
+ * The verifier seeds its IP range list from the bundled JSON files
84
+ * immediately (so it is never empty) and refreshes lazily in the background
85
+ * based on `rangeRefreshTtlMs`.
86
+ */
87
+ export function createBotVerifier(opts?: BotVerifierOptions): BotVerifier {
88
+ const rangeRefreshTtlMs = opts?.rangeRefreshTtlMs ?? 24 * 60 * 60 * 1000;
89
+ const useRdns = opts?.rdns ?? true;
90
+ const rdnsTimeoutMs = opts?.rdnsTimeoutMs ?? 1500;
91
+ const fetchImpl = opts?.fetchImpl ?? fetch;
92
+ const nowFn = opts?.now ?? Date.now;
93
+ const logger = opts?.logger;
94
+
95
+ const store = new RangeStore({
96
+ initialRanges: opts?.initialRanges,
97
+ now: nowFn,
98
+ });
99
+
100
+ const rdnsImpl =
101
+ opts?.rdnsImpl ??
102
+ ((ip: string) => reverseDnsVerify(ip, { timeoutMs: rdnsTimeoutMs }));
103
+
104
+ async function verify(input: VerifyInput): Promise<VerifyResult> {
105
+ const result = await doVerify(input);
106
+ if (logger) {
107
+ try {
108
+ logger(result);
109
+ } catch {
110
+ // Logger errors must never propagate
111
+ }
112
+ }
113
+ return result;
114
+ }
115
+
116
+ async function doVerify(input: VerifyInput): Promise<VerifyResult> {
117
+ const { userAgent, ip } = input;
118
+
119
+ // Step 1: Check if the UA claims to be a Google crawler at all
120
+ const claimedBot = detectClaimedCrawler(userAgent);
121
+ if (claimedBot === null) {
122
+ return {
123
+ verdict: 'not-a-claim',
124
+ claimedBot: null,
125
+ ip,
126
+ method: 'none',
127
+ reason: 'UA does not claim a Google crawler',
128
+ };
129
+ }
130
+
131
+ // Step 2: Validate the IP
132
+ const normalizedIp = ip ? normalizeIp(ip) : null;
133
+ if (!ip || normalizedIp === null) {
134
+ return {
135
+ verdict: 'uncertain',
136
+ claimedBot,
137
+ ip: ip ?? null,
138
+ method: 'none',
139
+ reason: 'no usable client IP',
140
+ };
141
+ }
142
+
143
+ // Step 3: Lazy range refresh
144
+ const age = nowFn() - store.lastRefreshedAt;
145
+ if (age > rangeRefreshTtlMs) {
146
+ // Fire and forget — failures are swallowed inside RangeStore.refresh
147
+ await store.refresh(fetchImpl);
148
+ }
149
+
150
+ // Step 4: IP-range check
151
+ if (store.contains(normalizedIp)) {
152
+ return {
153
+ verdict: 'verified',
154
+ claimedBot,
155
+ ip: normalizedIp,
156
+ method: 'ip-range',
157
+ reason: 'IP in Google published ranges',
158
+ };
159
+ }
160
+
161
+ // Step 5: rDNS check (when enabled)
162
+ if (useRdns) {
163
+ const rdnsResult = await rdnsImpl(normalizedIp);
164
+
165
+ if (rdnsResult === 'confirmed') {
166
+ return {
167
+ verdict: 'verified',
168
+ claimedBot,
169
+ ip: normalizedIp,
170
+ method: 'rdns',
171
+ reason: 'reverse-DNS confirmed Google',
172
+ };
173
+ }
174
+
175
+ if (rdnsResult === 'failed') {
176
+ return {
177
+ verdict: 'spoofed',
178
+ claimedBot,
179
+ ip: normalizedIp,
180
+ method: 'rdns',
181
+ reason: 'reverse-DNS does not confirm Google ownership',
182
+ };
183
+ }
184
+
185
+ // rdnsResult === 'error'
186
+ return {
187
+ verdict: 'uncertain',
188
+ claimedBot,
189
+ ip: normalizedIp,
190
+ method: 'rdns',
191
+ reason: 'rDNS inconclusive (DNS infrastructure error)',
192
+ };
193
+ }
194
+
195
+ // Step 6: rDNS disabled — IP not in range → spoofed
196
+ return {
197
+ verdict: 'spoofed',
198
+ claimedBot,
199
+ ip: normalizedIp,
200
+ method: 'ip-range',
201
+ reason: 'IP not in Google published ranges (rDNS disabled)',
202
+ };
203
+ }
204
+
205
+ return {
206
+ verify,
207
+ refreshRanges: () => store.refresh(fetchImpl),
208
+ };
209
+ }