Skip to content

fix(middleware): make crawler detection better and isolate bot traffic - #949

Open
thostetler wants to merge 6 commits into
adsabs:masterfrom
thostetler:fix/bot-detection-ua-match
Open

thostetler wants to merge 6 commits into
adsabs:masterfrom
thostetler:fix/bot-detection-ua-match

Conversation

@thostetler

Copy link
Copy Markdown
Member

Crawler user agents were matched by exact map lookup against the whole UA string, which was probably making some bots not match

  • Fixes the existing IP checks and improves the reverse DNS check
  • Adds a better way to classify bots as much as we can using our known list of UAs

@thostetler
thostetler requested a review from shinyichen October 5, 2026 22:57
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Bundle size

Shared by all pages: 627.8 kB (+0.1 kB) ⚪

No route changed by more than 1 kB. ✅

First load = polyfills + shared _app chunks + route chunks, gzipped.

@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.37367% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.1%. Comparing base (a815fbe) to head (9aa1cd6).

Files with missing lines Patch % Lines
src/middlewares/cidr.ts 91.2% 4 Missing and 4 partials ⚠️
src/pages/api/isBot.ts 92.2% 2 Missing and 1 partial ⚠️
src/middlewares/botCheckSignature.ts 91.7% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master    #949     +/-   ##
========================================
+ Coverage    55.2%   56.1%   +1.0%     
========================================
  Files         378     383      +5     
  Lines       11618   11841    +223     
  Branches     2554    2606     +52     
========================================
+ Hits         6406    6639    +233     
+ Misses       4574    4559     -15     
- Partials      638     643      +5     
Files with missing lines Coverage Δ
src/api/api.ts 77.1% <100.0%> (+2.2%) ⬆️
src/logger.ts 41.7% <100.0%> (+5.4%) ⬆️
src/middlewares/botCheck.ts 100.0% <100.0%> (ø)
src/middlewares/clientIp.ts 100.0% <100.0%> (ø)
src/middlewares/crawlers.ts 100.0% <100.0%> (ø)
src/middlewares/initSession.ts 84.5% <100.0%> (+2.4%) ⬆️
src/middlewares/reverseDns.ts 100.0% <100.0%> (ø)
src/pages/api/user.ts 100.0% <100.0%> (ø)
src/ssr-utils.ts 84.8% <100.0%> (+2.6%) ⬆️
src/middlewares/botCheckSignature.ts 91.7% <91.7%> (ø)
... and 2 more

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thostetler thostetler changed the title fix(middleware): make crawler detection work and isolate bot traffic fix(middleware): make crawler detection better and isolate bot traffic Oct 8, 2026
@thostetler
thostetler marked this pull request as ready for review October 8, 2026 17:58
Copilot AI balanced review requested due to automatic review settings October 8, 2026 17:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Bot isolation and shared-credential handling still have unresolved correctness and security issues.

4 open findings
What changed in this PR

This security-sensitive PR improves crawler verification and separates bot traffic from normal user sessions.

Changes:

  • Adds broader crawler detection, trusted-IP handling, signed checks, and corrected DNS verification.
  • Persists dedicated bot sessions while withholding shared credentials from client responses.
  • Adds automated IP-range refreshes and regression coverage.
File Description
vitest.config.js Excludes build output from tests.
src/​types/​global.d.ts Types the trusted-IP header setting.
src/​ssr-utils.ts Omits bot credentials from client state.
src/​pages/​api/​user.ts Withholds bot credentials from responses.
src/​pages/​api/​isBot.ts Authenticates checks and delegates verification.
src/​pages/​api/​auth/​logout.ts Clears the bot-session flag.
src/​pages/​api/​auth/​login.ts Clears the bot-session flag.
src/​middlewares/​reverseDns.ts Adds forward-confirmed reverse DNS.
src/​middlewares/​initSession.ts Persists bot sessions without bootstrap.
src/​middlewares/​crawlers.ts Centralizes crawler classification.
src/​middlewares/​crawler-ranges.generated.json Stores published crawler IP ranges.
src/​middlewares/​clientIp.ts Distinguishes trusted IP sources.
src/​middlewares/​cidr.ts Implements IP parsing and range matching.
src/​middlewares/​botCheckSignature.ts Signs and validates bot checks.
src/​middlewares/​botCheck.ts Selects expiring bot-tier credentials.
src/​middlewares/​__tests__/​reverseDns.test.ts Tests DNS verification.
src/​middlewares/​__tests__/​initSession.integration.test.ts Tests bot-session transitions.
src/​middlewares/​__tests__/​crawlers.test.ts Tests crawler classification.
src/​middlewares/​__tests__/​crawler-ranges.test.ts Validates range snapshots and freshness.
src/​middlewares/​__tests__/​crawler-breadth.test.ts Measures crawler detection coverage.
src/​middlewares/​__tests__/​clientIp.test.ts Tests IP-source trust handling.
src/​middlewares/​__tests__/​cidr.test.ts Tests address parsing and matching.
src/​middlewares/​__tests__/​botCheck.test.ts Tests bot checks and token selection.
src/​__tests__/​ssr-utils.test.ts Tests credential omission during serialization.
src/​__tests__/​api-isBot.test.ts Tests signed requests and classification.
scripts/​fetch-crawler-ranges.mjs Refreshes published IP ranges.
pnpm-lock.yaml Locks added dependencies.
package.json Adds detection and test dependencies.
.github/​workflows/​crawler-ranges.yml Schedules range-refresh pull requests.
.env.local.sample Adds trusted-IP header configuration.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/middlewares/cidr.ts Outdated

const groupCount = head.length + tail.length;
const trailingGroups = trailing === null ? 0 : 2;
if (halves.length === 2 ? groupCount + trailingGroups > 8 : groupCount + trailingGroups !== 8) {
const botToken = await botCheck(req);

if (botToken) {
session.token = botToken;
Comment thread src/pages/api/user.ts Outdated
return res.json({
isAuthenticated: session.isAuthenticated,
user: session.token,
user: session.bot ? undefined : session.token,
Comment thread src/middlewares/reverseDns.ts Outdated

let ptrRecords: readonly string[];
try {
ptrRecords = await lookupPtr(remoteIp);
Crawler user agents were matched by exact map lookup against the whole UA string, so no real crawler ever matched. Fixing that exposed three more defects: IP checks compared bare addresses instead of CIDR ranges, reverse DNS accepted any host with a PTR record, and classification was discarded by a second iron-session write.

Verified-tier classification now requires TRUSTED_CLIENT_IP_HEADER, since X-Forwarded-For's leftmost entry is client-supplied. Without that header, matched crawlers are capped at the unverifiable tier instead of getting an elevated-quota token.
/api/isBot sits outside the middleware matcher, so it was reachable
unauthenticated and would drive up to six resolver lookups per request on
a caller-supplied IP. The hop is now signed with an HMAC over the
timestamp, IP and user agent, keyed on COOKIE_SECRET, and the handler
rejects anything unsigned, stale or altered before touching DNS. The
response status is checked too, so a rejected hop no longer reads a 403
body as a HUMAN verdict.

A successful bootstrap also clears session.bot. Without it a session that
was once classified as a bot kept the flag after its token expired and
was replaced by a human one, and isUserIdentifiedAsBot then skipped the
apiCookieHash comparison for the rest of that token's life.
A zero run must stand for at least one group, but the parser also accepted eight explicit groups alongside it. 0:0:0:0:0:ffff:14ab:ce07:: and 0:0:0:0:0:ffff::20.171.206.7 both resolved to 20.171.206.7 and matched a published GPTBot prefix.
parseIp collapses an IPv4-mapped address to four bytes, which is what selects the A record family, but the PTR lookup still received the original form and queried ip6.arpa. A legitimate crawler arriving IPv4-mapped therefore failed verification and was handed the malicious tier. The existing tests mocked the lookup to resolve regardless of its argument, so they did not catch it.
The session and user-data debug payloads carried access_token and neither logger redacted it. Bot tier tokens are shared across all crawler traffic and rotate only on redeploy, so the exposure matters more than it would for a per-session token. Both call sites now log summaries rather than the objects, because pino redaction is inert in browser mode and that is the mode the edge logger runs in. Redaction is added to the Node logger, where it works, covering paths not enumerated here.
Withholding the bot token from the client still left a direct browser call to the bootstrap endpoint, so a crawler that executes JavaScript picked up an ordinary anonymous token and left the bot tier for every client-side request. The session endpoint now reports the classification and the client stops negotiating once it knows. Client-side queries fail for those sessions instead of succeeding on the anonymous quota.
@thostetler
thostetler force-pushed the fix/bot-detection-ua-match branch from 7c5e0c6 to 9aa1cd6 Compare October 14, 2026 16:11

This branch has not been deployed

No deployments
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.

2 participants