Skip to content

Commit 19f8a47

Browse files
committed
Add comprehensive anti-bot/spam/VPN/Tor protection system
1 parent 5319bbe commit 19f8a47

7 files changed

Lines changed: 759 additions & 36 deletions

File tree

middleware/security.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import {
2+
getClientIp,
3+
isPrivateIp,
4+
normalizeIp,
5+
isBotUserAgent,
6+
isKnownBotIp,
7+
checkHeaders,
8+
checkHoneypot,
9+
validateBrowserSignature,
10+
detectVpnProxy,
11+
checkSuspiciousQueryParams,
12+
checkBlockedCountry,
13+
checkConcurrentRequests,
14+
validateRequestTiming,
15+
} from '../services/security.js';
16+
17+
const BLOCKED_COUNTRIES = new Set(['CN', 'RU', 'KP', 'IR', 'SY', 'CU', 'VE']);
18+
19+
const TIMING_EXEMPT_PATHS = [
20+
'/api/config', '/api/health', '/api/auth/passkeys/login/begin',
21+
'/api/auth/passkeys/login/complete', '/api/auth/passkeys/register/begin',
22+
'/api/auth/passkeys/register/complete', '/api/auth/totp/verify',
23+
'/api/auth/totp/recovery', '/api/auth/check-availability',
24+
'/api/auth/check-vpn',
25+
];
26+
27+
const VPN_EXEMPT_PATHS = [
28+
'/api/config', '/api/health', '/api/activity',
29+
];
30+
31+
const SUSPICIOUS_IPS = new Set();
32+
33+
export function advancedBotProtection(required = false) {
34+
return async (req, res, next) => {
35+
try {
36+
if (!req.path.startsWith('/api/')) return next();
37+
const ip = getClientIp(req);
38+
const ua = (req.headers['user-agent'] || '').toString().slice(0, 512);
39+
const method = req.method;
40+
const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
41+
42+
if (SUSPICIOUS_IPS.has(ip)) {
43+
const attempts = SUSPICIOUS_IPS.get(ip);
44+
if (attempts >= 3) {
45+
return res.status(403).json({ error: 'Access denied', requestId: req.requestId });
46+
}
47+
}
48+
49+
const { allowed: concurrentOk, count: concurrentCount } = checkConcurrentRequests(ip, 8);
50+
if (!concurrentOk && isPost) {
51+
return res.status(429).json({ error: 'Too many simultaneous requests. Slow down.', requestId: req.requestId });
52+
}
53+
54+
if (isBotUserAgent(ua) && isPost) {
55+
SUSPICIOUS_IPS.set(ip, (SUSPICIOUS_IPS.get(ip) || 0) + 1);
56+
return res.status(403).json({ error: 'Automated requests are not allowed. Please use a real browser.', requestId: req.requestId });
57+
}
58+
59+
if (isKnownBotIp(ip) && isPost) {
60+
return res.status(403).json({ error: 'Access denied', requestId: req.requestId });
61+
}
62+
63+
const { flagged: queryFlagged, param, pattern } = checkSuspiciousQueryParams(req);
64+
if (queryFlagged && isPost) {
65+
console.warn(`[SECURITY] Suspicious query param "${param}" containing "${pattern}" from IP ${ip}`);
66+
return res.status(400).json({ error: 'Invalid request parameters', requestId: req.requestId });
67+
}
68+
69+
const { triggered: honeypotTriggered, field: honeypotField } = checkHoneypot(req.body);
70+
if (honeypotTriggered && isPost) {
71+
console.warn(`[SECURITY] Honeypot triggered on field "${honeypotField}" from IP ${ip}`);
72+
return res.status(400).json({ error: 'Invalid form submission', requestId: req.requestId });
73+
}
74+
75+
next();
76+
} catch (err) {
77+
console.error('[SECURITY] Middleware error:', err.message);
78+
next();
79+
}
80+
};
81+
}
82+
83+
export function vpnProxyProtection() {
84+
return async (req, res, next) => {
85+
try {
86+
if (!req.path.startsWith('/api/')) return next();
87+
if (VPN_EXEMPT_PATHS.includes(req.path)) return next();
88+
const ip = getClientIp(req);
89+
const cleanIp = normalizeIp(ip);
90+
if (!cleanIp || isPrivateIp(cleanIp)) return next();
91+
const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
92+
if (!isPost) return next();
93+
const result = await detectVpnProxy(cleanIp);
94+
if (result.isVpn || result.isProxy || result.isTor) {
95+
const source = result.source || 'unknown';
96+
console.warn(`[SECURITY] VPN/Proxy/Tor detected for IP ${cleanIp} (source: ${source})`);
97+
return res.status(403).json({
98+
error: result.isTor
99+
? 'Tor network access is not allowed.'
100+
: 'VPN or proxy detected. Please disable your VPN for security reasons.',
101+
requestId: req.requestId,
102+
});
103+
}
104+
next();
105+
} catch (err) {
106+
console.error('[SECURITY] VPN proxy middleware error:', err.message);
107+
next();
108+
}
109+
};
110+
}
111+
112+
export function countryBlock() {
113+
return async (req, res, next) => {
114+
try {
115+
if (!req.path.startsWith('/api/')) return next();
116+
const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
117+
if (!isPost) return next();
118+
if (req.path === '/api/auth/login') return next();
119+
const ip = getClientIp(req);
120+
const { blocked, countryCode } = await checkBlockedCountry(ip);
121+
if (blocked) {
122+
console.warn(`[SECURITY] Blocked request from ${countryCode} IP ${ip}`);
123+
return res.status(403).json({ error: 'Service not available in your region.', requestId: req.requestId });
124+
}
125+
next();
126+
} catch (err) {
127+
console.error('[SECURITY] Country block middleware error:', err.message);
128+
next();
129+
}
130+
};
131+
}
132+
133+
export function browserIntegrityCheck() {
134+
return (req, res, next) => {
135+
try {
136+
if (!req.path.startsWith('/api/')) return next();
137+
const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
138+
if (!isPost) return next();
139+
const issues = checkHeaders(req);
140+
if (issues.length >= 3) {
141+
const ip = getClientIp(req);
142+
console.warn(`[SECURITY] Browser integrity check failed for IP ${ip}: ${issues.join(', ')}`);
143+
return res.status(403).json({ error: 'Invalid request headers. Please use a real browser.', requestId: req.requestId });
144+
}
145+
const signature = validateBrowserSignature(req);
146+
if (!signature.passed && issues.length > 0) {
147+
const ip = getClientIp(req);
148+
console.warn(`[SECURITY] Low browser signature score ${signature.total}/100 for IP ${ip}`);
149+
}
150+
next();
151+
} catch (err) {
152+
console.error('[SECURITY] Browser integrity error:', err.message);
153+
next();
154+
}
155+
};
156+
}
157+
158+
export function securityAudit(action) {
159+
return async (req, res, next) => {
160+
const ip = getClientIp(req);
161+
const origJson = res.json.bind(res);
162+
res.json = function (body) {
163+
if (res.statusCode >= 400) {
164+
console.warn(`[AUDIT] ${action} failed for IP ${ip}: ${JSON.stringify(body)}`);
165+
}
166+
return origJson(body);
167+
};
168+
next();
169+
};
170+
}

routes/admin.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs,
88
import { verifyCap } from '../config/cap.js';
99
import { logActivity } from '../services/activity.js';
1010
import { createNotification } from '../services/notification.js';
11+
import { detectVpnProxy, isBotUserAgent } from '../services/security.js';
1112

1213
const router = Router();
1314
const JWT_SECRET = process.env.JWT_SECRET;
@@ -79,6 +80,15 @@ router.post('/login', adminLoginLimiter, async (req, res) => {
7980

8081
const ip = getClientIp(req);
8182
const userAgent = (req.headers['user-agent'] || 'unknown').toString().slice(0, 512);
83+
84+
if (isBotUserAgent(userAgent)) {
85+
return res.status(403).json({ error: 'Automated requests are not allowed.' });
86+
}
87+
88+
const vpnResult = await detectVpnProxy(ip);
89+
if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) {
90+
return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Access denied.' });
91+
}
8292
const delay = getAdminLoginDelay(ip);
8393
if (delay > 0) {
8494
await new Promise(r => setTimeout(r, delay));

routes/auth.js

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ import { createPteroUser, updatePteroPassword, updatePteroEmail, deletePteroUser
1111
import { verifyCap } from '../config/cap.js';
1212
import { logActivity } from '../services/activity.js';
1313
import { sendVerificationEmail, sendEmailChangeLink, sendEmailChangeCode } from '../services/email.js';
14+
import { isBotUserAgent, detectVpnProxy } from '../services/security.js';
1415

1516
const router = Router();
1617

1718
router.get('/check-vpn', async (req, res) => {
1819
try {
1920
const ip = getClientIp(req);
2021
const forwarded = req.headers['x-forwarded-for'] || 'none';
21-
const isVpn = await isVpnOrProxy(ip);
22-
res.json({ vpn: isVpn, ip, forwarded });
22+
const result = await detectVpnProxy(ip);
23+
res.json({ vpn: result.isVpn || result.isProxy || result.isTor, tor: !!result.isTor, proxy: !!result.isProxy, ip, forwarded, source: result.source });
2324
} catch {
2425
res.json({ vpn: false });
2526
}
@@ -116,6 +117,14 @@ const sensitiveLimiter = rateLimit({
116117
legacyHeaders: false,
117118
});
118119

120+
const ultraStrictLimiter = rateLimit({
121+
windowMs: 60 * 60 * 1000,
122+
max: 3,
123+
message: { error: 'Too many attempts. Try again later.' },
124+
standardHeaders: true,
125+
legacyHeaders: false,
126+
});
127+
119128
function getClientIp(req) {
120129
const forwarded = req.headers['x-forwarded-for'];
121130
if (forwarded) return forwarded.split(',')[0].trim();
@@ -206,7 +215,7 @@ router.post('/register', async (req, res) => {
206215
const ip = getClientIp(req);
207216
const userAgent = (req.headers['user-agent'] || '').toString().slice(0, 512);
208217

209-
if (!userAgent || /curl|wget|node-fetch|python-requests|python-httpx|urllib|aiohttp|go-http-client|java\/|libcurl|okhttp|httpie|postmanruntime|insomnia|axios|fetch\//i.test(userAgent)) {
218+
if (isBotUserAgent(userAgent)) {
210219
recordLoginAttempt(ip, false);
211220
return res.status(403).json({ error: 'Automated registration is not allowed. Please use a real browser.' });
212221
}
@@ -231,12 +240,18 @@ router.post('/register', async (req, res) => {
231240
return res.status(400).json({ error: 'Username must be 3-32 chars (letters, numbers, underscore, hyphen)' });
232241
}
233242

234-
if (password.length < 8 || password.length > MAX_PASSWORD_LENGTH) {
235-
return res.status(400).json({ error: 'Password must be between 8 and 128 characters' });
243+
if (password.length < 12 || password.length > MAX_PASSWORD_LENGTH) {
244+
return res.status(400).json({ error: 'Password must be between 12 and 128 characters' });
236245
}
237246

238-
if (await isVpnOrProxy(ip)) {
239-
return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN for security reasons.' });
247+
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
248+
return res.status(400).json({ error: 'Password must contain uppercase, lowercase, and a number' });
249+
}
250+
251+
const vpnResult = await detectVpnProxy(ip);
252+
if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) {
253+
recordLoginAttempt(ip, false);
254+
return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them for security reasons.' });
240255
}
241256

242257
const delay = getLoginDelay(ip);
@@ -448,18 +463,17 @@ router.post('/login', async (req, res) => {
448463
const ip = getClientIp(req);
449464
const userAgent = (req.headers['user-agent'] || 'unknown').toString().slice(0, 512);
450465

451-
// VPN / Proxy detection — checked first for security
452-
if (await isVpnOrProxy(ip)) {
453-
return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN for security reasons.' });
466+
const vpnResult = await detectVpnProxy(ip);
467+
if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) {
468+
recordLoginAttempt(ip, false);
469+
return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them for security reasons.' });
454470
}
455471

456-
// Cap verification
457472
if (!await verifyCap(capToken)) {
458473
recordLoginAttempt(ip, false);
459474
return res.status(400).json({ error: 'Please complete the security check' });
460475
}
461476

462-
// Progressive delay applied only after validation to prevent resource exhaustion
463477
const delay = getLoginDelay(ip);
464478
if (delay > 0) {
465479
await new Promise(r => setTimeout(r, delay));
@@ -567,8 +581,11 @@ router.post('/change-password', authenticateToken, sensitiveLimiter, async (req,
567581
return res.status(400).json({ error: 'Invalid input types' });
568582
}
569583

570-
if (newPassword.length < 8 || newPassword.length > MAX_PASSWORD_LENGTH) {
571-
return res.status(400).json({ error: 'New password must be between 8 and 128 characters' });
584+
if (newPassword.length < 12 || newPassword.length > MAX_PASSWORD_LENGTH) {
585+
return res.status(400).json({ error: 'New password must be between 12 and 128 characters' });
586+
}
587+
if (!/[A-Z]/.test(newPassword) || !/[a-z]/.test(newPassword) || !/[0-9]/.test(newPassword)) {
588+
return res.status(400).json({ error: 'New password must contain uppercase, lowercase, and a number' });
572589
}
573590

574591
const users = await query('SELECT * FROM users WHERE ptero_user_id = ?', [pteroId]);

routes/passkeys.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { authenticateToken, generateToken } from '../middleware/auth.js';
1111
import { createHash, randomBytes } from 'crypto';
1212
import { query } from '../config/db.js';
1313
import { logActivity } from '../services/activity.js';
14-
import { isVpnOrProxy } from './auth.js';
14+
import { detectVpnProxy } from '../services/security.js';
1515

1616
const router = Router();
1717

@@ -223,8 +223,9 @@ router.post('/passkeys/login/complete', passkeyLoginLimiter, async (req, res) =>
223223
try {
224224
const ip = getClientIp(req);
225225

226-
if (await isVpnOrProxy(ip)) {
227-
return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN for security reasons.' });
226+
const vpnResult = await detectVpnProxy(ip);
227+
if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) {
228+
return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them for security reasons.' });
228229
}
229230

230231
const { response, sessionToken } = req.body;

routes/servers.js

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Router } from 'express';
22
import rateLimit from 'express-rate-limit';
33
import { authenticateToken, requireNotRestricted, requireOwnership } from '../middleware/auth.js';
4-
import { getClientIp, isVpnOrProxy, fetchWithTimeout, normalizeClientIp, isPrivateIp } from './auth.js';
4+
import { getClientIp, fetchWithTimeout } from './auth.js';
55
import {
66
getServersByUser,
77
getServerById,
@@ -19,23 +19,10 @@ import { query } from '../config/db.js';
1919
import { verifyCap } from '../config/cap.js';
2020
import { logActivity } from '../services/activity.js';
2121
import { createNotification } from '../services/notification.js';
22+
import { isBotUserAgent, detectVpnProxy, checkBlockedCountry } from '../services/security.js';
2223

2324
const router = Router();
2425

25-
const BLOCKED_COUNTRY_CODES = ['CN', 'RU'];
26-
27-
async function checkBlockedCountry(ip) {
28-
const cleanIp = normalizeClientIp(ip);
29-
if (!cleanIp || isPrivateIp(cleanIp)) return false;
30-
try {
31-
const res = await fetchWithTimeout(`http://ip-api.com/json/${encodeURIComponent(cleanIp)}?fields=countryCode`, {}, 5000);
32-
const data = await res.json();
33-
return BLOCKED_COUNTRY_CODES.includes(data.countryCode);
34-
} catch {
35-
return false;
36-
}
37-
}
38-
3926
const createServerLimiter = rateLimit({
4027
windowMs: 60 * 60 * 1000,
4128
max: 3,
@@ -253,11 +240,12 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi
253240
const ip = getClientIp(req);
254241
const userAgent = (req.headers['user-agent'] || '').toString().slice(0, 512);
255242

256-
if (await isVpnOrProxy(ip)) {
257-
return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN.', check: 'vpn' });
243+
const vpnResult = await detectVpnProxy(ip);
244+
if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) {
245+
return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them.', check: 'vpn' });
258246
}
259247

260-
if (!userAgent || /curl|wget|node-fetch|python-requests|python-httpx|urllib|aiohttp|go-http-client|java\/|libcurl|okhttp|httpie|postmanruntime|insomnia|axios|fetch\//i.test(userAgent)) {
248+
if (isBotUserAgent(userAgent)) {
261249
return res.status(403).json({ error: 'Automated requests are not allowed. Please use a real browser.', check: 'useragent' });
262250
}
263251

@@ -266,7 +254,8 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi
266254
return res.status(403).json({ error: 'Please verify your email before creating a server.', check: 'email' });
267255
}
268256

269-
if (await checkBlockedCountry(ip)) {
257+
const countryCheck = await checkBlockedCountry(ip);
258+
if (countryCheck.blocked) {
270259
return res.status(403).json({ error: 'Service not available in your region.', check: 'country' });
271260
}
272261

server.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { query, closePool, getPoolStatus } from './config/db.js';
3838
import { getRecentActivity } from './services/activity.js';
3939
import { authenticateToken } from './middleware/auth.js';
4040
import { ensureLogFile, writeLog, startLogCleaner } from './services/fileLogger.js';
41+
import { advancedBotProtection, vpnProxyProtection, countryBlock, browserIntegrityCheck } from './middleware/security.js';
4142

4243
const app = express();
4344

@@ -72,6 +73,11 @@ app.use((req, res, next) => {
7273
next();
7374
});
7475

76+
app.use('/api', advancedBotProtection());
77+
app.use('/api', browserIntegrityCheck());
78+
app.use('/api', vpnProxyProtection());
79+
app.use('/api', countryBlock());
80+
7581
const activeRequests = new Map();
7682

7783
app.use((req, res, next) => {

0 commit comments

Comments
 (0)