Skip to content

Commit f72310c

Browse files
committed
feat: bot detection
1 parent 74ec2fb commit f72310c

13 files changed

Lines changed: 818 additions & 55 deletions
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { detectHeaderAnomalies } from './header-signals';
3+
4+
// Representative UA strings.
5+
const UA = {
6+
chromeDesktop:
7+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
8+
chromeAndroid:
9+
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
10+
edgeDesktop:
11+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
12+
firefox:
13+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
14+
safariMac:
15+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
16+
chromeIOS:
17+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1',
18+
curl: 'curl/8.4.0',
19+
goClient: 'Go-http-client/1.1',
20+
};
21+
22+
// Headers a real browser fetch/sendBeacon request carries.
23+
const realBrowserHeaders = {
24+
'sec-ch-ua': '"Chromium";v="120"',
25+
'sec-fetch-mode': 'cors',
26+
'sec-fetch-site': 'cross-site',
27+
'accept-language': 'en-US,en;q=0.9',
28+
};
29+
30+
describe('detectHeaderAnomalies', () => {
31+
describe('does not flag legitimate browser traffic', () => {
32+
it('clean desktop Chrome → no reasons', () => {
33+
expect(
34+
detectHeaderAnomalies({
35+
'user-agent': UA.chromeDesktop,
36+
...realBrowserHeaders,
37+
}),
38+
).toEqual([]);
39+
});
40+
41+
it('Firefox (no sec-ch-ua, but sends everything else) → no reasons', () => {
42+
expect(
43+
detectHeaderAnomalies({
44+
'user-agent': UA.firefox,
45+
'sec-fetch-mode': 'cors',
46+
'sec-fetch-site': 'cross-site',
47+
'accept-language': 'en-US,en;q=0.5',
48+
}),
49+
).toEqual([]);
50+
});
51+
52+
it('Safari (no sec-ch-ua, WebKit) → no reasons', () => {
53+
expect(
54+
detectHeaderAnomalies({
55+
'user-agent': UA.safariMac,
56+
'sec-fetch-mode': 'cors',
57+
'sec-fetch-site': 'cross-site',
58+
'accept-language': 'en-US,en;q=0.9',
59+
}),
60+
).toEqual([]);
61+
});
62+
63+
it('iOS Chrome / CriOS (WebKit, no sec-ch-ua) → not flagged for sec-ch-ua', () => {
64+
const reasons = detectHeaderAnomalies({
65+
'user-agent': UA.chromeIOS,
66+
'sec-fetch-mode': 'cors',
67+
'sec-fetch-site': 'cross-site',
68+
'accept-language': 'en-US,en;q=0.9',
69+
});
70+
expect(reasons).not.toContain('header:missing_sec_ch_ua');
71+
expect(reasons).toEqual([]);
72+
});
73+
});
74+
75+
describe('does not apply to non-browser clients', () => {
76+
for (const [name, ua] of Object.entries({ curl: UA.curl, go: UA.goClient })) {
77+
it(`${name} → no reasons (handled by isServer/auth, not headers)`, () => {
78+
expect(detectHeaderAnomalies({ 'user-agent': ua })).toEqual([]);
79+
});
80+
}
81+
82+
it('missing UA entirely → no reasons', () => {
83+
expect(detectHeaderAnomalies({})).toEqual([]);
84+
});
85+
});
86+
87+
describe('flags spoofed Chromium user agents', () => {
88+
it('Chrome UA with no sec-ch-ua → missing_sec_ch_ua', () => {
89+
const reasons = detectHeaderAnomalies({
90+
'user-agent': UA.chromeDesktop,
91+
'sec-fetch-mode': 'cors',
92+
'sec-fetch-site': 'cross-site',
93+
'accept-language': 'en-US',
94+
});
95+
expect(reasons).toContain('header:missing_sec_ch_ua');
96+
});
97+
98+
it('Edge UA with no sec-ch-ua → missing_sec_ch_ua', () => {
99+
const reasons = detectHeaderAnomalies({
100+
'user-agent': UA.edgeDesktop,
101+
'sec-fetch-mode': 'cors',
102+
'sec-fetch-site': 'cross-site',
103+
'accept-language': 'en-US',
104+
});
105+
expect(reasons).toContain('header:missing_sec_ch_ua');
106+
});
107+
108+
it('Android Chrome with no sec-ch-ua → missing_sec_ch_ua', () => {
109+
const reasons = detectHeaderAnomalies({
110+
'user-agent': UA.chromeAndroid,
111+
'sec-fetch-mode': 'cors',
112+
'sec-fetch-site': 'cross-site',
113+
'accept-language': 'en-US',
114+
});
115+
expect(reasons).toContain('header:missing_sec_ch_ua');
116+
});
117+
118+
it('bare Chrome UA with no browser headers at all → multiple reasons', () => {
119+
const reasons = detectHeaderAnomalies({ 'user-agent': UA.chromeDesktop });
120+
expect(reasons).toContain('header:missing_sec_ch_ua');
121+
expect(reasons).toContain('header:missing_sec_fetch');
122+
expect(reasons).toContain('header:missing_accept_language');
123+
});
124+
});
125+
126+
describe('individual header rules', () => {
127+
it('missing both sec-fetch headers → missing_sec_fetch', () => {
128+
const reasons = detectHeaderAnomalies({
129+
'user-agent': UA.firefox,
130+
'accept-language': 'en-US',
131+
});
132+
expect(reasons).toContain('header:missing_sec_fetch');
133+
});
134+
135+
it('one sec-fetch header present → no missing_sec_fetch', () => {
136+
const reasons = detectHeaderAnomalies({
137+
'user-agent': UA.firefox,
138+
'sec-fetch-site': 'cross-site',
139+
'accept-language': 'en-US',
140+
});
141+
expect(reasons).not.toContain('header:missing_sec_fetch');
142+
});
143+
144+
it('empty-string header counts as absent', () => {
145+
const reasons = detectHeaderAnomalies({
146+
'user-agent': UA.firefox,
147+
'sec-fetch-mode': '',
148+
'sec-fetch-site': ' ',
149+
'accept-language': 'en',
150+
});
151+
expect(reasons).toContain('header:missing_sec_fetch');
152+
});
153+
});
154+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Header-consistency bot signals.
2+
//
3+
// Real browsers send a predictable set of request headers. Automated clients
4+
// that spoof a browser User-Agent frequently forget the low-entropy client
5+
// hints (`sec-ch-ua`) and fetch-metadata headers (`sec-fetch-*`) that browsers
6+
// attach automatically. A mismatch between "claims to be Chrome" and "sends
7+
// what Chrome sends" is a strong bot signal.
8+
//
9+
// These checks only MARK traffic (they never block), so the cost of a false
10+
// positive is a mislabeled event. Even so, every rule is deliberately
11+
// UA-conditional to avoid mislabeling legitimate browsers:
12+
// - Firefox and Safari never send `sec-ch-ua` (it is a Blink/Chromium
13+
// feature), so the `sec-ch-ua` rule is gated to desktop/Android Chromium.
14+
// - iOS "Chrome" (CriOS) and other iOS browsers are WebKit under the hood and
15+
// also omit `sec-ch-ua`, so iOS is excluded from that rule too.
16+
//
17+
// Returned reasons are namespaced `header:<detail>` so the caller can count
18+
// distinct signal *categories* when deciding whether to flag an event.
19+
20+
type HeaderValue = string | string[] | undefined;
21+
type Headers = Record<string, HeaderValue>;
22+
23+
function getHeaderString(value: HeaderValue): string {
24+
if (Array.isArray(value)) {
25+
return value.join(',');
26+
}
27+
return typeof value === 'string' ? value : '';
28+
}
29+
30+
function hasHeader(value: HeaderValue): boolean {
31+
return getHeaderString(value).trim() !== '';
32+
}
33+
34+
// A browser-shaped UA. Non-browser clients (curl, python-requests, Go-http-
35+
// client) don't send this and are handled elsewhere (server-side auth /
36+
// `isServer`), so header rules simply don't apply to them.
37+
function looksLikeBrowser(ua: string): boolean {
38+
return ua.includes('Mozilla/');
39+
}
40+
41+
// Desktop/Android Chromium (Blink), which ALWAYS emits `sec-ch-ua` — including
42+
// on `fetch`/`sendBeacon` requests like the OpenPanel web SDK makes. iOS
43+
// browsers (CriOS/EdgiOS/…) are WebKit and excluded, as are Firefox/Safari.
44+
const CHROMIUM_UA_REGEX = /(?:Chrome|Chromium|Edg|OPR)\/\d/;
45+
const APPLE_WEBKIT_UA_REGEX = /(CriOS|EdgiOS|OPiOS|FxiOS|iPhone|iPad|iPod)/;
46+
47+
function isBlinkChromium(ua: string): boolean {
48+
return CHROMIUM_UA_REGEX.test(ua) && !APPLE_WEBKIT_UA_REGEX.test(ua);
49+
}
50+
51+
export function detectHeaderAnomalies(headers: Headers): string[] {
52+
const ua = getHeaderString(headers['user-agent']);
53+
if (!(ua && looksLikeBrowser(ua))) {
54+
return [];
55+
}
56+
57+
const reasons: string[] = [];
58+
59+
// Blink/Chromium claims to be Chrome but sent no client hints → spoofed UA.
60+
if (isBlinkChromium(ua) && !hasHeader(headers['sec-ch-ua'])) {
61+
reasons.push('header:missing_sec_ch_ua');
62+
}
63+
64+
// Fetch-metadata headers are sent by all modern browsers on fetch requests.
65+
// Their total absence points to a raw HTTP client. (Kept general but weak:
66+
// very old Safari/iOS may omit these, which is why headers only ever count
67+
// as a single signal category and never flag an event on their own.)
68+
if (
69+
!(
70+
hasHeader(headers['sec-fetch-mode']) ||
71+
hasHeader(headers['sec-fetch-site'])
72+
)
73+
) {
74+
reasons.push('header:missing_sec_fetch');
75+
}
76+
77+
// Browsers send Accept-Language by default; total absence is mildly suspect.
78+
if (!hasHeader(headers['accept-language'])) {
79+
reasons.push('header:missing_accept_language');
80+
}
81+
82+
return reasons;
83+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { AsnInfo } from '@openpanel/geo';
2+
import { describe, expect, it } from 'vitest';
3+
import {
4+
applyBotSuspicion,
5+
stripBotProperties,
6+
summarizeBotSignals,
7+
} from './suspicion';
8+
9+
describe('stripBotProperties', () => {
10+
it('removes client-supplied __bot keys in place, keeps the rest', () => {
11+
const properties: Record<string, unknown> = {
12+
__bot: '1',
13+
__bot_reasons: 'forged',
14+
keep: 'me',
15+
};
16+
stripBotProperties(properties);
17+
expect(properties).toEqual({ keep: 'me' });
18+
});
19+
20+
it('tolerates undefined properties', () => {
21+
expect(() => stripBotProperties(undefined)).not.toThrow();
22+
});
23+
});
24+
25+
describe('summarizeBotSignals', () => {
26+
it('no signals → not a bot, empty reasons', () => {
27+
expect(summarizeBotSignals([])).toEqual({ reasons: '', isBot: false });
28+
});
29+
30+
it('single category (datacenter only) → recorded but not flagged', () => {
31+
const result = summarizeBotSignals(['datacenter_ip:AS15169']);
32+
expect(result.isBot).toBe(false);
33+
expect(result.reasons).toBe('datacenter_ip:AS15169');
34+
});
35+
36+
it('multiple reasons in the SAME category → still one category, not flagged', () => {
37+
const result = summarizeBotSignals([
38+
'header:missing_sec_ch_ua',
39+
'header:missing_sec_fetch',
40+
'header:missing_accept_language',
41+
]);
42+
expect(result.isBot).toBe(false);
43+
});
44+
45+
it('two distinct categories → flagged', () => {
46+
const result = summarizeBotSignals([
47+
'datacenter_ip:AS16509',
48+
'header:missing_sec_ch_ua',
49+
]);
50+
expect(result.isBot).toBe(true);
51+
expect(result.reasons).toBe(
52+
'datacenter_ip:AS16509,header:missing_sec_ch_ua'
53+
);
54+
});
55+
});
56+
57+
const HOSTING: AsnInfo = {
58+
asn: 16_509,
59+
org: 'Amazon.com, Inc.',
60+
isHosting: true,
61+
};
62+
const RESIDENTIAL: AsnInfo = { asn: 7922, org: 'Comcast', isHosting: false };
63+
64+
// A spoofed desktop Chrome request with none of the headers Chrome sends.
65+
const spoofedChromeHeaders = {
66+
'user-agent':
67+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
68+
};
69+
// A clean browser request.
70+
const cleanChromeHeaders = {
71+
...spoofedChromeHeaders,
72+
'sec-ch-ua': '"Chromium";v="120"',
73+
'sec-fetch-mode': 'cors',
74+
'sec-fetch-site': 'cross-site',
75+
'accept-language': 'en-US,en;q=0.9',
76+
};
77+
78+
describe('applyBotSuspicion', () => {
79+
it('clean residential browser → no bot props', () => {
80+
const props = applyBotSuspicion(
81+
{ foo: 'bar' },
82+
{ asnInfo: RESIDENTIAL, headers: cleanChromeHeaders, isServer: false }
83+
);
84+
expect(props).toEqual({ foo: 'bar' });
85+
});
86+
87+
it('datacenter IP alone → reason recorded, but NOT flagged', () => {
88+
const props = applyBotSuspicion(
89+
{},
90+
{ asnInfo: HOSTING, headers: cleanChromeHeaders, isServer: false }
91+
);
92+
expect(props?.__bot_reasons).toBe('datacenter_ip:AS16509');
93+
expect(props?.__bot).toBeUndefined();
94+
});
95+
96+
it('datacenter IP + header anomaly → flagged __bot=1', () => {
97+
const props = applyBotSuspicion(
98+
{},
99+
{ asnInfo: HOSTING, headers: spoofedChromeHeaders, isServer: false }
100+
);
101+
expect(props?.__bot).toBe('1');
102+
expect(props?.__bot_reasons).toContain('datacenter_ip:AS16509');
103+
expect(props?.__bot_reasons).toContain('header:missing_sec_ch_ua');
104+
});
105+
106+
it('strips client-supplied __bot / __bot_reasons (server-controlled)', () => {
107+
const props = applyBotSuspicion(
108+
{ __bot: '1', __bot_reasons: 'faked_by_client', keep: 'me' },
109+
{ asnInfo: RESIDENTIAL, headers: cleanChromeHeaders, isServer: false }
110+
);
111+
expect(props?.__bot).toBeUndefined();
112+
expect(props?.__bot_reasons).toBeUndefined();
113+
expect(props?.keep).toBe('me');
114+
});
115+
116+
it('server-side (clientSecretAuth) traffic is never flagged, even from a datacenter', () => {
117+
const props = applyBotSuspicion(
118+
{},
119+
{
120+
asnInfo: HOSTING,
121+
headers: spoofedChromeHeaders,
122+
clientSecretAuth: true,
123+
isServer: false,
124+
}
125+
);
126+
expect(props).toEqual({});
127+
});
128+
129+
it('isServer UA is never flagged, even from a datacenter', () => {
130+
const props = applyBotSuspicion(
131+
{},
132+
{
133+
asnInfo: HOSTING,
134+
headers: { 'user-agent': 'Go-http-client/1.1' },
135+
isServer: true,
136+
}
137+
);
138+
expect(props).toEqual({});
139+
});
140+
141+
it('handles undefined properties', () => {
142+
const props = applyBotSuspicion(undefined, {
143+
asnInfo: HOSTING,
144+
headers: spoofedChromeHeaders,
145+
isServer: false,
146+
});
147+
expect(props?.__bot).toBe('1');
148+
});
149+
});

0 commit comments

Comments
 (0)