Skip to content

Commit da06870

Browse files
committed
fix(bot): use tiered ASN classification
1 parent 5ee000c commit da06870

4 files changed

Lines changed: 124 additions & 13 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"@noble/hashes": "^2.2.0",
7070
"@number-flow/react": "^0.6.0",
7171
"@remixicon/react": "^4.9.0",
72-
"asn-blocklist": "^1.20260703.2",
72+
"asn-blocklist": "^1.20260703.3",
7373
"boring-avatars": "^2.0.4",
7474
"class-variance-authority": "^0.7.1",
7575
"clsx": "^2.1.1",

src/lib/edge/__tests__/bot-protection.test.ts

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isHostingASN } from "asn-blocklist";
1+
import { classifyASN } from "asn-blocklist";
22
import { describe, expect, it, vi } from "vitest";
33

44
import {
@@ -10,7 +10,13 @@ import {
1010
import type { Env, TrackerClientPayload } from "@/lib/edge/types";
1111

1212
vi.mock("asn-blocklist", () => ({
13-
isHostingASN: vi.fn((asn: unknown) => Number(asn) === 13335),
13+
classifyASN: vi.fn((asn: unknown) => {
14+
if (Number(asn) === 13335) return "hosting";
15+
if (Number(asn) === 9009) return "network_service";
16+
if (Number(asn) === 4134) return "transit";
17+
if (Number(asn) === 7922) return "access";
18+
return "unknown";
19+
}),
1420
}));
1521

1622
const CHROME_UA =
@@ -103,11 +109,11 @@ describe("bot protection", () => {
103109
confidence: "medium",
104110
});
105111
expect(result.reasons).toContain("hosting_asn");
106-
expect(vi.mocked(isHostingASN)).toHaveBeenCalledWith(13335);
112+
expect(vi.mocked(classifyASN)).toHaveBeenCalledWith(13335);
107113
});
108114

109-
it("does not classify AS organization names without a blocked ASN", () => {
110-
vi.mocked(isHostingASN).mockReturnValueOnce(false);
115+
it("does not classify AS organization names without a risky ASN class", () => {
116+
vi.mocked(classifyASN).mockReturnValueOnce("unknown");
111117
const result = classifyCollectBotTraffic({
112118
request: request(
113119
{
@@ -131,6 +137,99 @@ describe("bot protection", () => {
131137
});
132138
});
133139

140+
it("keeps network-service ASNs on the main lane when browser provenance is present", () => {
141+
const result = classifyCollectBotTraffic({
142+
request: request(
143+
{
144+
"user-agent": CHROME_UA,
145+
origin: "https://example.com",
146+
"sec-fetch-site": "cross-site",
147+
},
148+
{
149+
asn: 9009,
150+
asOrganization: "M247 Global",
151+
},
152+
),
153+
payload,
154+
origin: "https://example.com",
155+
});
156+
157+
expect(result).toEqual({
158+
isBot: false,
159+
confidence: "low",
160+
reasons: ["network_service_asn"],
161+
});
162+
});
163+
164+
it("classifies network-service ASNs with missing browser provenance as medium confidence", () => {
165+
const result = classifyCollectBotTraffic({
166+
request: request(
167+
{
168+
"user-agent": CHROME_UA,
169+
},
170+
{
171+
asn: 9009,
172+
asOrganization: "M247 Global",
173+
},
174+
),
175+
payload,
176+
origin: "https://example.com",
177+
});
178+
179+
expect(result).toMatchObject({
180+
isBot: true,
181+
confidence: "medium",
182+
});
183+
expect(result.reasons).toEqual([
184+
"network_service_asn",
185+
"missing_browser_provenance",
186+
]);
187+
});
188+
189+
it("does not divert transit or access ASNs on ASN class alone", () => {
190+
const transit = classifyCollectBotTraffic({
191+
request: request(
192+
{
193+
"user-agent": CHROME_UA,
194+
origin: "https://example.com",
195+
"sec-fetch-site": "cross-site",
196+
},
197+
{
198+
asn: 4134,
199+
asOrganization: "China Telecom",
200+
},
201+
),
202+
payload,
203+
origin: "https://example.com",
204+
});
205+
const access = classifyCollectBotTraffic({
206+
request: request(
207+
{
208+
"user-agent": CHROME_UA,
209+
origin: "https://example.com",
210+
"sec-fetch-site": "cross-site",
211+
},
212+
{
213+
asn: 7922,
214+
asOrganization: "Comcast",
215+
},
216+
),
217+
payload,
218+
origin: "https://example.com",
219+
});
220+
221+
expect(transit).toEqual({
222+
isBot: false,
223+
confidence: "low",
224+
reasons: ["transit_asn"],
225+
});
226+
expect(access).toEqual({
227+
isBot: false,
228+
confidence: "low",
229+
reasons: ["access_asn"],
230+
});
231+
});
232+
134233
it("keeps normal browser requests on the main lane", () => {
135234
const result = classifyCollectBotTraffic({
136235
request: request({

src/lib/edge/bot-protection.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isHostingASN } from "asn-blocklist";
1+
import { classifyASN } from "asn-blocklist";
22
import { isbot } from "isbot";
33

44
import type { Env, TrackerClientPayload } from "./types";
@@ -169,10 +169,16 @@ export function classifyCollectBotTraffic(input: {
169169
if (score !== null && score <= 29) reasons.push("cf_bot_score_low");
170170
if (cfVerifiedBotCategory(cf)) reasons.push("cf_verified_bot_category");
171171

172-
const hostedByAsn = typeof asn === "number" && isHostingASN(asn);
172+
const asnClass = typeof asn === "number" ? classifyASN(asn) : "unknown";
173+
const hostedByAsn = asnClass === "hosting";
174+
const networkServiceAsn = asnClass === "network_service";
173175
if (hostedByAsn) reasons.push("hosting_asn");
176+
else if (networkServiceAsn) reasons.push("network_service_asn");
177+
else if (asnClass === "transit") reasons.push("transit_asn");
178+
else if (asnClass === "access") reasons.push("access_asn");
174179

175-
if (!hasBrowserProvenance(input.request)) {
180+
const missingBrowserProvenance = !hasBrowserProvenance(input.request);
181+
if (missingBrowserProvenance) {
176182
reasons.push("missing_browser_provenance");
177183
}
178184

@@ -196,6 +202,12 @@ export function classifyCollectBotTraffic(input: {
196202
if (hostedByAsn) {
197203
return { isBot: true, confidence: "medium", reasons };
198204
}
205+
if (
206+
networkServiceAsn &&
207+
(missingBrowserProvenance || reasons.includes("origin_hostname_mismatch"))
208+
) {
209+
return { isBot: true, confidence: "medium", reasons };
210+
}
199211

200212
return reasons.length > 0
201213
? { isBot: false, confidence: "low", reasons }

0 commit comments

Comments
 (0)