@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 +21 -0
- package/README.md +93 -0
- package/index.ts +58 -0
- package/package.json +54 -0
- package/react-router/index.ts +159 -0
- package/src/__tests__/cidr.test.ts +115 -0
- package/src/__tests__/client-ip.test.ts +104 -0
- package/src/__tests__/ua.test.ts +145 -0
- package/src/__tests__/verify.test.ts +259 -0
- package/src/cidr.ts +114 -0
- package/src/client-ip.ts +94 -0
- package/src/data/googlebot.json +950 -0
- package/src/data/special-crawlers.json +815 -0
- package/src/data/user-triggered-fetchers.json +3161 -0
- package/src/ranges.ts +142 -0
- package/src/rdns.ts +167 -0
- package/src/types.ts +45 -0
- package/src/ua.ts +75 -0
- package/src/verify.ts +209 -0
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
|
+
}
|