feat: verify claimed crawler identity against published IP ranges - #18
Merged
Conversation
…11.0) UA strings are forgeable — `curl -A "ChatGPT-User"` is indistinguishable from the real thing at the UA layer, so every is_ai_bot number is a claim rather than a fact. Opt in with `verifyIdentity: true` to check the client IP against the vendor's published crawler ranges. Emits bot_verification (verified | spoofed | unverifiable | not-claimed), bot_verified (tri-state boolean), and bot_verification_reason. Off by default: the verdict is meaningless unless the caller's edge controls x-forwarded-for. Bundles range snapshots for the four vendors that publish machine-readable feeds — OpenAI (372 prefixes), Anthropic (20), Perplexity (8), Apple (12) — with scripts/refresh-bot-ranges.mjs to regenerate. The script aborts on a failed feed or a list that shrinks by more than half, because a stale or truncated snapshot produces false 'spoofed' verdicts on real crawlers, which is the failure mode that actually costs you. Two deliberate design constraints, both derived from 30 days of production traffic rather than assumed: - Vendors with no published feed report 'unverifiable', never 'spoofed'. bot_verified is tri-state for this reason; collapsing "can't check" into "impostor" would falsely accuse Bytespider, Amazonbot and Meta. - Products that fetch from the end user's device are excluded from verdicts. Claude Code runs on a developer's laptop, so the request carries their IP: 6,492 events across 4,486 IPs, 0% in Anthropic's ranges, versus ClaudeBot at 96% from 236 IPs. Perplexity-User behaves the same (0% from 148 IPs). A vendor-level check would have branded ~7k legitimate fetches a month as impersonation. The gate is per product, not per vendor — a `-User` suffix is not the signal, since OpenAI's ChatGPT-User fetches server-side and verifies at ~99%. CIDR matching is dependency-free and lives in its own module so it can be tested in isolation: IPv4 and IPv6, `::` compression, IPv4-mapped addresses from dual-stack edges, /0 and /32 edge cases, and the signed-shift traps that break naive mask implementations. Ranges are pre-compiled once at module load, not per request. Tests: 214 -> 216 files/216 total, including boundary cases either side of every prefix length and the client-side-agent carve-out.
Contributor
Author
|
Range freshness now has a mechanism. This PR flagged the bundled snapshot as "the part that will rot". #19 adds a weekly Two limits worth recording against this feature:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last open item from the agent-analytics audit: every
is_ai_botnumber today is a claim, not a fact.curl -A "ChatGPT-User"is indistinguishable from the real thing at the UA layer.Opt in per call:
Emits
bot_verification(verified|spoofed|unverifiable|not-claimed),bot_verified(tri-state), andbot_verification_reason. Off by default — the verdict is meaningless unless your edge controlsx-forwarded-for.Measured against 30 days of production traffic
I ran the check over real
agent_visitdata before finalising the design, and it changed the design twice.ChatGPT-UserClaudeBotPerplexityBotClaude-User(claude-code CLI)Perplexity-UserClient-side agents can't be IP-verified. Claude Code runs on a developer's laptop, so the request carries their IP and will never appear in Anthropic's ranges. A naive vendor-level check — which is what I built first — would have branded ~7,000 legitimate fetches a month as impersonation. Verdicts are now gated per product, returning
unverifiablewith reasonclient-side-agent.The distinction is not a
-Usersuffix: OpenAI fetches server-side from Azure, soChatGPT-Userverifies at ~99%. It has to be an explicit per-product list.Vendors without a feed are never accused. Only OpenAI, Anthropic, Perplexity and Apple publish machine-readable ranges. Bytespider, Amazonbot, Meta and the rest report
unverifiable. That's whybot_verifiedis tri-state rather than a boolean — collapsing "can't check" into "impostor" is a false accusation.A caution on the numbers above: my first pass sampled only the top-volume IPs and reported Claude and Perplexity at 100% verified. The full population tells a different story — the high-volume IPs are the real fleets, and the long tail is where the unverified traffic sits. Worth remembering when reading any of these percentages.
Freshness
Bundled snapshot in
src/bot-ranges.ts, stampedBOT_RANGES_CAPTURED_AT. Regenerate withnode scripts/refresh-bot-ranges.mjs.This is the part that will rot. Nearly every OpenAI prefix is an Azure block and Anthropic's are GCP, so "came from a datacenter" proves nothing on its own — only membership in the current list does. A stale snapshot produces false
spoofedverdicts on real crawlers, so the refresh script aborts on any failed feed or a list that shrinks by more than half rather than silently emptying a vendor. Worth wiring to a scheduled workflow.Implementation
CIDR matching is dependency-free and in its own module so it's testable in isolation — a bug there silently turns real crawlers into impostors. Covers IPv4 and IPv6,
::compression, IPv4-mapped addresses (dual-stack edges emit::ffff:a.b.c.d),/0and/32, and the signed-shift traps that break naive mask code (0xffffffff << 32is a no-op in JS, and255.xoverflows negative). Ranges pre-compile once at module load, not per request.Tests: 216 passing, including boundary cases either side of every prefix length and the client-side-agent carve-out.
Not covered
Google publishes ranges (147 prefixes in
special-crawlers.json) but its labels collapse Googlebot and Google-Extended into oneGooglebot_name, so a verdict would be ambiguous about which crawler was verified. Left out rather than shipped misleading.🤖 Generated with Claude Code