Skip to content

Commit 5c3be31

Browse files
authored
Merge pull request #589 from Swetrix/feature/ip-whitelist
feat: IP whitelist to bypass bot protection
2 parents e8bb046 + d251e78 commit 5c3be31

26 files changed

Lines changed: 265 additions & 15 deletions

backend/apps/cloud/src/analytics/bot-detection.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { parse as parseDomain } from 'tldts'
77
import { BotsProtectionLevel, Project } from '../project/entity/project.entity'
88
import { ProjectService } from '../project/project.service'
99
import { getIPDetails } from '../common/utils'
10+
import { isIpInRange } from '../common/ip-range'
1011

1112
type BotReason =
1213
| 'user_agent'
@@ -233,6 +234,18 @@ export class BotDetectionService {
233234

234235
if (level === BotsProtectionLevel.OFF) return NEGATIVE
235236

237+
// Whitelisted IPs (e.g. customer backends doing server-side tracking)
238+
// bypass the entire detection chain.
239+
const ipWhitelist = (project.ipWhitelist || []).filter(Boolean)
240+
241+
if (
242+
input.ip &&
243+
ipWhitelist.length > 0 &&
244+
isIpInRange(input.ip, ipWhitelist)
245+
) {
246+
return NEGATIVE
247+
}
248+
236249
const ua = (input.userAgent || '').trim()
237250

238251
if (ua && isbot(ua)) {

backend/apps/cloud/src/analytics/protection/analytics-read.util.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
import _isString from 'lodash/isString'
22

33
import { PID_REGEX } from '../../common/constants'
4-
import { getIPFromHeaders } from '../../common/utils'
5-
6-
const TRUSTED_PROXY_IPS = new Set(
7-
(process.env.TRUSTED_PROXY_IPS || '')
8-
.split(',')
9-
.map((entry) => entry.trim())
10-
.filter(Boolean),
11-
)
4+
import { getIPFromHeaders, TRUSTED_PROXY_IPS } from '../../common/utils'
125

136
const firstHeaderValue = (value: unknown): string => {
147
if (_isString(value) && value) {

backend/apps/cloud/src/common/utils.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,17 @@ const getHeader = (headers: any, name: string): string | null | undefined => {
413413
return headers?.[name] ?? headers?.[name.toLowerCase()]
414414
}
415415

416+
// Egress IPs of our own relays (the web app's server-side fetches, the
417+
// managed proxy edge). Requests arriving from these carry the real visitor
418+
// IP in X-Forwarded-For; for every other connection the only trustworthy
419+
// source is X-Real-IP, which nginx derives from the TCP connection itself.
420+
export const TRUSTED_PROXY_IPS: ReadonlySet<string> = new Set(
421+
(process.env.TRUSTED_PROXY_IPS || '')
422+
.split(',')
423+
.map((entry) => entry.trim())
424+
.filter(Boolean),
425+
)
426+
416427
export const getIPFromHeaders = (headers: unknown) => {
417428
const customHeader = process.env.CLIENT_IP_HEADER
418429

@@ -423,7 +434,33 @@ export const getIPFromHeaders = (headers: unknown) => {
423434
}
424435
}
425436

426-
return normalise(getHeader(headers, 'x-forwarded-for'))
437+
const realIp = normalise(getHeader(headers, 'x-real-ip'))
438+
439+
// Without X-Real-IP nothing identifies the connection peer, so
440+
// X-Forwarded-For is entirely client-supplied and cannot be trusted.
441+
// Callers fall back to the socket address instead.
442+
if (!realIp) {
443+
return null
444+
}
445+
446+
if (!TRUSTED_PROXY_IPS.has(realIp)) {
447+
return realIp
448+
}
449+
450+
// The connection is one of our own relays. Recover the visitor from
451+
// X-Forwarded-For, walking from the right past our own hops so a
452+
// client-supplied prefix never wins.
453+
const forwardedFor = String(getHeader(headers, 'x-forwarded-for') ?? '')
454+
const hops = forwardedFor.split(',')
455+
456+
for (let i = hops.length - 1; i >= 0; --i) {
457+
const candidate = normalise(hops[i])
458+
if (candidate && !TRUSTED_PROXY_IPS.has(candidate)) {
459+
return candidate
460+
}
461+
}
462+
463+
return realIp
427464
}
428465

429466
export const sumArrays = (source: number[], target: number[]) => {

backend/apps/cloud/src/project/dto/create-project.dto.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ export class CreateProjectDTO extends ProjectOrganisationDto {
4949
})
5050
ipBlacklist?: string[]
5151

52+
@ApiProperty({
53+
required: false,
54+
})
55+
ipWhitelist?: string[]
56+
5257
@ApiProperty({
5358
required: false,
5459
})

backend/apps/cloud/src/project/dto/project.dto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export class ProjectDTO {
5353
})
5454
ipBlacklist: string[] | null
5555

56+
@ApiProperty({
57+
example: ['::1', '127.0.0.1', '192.168.0.1/32'],
58+
required: false,
59+
description:
60+
'Array of IP addresses (or CIDR ranges) that are never flagged by bot protection. Useful for server-side event tracking.',
61+
})
62+
ipWhitelist: string[] | null
63+
5664
@ApiProperty({
5765
example: ['RU', 'BY', 'KP'],
5866
required: false,

backend/apps/cloud/src/project/entity/project.entity.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export class Project {
4747
@Column('simple-array', { nullable: true, default: null })
4848
ipBlacklist: string[]
4949

50+
// IPs (or CIDR ranges) that bot detection (Shields) never flags
51+
@ApiProperty()
52+
@Column('simple-array', { nullable: true, default: null })
53+
ipWhitelist: string[]
54+
5055
@ApiProperty()
5156
@Column('simple-array', { nullable: true, default: null })
5257
countryBlacklist: string[]

backend/apps/cloud/src/project/project.controller.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,6 +1808,17 @@ export class ProjectController {
18081808
: null
18091809
}
18101810

1811+
if (projectDTO.ipWhitelist !== undefined) {
1812+
project.ipWhitelist =
1813+
Array.isArray(projectDTO.ipWhitelist) &&
1814+
projectDTO.ipWhitelist.length > 0
1815+
? (_map(
1816+
projectDTO.ipWhitelist.slice(0, BLACKLIST_ITEMS_MAXIMUM),
1817+
_trim,
1818+
) as string[])
1819+
: null
1820+
}
1821+
18111822
if (projectDTO.countryBlacklist !== undefined) {
18121823
project.countryBlacklist =
18131824
projectDTO.countryBlacklist &&

backend/apps/cloud/src/project/project.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export class ProjectService {
195195
'project.active',
196196
'project.public',
197197
'project.ipBlacklist',
198+
'project.ipWhitelist',
198199
'project.countryBlacklist',
199200
'project.botsProtectionLevel',
200201
'project.captchaSecretKey',
@@ -789,6 +790,29 @@ export class ProjectService {
789790
})
790791
}
791792

793+
validateIPWhitelist(
794+
projectDTO: ProjectDTO | UpdateProjectDto | CreateProjectDTO,
795+
) {
796+
if (!projectDTO.ipWhitelist) {
797+
return
798+
}
799+
800+
if (!Array.isArray(projectDTO.ipWhitelist))
801+
throw new UnprocessableEntityException(
802+
'The list of whitelisted IP addresses must be an array.',
803+
)
804+
805+
if (_size(_join(projectDTO.ipWhitelist, ',')) > 300)
806+
throw new UnprocessableEntityException(
807+
'The list of whitelisted IP addresses must be less than 300 characters.',
808+
)
809+
_map(projectDTO.ipWhitelist, (ip) => {
810+
if (!net.isIP(_trim(ip)) && !IP_REGEX.test(_trim(ip))) {
811+
throw new ConflictException(`IP address ${ip} is not correct`)
812+
}
813+
})
814+
}
815+
792816
validateCountryBlacklist(
793817
projectDTO: ProjectDTO | UpdateProjectDto | CreateProjectDTO,
794818
) {
@@ -831,6 +855,7 @@ export class ProjectService {
831855

832856
this.validateOrigins(projectDTO)
833857
this.validateIPBlacklist(projectDTO)
858+
this.validateIPWhitelist(projectDTO)
834859
this.validateCountryBlacklist(projectDTO)
835860
}
836861

backend/apps/community/src/analytics/bot-detection.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { parse as parseDomain } from 'tldts'
77
import { BotsProtectionLevel, Project } from '../project/entity/project.entity'
88
import { ProjectService } from '../project/project.service'
99
import { getIPDetails } from '../common/utils'
10+
import { isIpInRange } from '../common/ip-range'
1011

1112
type BotReason =
1213
| 'user_agent'
@@ -216,6 +217,18 @@ export class BotDetectionService {
216217

217218
if (level === BotsProtectionLevel.OFF) return NEGATIVE
218219

220+
// Whitelisted IPs (e.g. customer backends doing server-side tracking)
221+
// bypass the entire detection chain.
222+
const ipWhitelist = (project.ipWhitelist || []).filter(Boolean)
223+
224+
if (
225+
input.ip &&
226+
ipWhitelist.length > 0 &&
227+
isIpInRange(input.ip, ipWhitelist)
228+
) {
229+
return NEGATIVE
230+
}
231+
219232
const ua = (input.userAgent || '').trim()
220233

221234
if (ua && isbot(ua)) {

backend/apps/community/src/common/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ const ALLOWED_KEYS = [
123123
'name',
124124
'origins',
125125
'ipBlacklist',
126+
'ipWhitelist',
126127
'countryBlacklist',
127128
'active',
128129
'public',
@@ -498,6 +499,7 @@ const createProjectClickhouse = async (project: Partial<Project>) => {
498499
name,
499500
origins: '',
500501
ipBlacklist: '',
502+
ipWhitelist: '',
501503
countryBlacklist: '',
502504
active: 1,
503505
public: 0,

0 commit comments

Comments
 (0)