diff --git a/src/router/websocket.ts b/src/router/websocket.ts index 1394e46..01771ad 100644 --- a/src/router/websocket.ts +++ b/src/router/websocket.ts @@ -13,6 +13,7 @@ import redis from '@/utils/redis'; import { parseSlop } from '@/utils/slop'; import { checkIpRanges } from '@/utils/ranges'; import { getRateLimitState, getRateLimitSubjects } from '@/utils/rate-limit'; +import { isSynthientBlocked } from '@/utils/synthient' const app = new Elysia() const videoIds: Record = {} @@ -176,9 +177,12 @@ app.ws('/save', { await cleanup(ws, videoId); console.log(`captcha failed for ${videoId} - ${JSON.stringify(captchaCheck)}`) return sendError(ws, 'Captcha validation failed.'); - } else { - ws.send('DATA - Captcha validated. Starting download...'); } + if (await isSynthientBlocked(ws.data.headers['cf-connecting-ip'] || '')) { + await cleanup(ws, videoId) + return sendError(ws, 'Your network is flagged as malicious.') + } + ws.send('DATA - Captcha validated. Starting download...'); } const data = await getVideo(videoId) @@ -272,9 +276,12 @@ app.ws('/savechannel', { await cleanup(ws, channelId); console.log(`captcha failed for ${channelId} - ${JSON.stringify(captchaCheck)}`) return sendError(ws, 'Captcha validation failed.'); - } else { - ws.send('DATA - Captcha validated. Starting download...'); } + if (await isSynthientBlocked(ws.data.headers['cf-connecting-ip'] || '')) { + await cleanup(ws, channelId) + return sendError(ws, 'Your network is flagged as malicious.') + } + ws.send('DATA - Captcha validated. Starting download...'); videoIds[ws.id] = `downloading-${channelId}`; const [videos, channelData] = await Promise.all([getChannelVideos(channelId), getChannel(channelId)]); @@ -284,6 +291,10 @@ app.ws('/savechannel', { } for (const video of videos.slice(0, 5)) { + if ((await checkIpRanges(ws.data.headers['cf-connecting-ip'] || '')).blocked) { + await cleanup(ws, channelId) + return sendError(ws, 'Your network is flagged as malicious.') + } if (!video || (await redis.get(saveKey(video.videoId))) || (await redis.get(`blacklist:${video.videoId}`))) continue; const already = await db.selectFrom('videos') diff --git a/src/utils/ranges.ts b/src/utils/ranges.ts index fcc8a48..dfdfd04 100644 --- a/src/utils/ranges.ts +++ b/src/utils/ranges.ts @@ -1,5 +1,6 @@ import { readdir, stat } from 'node:fs/promises' import * as path from 'node:path' +import redis from '@/utils/redis' export interface BlockedIpResult { blocked: boolean @@ -26,6 +27,10 @@ const asnBanList: number[] = [ 14618, // aws + 62327, + 8075, // microsoft + 210644, // aeza group + // expressvpn 206092, 137409, @@ -397,7 +402,14 @@ export async function checkIpRanges(ip: string): Promise { return { blocked: false, list: null, range: null } } + if (await redis.exists(`synthient:blocked-ip:${ip}`)) { + return { blocked: true, list: 'synthient', range: null } + } + const asnMatch = await resolveIpAsn(parsedIp) + if (asnMatch.range && await redis.exists(`synthient:blocked-range:${asnMatch.range}`)) { + return { blocked: true, list: 'synthient', range: asnMatch.range } + } const entries = await readdir(BLOCKED_DIR, { withFileTypes: true }) const files = entries @@ -444,6 +456,11 @@ export async function checkIpRanges(ip: string): Promise { } } +export async function getIpNetworkRange(ip: string): Promise { + const parsedIp = parseIp(ip) + return parsedIp ? (await resolveIpAsn(parsedIp)).range : null +} + const networkRefreshTimer = setInterval(() => { void refreshNetworksCache(true) }, NETWORKS_REFRESH_INTERVAL_MS) diff --git a/src/utils/synthient.ts b/src/utils/synthient.ts new file mode 100644 index 0000000..605cf4c --- /dev/null +++ b/src/utils/synthient.ts @@ -0,0 +1,67 @@ +import { isIP } from 'node:net' +import redis from '@/utils/redis' +import { checkIpRanges, getIpNetworkRange } from '@/utils/ranges' + +const responseKey = (ip: string) => `synthient:response:${ip}` +const banTtlSeconds = 30 * 24 * 60 * 60 + +function getBlockDecision(body: string) { + const result = JSON.parse(body) as { + intelligence?: { + categories?: string[] + providers?: { provider?: string, type?: string }[] + risk_score?: number + } + } + if (!result.intelligence || !Array.isArray(result.intelligence.categories) || !Array.isArray(result.intelligence.providers)) { + throw new Error('Invalid IP lookup response') + } + + if (result.intelligence.categories.includes('RESIDENTIAL_PROXY')) { + return { + blocked: typeof result.intelligence.risk_score === 'number' && result.intelligence.risk_score > 90, + blockRange: false + } + } + + return { + blocked: result.intelligence.categories.includes('FREE_VPN') || result.intelligence.providers.some(provider => + provider.type === 'FREE_VPN' || + provider.provider === 'NORDVPN' || + provider.provider === 'PROTONVPN'), + blockRange: true + } +} + +export async function isSynthientBlocked(ip: string): Promise { + if (!isIP(ip)) return false + if ((await checkIpRanges(ip)).blocked) return true + + try { + const cached = await redis.get(responseKey(ip)) + if (cached) return getBlockDecision(cached).blocked + if (!process.env.SYNTHIENT_API_KEY) return false + + const response = await fetch(`https://api.synthient.com/api/v4/lookup/ip/${encodeURIComponent(ip)}`, { + headers: { 'x-api-key': process.env.SYNTHIENT_API_KEY }, + signal: AbortSignal.timeout(5000) + }) + const body = await response.text() + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const decision = getBlockDecision(body) + if (!decision.blocked) { + await redis.set(responseKey(ip), body, 'EX', 24 * 60 * 60) + return false + } + + const range = decision.blockRange ? await getIpNetworkRange(ip) : null + const transaction = redis.multi() + transaction.set(responseKey(ip), body, 'EX', banTtlSeconds) + transaction.set(`synthient:blocked-ip:${ip}`, '1', 'EX', banTtlSeconds) + if (range) transaction.set(`synthient:blocked-range:${range}`, '1', 'EX', banTtlSeconds) + await transaction.exec() + return true + } catch { + return false + } +}