Skip to content

Commit a53037d

Browse files
committed
Require Scamalytics for residential proxy blocks
1 parent 0f331ed commit a53037d

5 files changed

Lines changed: 114 additions & 7 deletions

File tree

docs/environment-variables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase.
88
- `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic.
99
- `SPUR_TOKEN` is required; VPN/proxy/Tor/residential-proxy privacy signals use Spur Context API corroboration.
10-
- `SCAMALYTICS_API_KEY` is required; when IPinfo reports privacy or hosting/service signals, free-mode gating also checks Scamalytics for a fraud score and proxy/Tor/VPN evidence. In allowlisted countries, full access requires both Spur and Scamalytics to return clean follow-up results. Provider failures, ambiguous results, VPN/proxy/residential-proxy signals, and hosting/datacenter signals fall back to limited access. Only Cloudflare Tor or Tor corroborated by another provider is blocked entirely by the IP-intelligence gate.
10+
- `SCAMALYTICS_API_KEY` is required; when IPinfo reports privacy or hosting/service signals, free-mode gating also checks Scamalytics for a fraud score and proxy/Tor/VPN evidence. In allowlisted countries, full access requires both Spur and Scamalytics to return clean follow-up results. Provider failures, ambiguous results, VPN/generic-proxy signals, and hosting/datacenter signals fall back to limited access. Residential proxy is blocked only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score, as are Cloudflare Tor or Tor corroborated by another provider.
1111
- `CODEBUFF_FULL_TELEMETRY=true` or `CODEBUFF_FULL_TELEMETRY_IDS=user-id,email@example.com`
1212
disables client analytics sampling for targeted debugging. Use sparingly because it can send full CLI log payloads.
1313

docs/freebuff-waiting-room.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ All endpoints authenticate via the standard `Authorization: Bearer <api-key>` or
181181
- Existing active+unexpired row, **different model** → reject with `model_locked` (HTTP 409); `active_instance_id` is **not** rotated so the other CLI stays valid. Client must DELETE the session before switching.
182182
- Existing active+expired row → reset to queued with fresh `queued_at` and the requested `model` (re-queue at back).
183183

184-
Before any of those state transitions, the handler requires a resolved country and IPinfo privacy classification. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy/hosting/service signals trigger paid follow-up checks with Spur and Scamalytics. Full access is restored only when both follow-up providers return clean context; suspicious or failed follow-up checks fall back to limited access. The server records a 0-100 privacy risk score for observability/cache rows; named/recent IPinfo anonymizer observations raise that score, while generic Scamalytics third-party proxy labels do not override a low top-level Scamalytics score by themselves. VPN, proxy, residential proxy, and hosting/datacenter signals limit access by default; only Cloudflare Tor country detection or Tor corroborated by another provider is hard-blocked by the IP-intelligence gate.
184+
Before any of those state transitions, the handler requires a resolved country and IPinfo privacy classification. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy/hosting/service signals trigger paid follow-up checks with Spur and Scamalytics. Full access is restored when both follow-up providers return clean context; suspicious or failed follow-up checks fall back to limited access. The server records a 0-100 privacy risk score for observability/cache rows; named/recent IPinfo anonymizer observations raise that score, while generic Scamalytics third-party proxy labels do not override a low top-level Scamalytics score by themselves. VPN, generic proxy, and hosting/datacenter signals limit access when follow-up providers do not clear them. Residential proxy signals hard-block only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score. Cloudflare Tor country detection or Tor corroborated by another provider is also hard-blocked by the IP-intelligence gate.
185185

186186
Response shapes:
187187

web/src/app/api/v1/freebuff/session/__tests__/session.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,8 +506,8 @@ describe('GET /api/v1/freebuff/session', () => {
506506
blockReason: 'anonymous_network',
507507
cfCountry: 'US',
508508
geoipCountry: null,
509-
ipPrivacy: { signals: ['res_proxy'] },
510-
spurIpPrivacy: { signals: ['res_proxy'] },
509+
ipPrivacy: { signals: ['vpn'] },
510+
spurIpPrivacy: { signals: ['proxy'] },
511511
spurStatus: 'suspicious',
512512
...NOT_CHECKED_SCAMALYTICS_CONTEXT,
513513
hasClientIp: true,
@@ -520,7 +520,7 @@ describe('GET /api/v1/freebuff/session', () => {
520520
expect(body.status).toBe('none')
521521
expect(body.accessTier).toBe('limited')
522522
expect(body.countryBlockReason).toBe('anonymous_network')
523-
expect(body.ipPrivacySignals).toEqual(['res_proxy'])
523+
expect(body.ipPrivacySignals).toEqual(['vpn'])
524524
expect(sessionDeps.rows.size).toBe(0)
525525
})
526526

web/src/server/__tests__/free-mode-country.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ describe('free mode country access', () => {
184184
expect(getFreeModePrivacyProviderDecision(access)).toBe('ipinfo_only')
185185
})
186186

187-
test('allows allowlisted countries when Spur does not corroborate IPinfo residential proxy detection', async () => {
187+
test('allows allowlisted countries when follow-up providers clear IPinfo residential proxy detection', async () => {
188188
const access = await getFreeModeCountryAccess(
189189
makeReq({
190190
'cf-ipcountry': 'US',
@@ -211,6 +211,8 @@ describe('free mode country access', () => {
211211
expect(access.ipPrivacy?.signals).toEqual(['res_proxy'])
212212
expect(access.spurIpPrivacy?.signals).toEqual([])
213213
expect(access.spurStatus).toBe('clean')
214+
expect(getFreeModeRiskScore(access)).toBe(70)
215+
expect(shouldHardBlockFreeModeAccess(access)).toBe(false)
214216
})
215217

216218
test('allows allowlisted countries when Spur does not corroborate IPinfo hosting or service detection', async () => {
@@ -382,6 +384,65 @@ describe('free mode country access', () => {
382384
expect(shouldHardBlockFreeModeAccess(access)).toBe(true)
383385
})
384386

387+
test('hard-blocks residential proxy when Scamalytics also corroborates it', async () => {
388+
const access = await getFreeModeCountryAccess(
389+
makeReq({
390+
'cf-ipcountry': 'US',
391+
'x-forwarded-for': '203.0.113.10',
392+
}),
393+
{
394+
ipinfoToken: 'test-token',
395+
spurToken: 'test-spur-token',
396+
lookupIpPrivacy: async () => ({
397+
signals: ['res_proxy'],
398+
}),
399+
lookupSpurIpPrivacy: async () => ({
400+
signals: ['proxy'],
401+
}),
402+
lookupScamalyticsIpRisk: async () => ({
403+
signals: [],
404+
score: 60,
405+
risk: 'medium',
406+
}),
407+
},
408+
)
409+
410+
expect(access.allowed).toBe(false)
411+
expect(access.blockReason).toBe('anonymous_network')
412+
expect(getFreeModeRiskScore(access)).toBe(95)
413+
expect(getFreeModePrivacyDecision(access)).toBe('corroborated_block')
414+
expect(shouldHardBlockFreeModeAccess(access)).toBe(true)
415+
})
416+
417+
test('keeps IPinfo and Spur residential proxy corroboration limited when Scamalytics is clean', async () => {
418+
const access = await getFreeModeCountryAccess(
419+
makeReq({
420+
'cf-ipcountry': 'US',
421+
'x-forwarded-for': '203.0.113.10',
422+
}),
423+
{
424+
ipinfoToken: 'test-token',
425+
spurToken: 'test-spur-token',
426+
lookupIpPrivacy: async () => ({
427+
signals: ['res_proxy'],
428+
}),
429+
lookupSpurIpPrivacy: async () => ({
430+
signals: ['proxy'],
431+
}),
432+
lookupScamalyticsIpRisk: async () => ({
433+
signals: [],
434+
score: 20,
435+
risk: 'low',
436+
}),
437+
},
438+
)
439+
440+
expect(access.allowed).toBe(false)
441+
expect(access.blockReason).toBe('anonymous_network')
442+
expect(getFreeModeRiskScore(access)).toBe(75)
443+
expect(shouldHardBlockFreeModeAccess(access)).toBe(false)
444+
})
445+
385446
test('keeps IPinfo VPN/proxy detections in limited mode when Spur lookup fails', async () => {
386447
const access = await getFreeModeCountryAccess(
387448
makeReq({

web/src/server/free-mode-country.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ function hasTorPrivacySignal(
172172
return ipPrivacy?.signals.includes('tor') ?? false
173173
}
174174

175+
function hasResidentialProxySignal(
176+
ipPrivacy: FreeModeIpPrivacy | null | undefined,
177+
): boolean {
178+
return ipPrivacy?.signals.includes('res_proxy') ?? false
179+
}
180+
175181
function hasCorroboratedTorSignal(
176182
countryAccess: Partial<
177183
Pick<
@@ -187,6 +193,40 @@ function hasCorroboratedTorSignal(
187193
)
188194
}
189195

196+
function hasCorroboratedResidentialProxySignal(
197+
countryAccess: Partial<
198+
Pick<
199+
FreeModeCountryAccess,
200+
| 'ipPrivacy'
201+
| 'spurIpPrivacy'
202+
| 'scamalyticsIpPrivacy'
203+
| 'scamalyticsScore'
204+
>
205+
>,
206+
): boolean {
207+
const ipinfoResidentialProxy = hasResidentialProxySignal(
208+
countryAccess.ipPrivacy,
209+
)
210+
const spurResidentialProxy = hasResidentialProxySignal(
211+
countryAccess.spurIpPrivacy,
212+
)
213+
const scamalyticsResidentialProxy = hasResidentialProxySignal(
214+
countryAccess.scamalyticsIpPrivacy,
215+
)
216+
const scamalyticsCorroborates =
217+
scamalyticsResidentialProxy ||
218+
hasHardBlockedPrivacySignal(countryAccess.scamalyticsIpPrivacy) ||
219+
(countryAccess.scamalyticsScore ?? 0) >= SCAMALYTICS_LIMITED_RISK_SCORE
220+
221+
return (
222+
(ipinfoResidentialProxy && scamalyticsCorroborates) ||
223+
(spurResidentialProxy && scamalyticsCorroborates) ||
224+
(scamalyticsResidentialProxy &&
225+
(hasHardBlockedPrivacySignal(countryAccess.ipPrivacy) ||
226+
hasHardBlockedPrivacySignal(countryAccess.spurIpPrivacy)))
227+
)
228+
}
229+
190230
function maxPrivacySignalRisk(
191231
ipPrivacy: FreeModeIpPrivacy | null | undefined,
192232
): number {
@@ -280,6 +320,9 @@ export function getFreeModeRiskScore(
280320
if (hasCorroboratedTorSignal(countryAccess)) {
281321
score = Math.max(score, 95)
282322
}
323+
if (hasCorroboratedResidentialProxySignal(countryAccess)) {
324+
score = Math.max(score, 95)
325+
}
283326

284327
return Math.min(100, Math.max(0, Math.round(score)))
285328
}
@@ -299,7 +342,10 @@ export function shouldHardBlockFreeModeAccess(
299342
): boolean {
300343
if (countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY) return true
301344
if (countryAccess.blockReason !== 'anonymous_network') return false
302-
return hasCorroboratedTorSignal(countryAccess)
345+
return (
346+
hasCorroboratedTorSignal(countryAccess) ||
347+
hasCorroboratedResidentialProxySignal(countryAccess)
348+
)
303349
}
304350

305351
export function getFreeModePrivacyDecision(

0 commit comments

Comments
 (0)