Skip to content

feat: verify claimed crawler identity against published IP ranges - #18

Merged
Gdewilde merged 1 commit into
mainfrom
feat/verify-bot-identity
Aug 2, 2026
Merged

feat: verify claimed crawler identity against published IP ranges#18
Gdewilde merged 1 commit into
mainfrom
feat/verify-bot-identity

Conversation

@Gdewilde

@Gdewilde Gdewilde commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Closes the last open item from the agent-analytics audit: every is_ai_bot number 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:

void trackVisit(request, { analytics, verifyIdentity: true })

Emits bot_verification (verified | spoofed | unverifiable | not-claimed), bot_verified (tri-state), and bot_verification_reason. Off by default — the verdict is meaningless unless your edge controls x-forwarded-for.

Measured against 30 days of production traffic

I ran the check over real agent_visit data before finalising the design, and it changed the design twice.

user agent events distinct IPs in published range
ChatGPT-User ~72,000 43 99%
ClaudeBot 13,671 236 96%
PerplexityBot 6,897 158 91%
Claude-User (claude-code CLI) 6,492 4,486 0%
Perplexity-User 493 148 0%

Client-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 unverifiable with reason client-side-agent.

The distinction is not a -User suffix: OpenAI fetches server-side from Azure, so ChatGPT-User verifies 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 why bot_verified is 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, stamped BOT_RANGES_CAPTURED_AT. Regenerate with node 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 spoofed verdicts 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), /0 and /32, and the signed-shift traps that break naive mask code (0xffffffff << 32 is a no-op in JS, and 255.x overflows 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 one Google bot_name, so a verdict would be ambiguous about which crawler was verified. Left out rather than shipped misleading.

🤖 Generated with Claude Code

…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.
@Gdewilde
Gdewilde merged commit 8364c74 into main Aug 2, 2026
3 checks passed
@Gdewilde
Gdewilde deleted the feat/verify-bot-identity branch August 2, 2026 04:17
@Gdewilde

Gdewilde commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Range freshness now has a mechanism.

This PR flagged the bundled snapshot as "the part that will rot". #19 adds a weekly refresh-bot-ranges workflow that re-fetches the vendor feeds and opens a PR when prefixes actually move (timestamp-only diffs are discarded), with typecheck + tests run against the new snapshot before the PR opens.

Two limits worth recording against this feature:

  1. A rotation takes two steps to reach production — merge the refresh PR, then bump package.json to publish. The window between a vendor rotating and the deploy landing is when false spoofed verdicts appear.
  2. The range data ships to consumers who don't use the feature. It's re-exported from the package root, so it can't be tree-shaken — the starter's middleware went 37.4 → 40.9 kB on this bump. Moving it behind a @apideck/agent-analytics/verify subpath (the package already does this for /markdown, /posthog, /webhook) would fix it, at the cost of an API change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant