|
| 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 | +} |
0 commit comments