diff --git a/.gitignore b/.gitignore index 3a45bda..36f6e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ Thumbs.db # Logs logs/ *.log +log.txt # Runtime port.txt diff --git a/config/migrate.js b/config/migrate.js index 58ad11e..8189eb7 100644 --- a/config/migrate.js +++ b/config/migrate.js @@ -27,6 +27,9 @@ const tables = { { name: 'email_change_expires', def: 'DATETIME DEFAULT NULL' }, { name: 'user_agent', def: 'VARCHAR(512) DEFAULT NULL' }, { name: 'onboarding_done', def: 'TINYINT(1) NOT NULL DEFAULT 0' }, + { name: 'totp_secret', def: 'VARCHAR(255) DEFAULT NULL' }, + { name: 'totp_enabled', def: 'TINYINT(1) NOT NULL DEFAULT 0' }, + { name: 'recovery_codes', def: 'TEXT DEFAULT NULL' }, { name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, ], }, diff --git a/middleware/security.js b/middleware/security.js new file mode 100644 index 0000000..504cc76 --- /dev/null +++ b/middleware/security.js @@ -0,0 +1,170 @@ +import { + getClientIp, + isPrivateIp, + normalizeIp, + isBotUserAgent, + isKnownBotIp, + checkHeaders, + checkHoneypot, + validateBrowserSignature, + detectVpnProxy, + checkSuspiciousQueryParams, + checkBlockedCountry, + checkConcurrentRequests, + validateRequestTiming, +} from '../services/security.js'; + +const BLOCKED_COUNTRIES = new Set(['CN', 'RU', 'KP', 'IR', 'SY', 'CU', 'VE']); + +const TIMING_EXEMPT_PATHS = [ + '/api/config', '/api/health', '/api/auth/passkeys/login/begin', + '/api/auth/passkeys/login/complete', '/api/auth/passkeys/register/begin', + '/api/auth/passkeys/register/complete', '/api/auth/totp/verify', + '/api/auth/totp/recovery', '/api/auth/check-availability', + '/api/auth/check-vpn', +]; + +const VPN_EXEMPT_PATHS = [ + '/api/config', '/api/health', '/api/activity', +]; + +const SUSPICIOUS_IPS = new Set(); + +export function advancedBotProtection(required = false) { + return async (req, res, next) => { + try { + if (!req.path.startsWith('/api/')) return next(); + const ip = getClientIp(req); + const ua = (req.headers['user-agent'] || '').toString().slice(0, 512); + const method = req.method; + const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method); + + if (SUSPICIOUS_IPS.has(ip)) { + const attempts = SUSPICIOUS_IPS.get(ip); + if (attempts >= 3) { + return res.status(403).json({ error: 'Access denied', requestId: req.requestId }); + } + } + + const { allowed: concurrentOk, count: concurrentCount } = checkConcurrentRequests(ip, 8); + if (!concurrentOk && isPost) { + return res.status(429).json({ error: 'Too many simultaneous requests. Slow down.', requestId: req.requestId }); + } + + if (isBotUserAgent(ua) && isPost) { + SUSPICIOUS_IPS.set(ip, (SUSPICIOUS_IPS.get(ip) || 0) + 1); + return res.status(403).json({ error: 'Automated requests are not allowed. Please use a real browser.', requestId: req.requestId }); + } + + if (isKnownBotIp(ip) && isPost) { + return res.status(403).json({ error: 'Access denied', requestId: req.requestId }); + } + + const { flagged: queryFlagged, param, pattern } = checkSuspiciousQueryParams(req); + if (queryFlagged && isPost) { + console.warn(`[SECURITY] Suspicious query param "${param}" containing "${pattern}" from IP ${ip}`); + return res.status(400).json({ error: 'Invalid request parameters', requestId: req.requestId }); + } + + const { triggered: honeypotTriggered, field: honeypotField } = checkHoneypot(req.body); + if (honeypotTriggered && isPost) { + console.warn(`[SECURITY] Honeypot triggered on field "${honeypotField}" from IP ${ip}`); + return res.status(400).json({ error: 'Invalid form submission', requestId: req.requestId }); + } + + next(); + } catch (err) { + console.error('[SECURITY] Middleware error:', err.message); + next(); + } + }; +} + +export function vpnProxyProtection() { + return async (req, res, next) => { + try { + if (!req.path.startsWith('/api/')) return next(); + if (VPN_EXEMPT_PATHS.includes(req.path)) return next(); + const ip = getClientIp(req); + const cleanIp = normalizeIp(ip); + if (!cleanIp || isPrivateIp(cleanIp)) return next(); + const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method); + if (!isPost) return next(); + const result = await detectVpnProxy(cleanIp); + if (result.isVpn || result.isProxy || result.isTor) { + const source = result.source || 'unknown'; + console.warn(`[SECURITY] VPN/Proxy/Tor detected for IP ${cleanIp} (source: ${source})`); + return res.status(403).json({ + error: result.isTor + ? 'Tor network access is not allowed.' + : 'VPN or proxy detected. Please disable your VPN for security reasons.', + requestId: req.requestId, + }); + } + next(); + } catch (err) { + console.error('[SECURITY] VPN proxy middleware error:', err.message); + next(); + } + }; +} + +export function countryBlock() { + return async (req, res, next) => { + try { + if (!req.path.startsWith('/api/')) return next(); + const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method); + if (!isPost) return next(); + if (req.path === '/api/auth/login') return next(); + const ip = getClientIp(req); + const { blocked, countryCode } = await checkBlockedCountry(ip); + if (blocked) { + console.warn(`[SECURITY] Blocked request from ${countryCode} IP ${ip}`); + return res.status(403).json({ error: 'Service not available in your region.', requestId: req.requestId }); + } + next(); + } catch (err) { + console.error('[SECURITY] Country block middleware error:', err.message); + next(); + } + }; +} + +export function browserIntegrityCheck() { + return (req, res, next) => { + try { + if (!req.path.startsWith('/api/')) return next(); + const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method); + if (!isPost) return next(); + const issues = checkHeaders(req); + if (issues.length >= 3) { + const ip = getClientIp(req); + console.warn(`[SECURITY] Browser integrity check failed for IP ${ip}: ${issues.join(', ')}`); + return res.status(403).json({ error: 'Invalid request headers. Please use a real browser.', requestId: req.requestId }); + } + const signature = validateBrowserSignature(req); + if (!signature.passed && issues.length > 0) { + const ip = getClientIp(req); + console.warn(`[SECURITY] Low browser signature score ${signature.total}/100 for IP ${ip}`); + } + next(); + } catch (err) { + console.error('[SECURITY] Browser integrity error:', err.message); + next(); + } + }; +} + +export function securityAudit(action) { + return async (req, res, next) => { + const ip = getClientIp(req); + const origJson = res.json.bind(res); + res.json = function (body) { + if (res.statusCode >= 400) { + console.warn(`[AUDIT] ${action} failed for IP ${ip}: ${JSON.stringify(body)}`); + } + return origJson(body); + }; + next(); + }; +} diff --git a/package-lock.json b/package-lock.json index d19dc06..a3ae033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zerohost-dashboard", - "version": "1.0.4", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zerohost-dashboard", - "version": "1.0.4", + "version": "1.1.1", "dependencies": { "@simplewebauthn/server": "^13.3.2", "argon2": "^0.41.1", @@ -18,6 +18,8 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "mariadb": "^3.4.0", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "resend": "^6.17.1" } }, @@ -33,6 +35,74 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", + "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", + "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", + "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", + "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.4.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", + "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", + "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1" + } + }, "node_modules/@peculiar/asn1-android": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", @@ -210,6 +280,15 @@ "node": ">=10" } }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@simplewebauthn/server": { "version": "13.3.2", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", @@ -263,6 +342,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argon2": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", @@ -366,6 +469,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -441,6 +582,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -469,6 +619,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -510,6 +666,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -649,6 +811,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -676,6 +851,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -808,6 +992,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -857,6 +1050,18 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1077,6 +1282,56 @@ "node": ">= 0.8" } }, + "node_modules/otplib": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", + "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/plugin-base32-scure": "13.4.1", + "@otplib/plugin-crypto-noble": "13.4.1", + "@otplib/totp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1086,12 +1341,30 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postal-mime": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", @@ -1129,6 +1402,23 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -1174,6 +1464,21 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resend": { "version": "6.17.1", "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.1.tgz", @@ -1278,6 +1583,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1375,6 +1686,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1453,6 +1790,67 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index 8409027..12e922c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.1.1", + "version": "1.2.0", "private": true, "type": "module", "scripts": { @@ -18,6 +18,8 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "mariadb": "^3.4.0", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "resend": "^6.17.1" } } diff --git a/public/css/style.css b/public/css/style.css index 1b77bf0..f985499 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -3688,6 +3688,9 @@ tbody tr:hover { .activity-icon-passkey_registered { background: rgba(6,182,212,0.15); color: #06b6d4; } .activity-icon-email_verified { background: rgba(5,150,105,0.15); color: #059669; } .activity-icon-passkey_deleted { background: rgba(239,68,68,0.15); color: #ef4444; } +.activity-icon-totp_enabled { background: rgba(5,150,105,0.15); color: #059669; } +.activity-icon-totp_disabled { background: rgba(239,68,68,0.15); color: #ef4444; } +.activity-icon-login_totp { background: rgba(14,165,233,0.15); color: #0ea5e9; } .activity-content { flex: 1; diff --git a/public/js/app.js b/public/js/app.js index 201e86e..8fc35b3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -20,6 +20,7 @@ const state = { accountTab: 'info', sidebarServersOpen: false, sidebarServersLoading: false, + totpTempToken: null, }; function setRgpdConsent(preferences) { @@ -660,6 +661,51 @@ function renderLoginPage() { +
+ @@ -698,6 +744,25 @@ function renderLoginPage() { navigateTo('signup'); }); + $('#login-totp-form-inner').addEventListener('submit', handleTotpVerify); + $('#login-totp-recovery-btn').addEventListener('click', () => { + $('#login-totp-initial').style.display = 'none'; + $('#login-totp-recovery-form').style.display = 'block'; + }); + $('#login-totp-back-btn').addEventListener('click', () => { + $('#login-totp-recovery-form').style.display = 'none'; + $('#login-totp-initial').style.display = 'block'; + }); + $('#login-totp-recovery-inner').addEventListener('submit', handleTotpRecovery); + $('#login-totp-cancel-btn').addEventListener('click', () => { + state.totpTempToken = null; + $('#login-totp-form').style.display = 'none'; + $('#login-choices').style.display = 'block'; + const dc = $('#discord-login-card'); + if (dc) dc.style.display = ''; + initIcons(); + }); + initIcons(); setTimeout(initIcons, 100); } @@ -907,6 +972,19 @@ async function handleLogin(e) { capToken, }), }); + + if (data.needsTotp) { + state.totpTempToken = data.tempToken; + $('#login-email-form').style.display = 'none'; + $('#login-totp-form').style.display = 'block'; + $('#login-totp-code').value = ''; + $('#login-totp-code').focus(); + btn.disabled = false; + btn.innerHTML = 'Sign In'; + initIcons(); + return; + } + state.token = data.token; state.user = data.user; localStorage.setItem('zh_token', data.token); @@ -925,6 +1003,71 @@ async function handleLogin(e) { } } +async function handleTotpVerify(e) { + e.preventDefault(); + const form = $('#login-totp-form-inner'); + hideError(form); + const btn = $('#login-totp-btn'); + btn.disabled = true; + btn.innerHTML = ' Verifying...'; + + try { + const data = await api('/auth/totp/verify', { + method: 'POST', + body: JSON.stringify({ + code: $('#login-totp-code').value.trim(), + tempToken: state.totpTempToken, + }), + }); + state.totpTempToken = null; + state.token = data.token; + state.user = data.user; + localStorage.setItem('zh_token', data.token); + localStorage.setItem('zh_user', JSON.stringify(data.user)); + history.replaceState({ page: 'overview' }, '', '/'); + renderDashboard(); + } catch (err) { + showError(form, err.message); + $('#login-totp-code').value = ''; + $('#login-totp-code').focus(); + } finally { + btn.disabled = false; + btn.innerHTML = 'Verify'; + } +} + +async function handleTotpRecovery(e) { + e.preventDefault(); + const form = $('#login-totp-recovery-inner'); + hideError(form); + const btn = $('#login-recovery-btn'); + btn.disabled = true; + btn.innerHTML = ' Verifying...'; + + try { + const data = await api('/auth/totp/recovery', { + method: 'POST', + body: JSON.stringify({ + code: $('#login-recovery-code').value.trim(), + tempToken: state.totpTempToken, + }), + }); + state.totpTempToken = null; + state.token = data.token; + state.user = data.user; + localStorage.setItem('zh_token', data.token); + localStorage.setItem('zh_user', JSON.stringify(data.user)); + history.replaceState({ page: 'overview' }, '', '/'); + renderDashboard(); + } catch (err) { + showError(form, err.message); + $('#login-recovery-code').value = ''; + } finally { + btn.disabled = false; + btn.innerHTML = 'Verify Recovery Code'; + } +} + async function completePasskeyLogin(credential) { if (await checkVpn()) { await showVpnBlockModal(); @@ -1511,7 +1654,7 @@ async function renderDashboard() {Loading...
+Failed to load: ${escapeHtml(err.message)}
`; + } +} + +function renderTotpDisabled(section) { + section.innerHTML = html` ++ Add an extra layer of security to your account by enabling two-factor authentication. + You'll need to enter a verification code from your authenticator app when signing in. +
+ + `; + $('#enable-totp-btn').addEventListener('click', handleTotpSetup); + initIcons(); +} + +async function renderTotpEnabled(section) { + section.innerHTML = html` ++ Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy, 2FAS). +
+
+ Or manually enter this key: ${data.secret}
+
+ Each code can be used once to sign in if you lose access to your authenticator app. +
++ Enter your password to disable two-factor authentication. +
++ Your existing recovery codes will be invalidated and new ones will be generated. +
+ +${escapeHtml(err.message)}
`; + } + }); +} + function handleModifyApiKey() { const overlay = $('#modal-overlay'); const content = $('#modal-content'); diff --git a/routes/admin.js b/routes/admin.js index 3fbe430..7cdb864 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -8,6 +8,7 @@ import { getAllServers, getServerById, getEgg, getPteroNests, getPteroNestEggs, import { verifyCap } from '../config/cap.js'; import { logActivity } from '../services/activity.js'; import { createNotification } from '../services/notification.js'; +import { detectVpnProxy, isBotUserAgent } from '../services/security.js'; const router = Router(); const JWT_SECRET = process.env.JWT_SECRET; @@ -79,6 +80,15 @@ router.post('/login', adminLoginLimiter, async (req, res) => { const ip = getClientIp(req); const userAgent = (req.headers['user-agent'] || 'unknown').toString().slice(0, 512); + + if (isBotUserAgent(userAgent)) { + return res.status(403).json({ error: 'Automated requests are not allowed.' }); + } + + const vpnResult = await detectVpnProxy(ip); + if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) { + return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Access denied.' }); + } const delay = getAdminLoginDelay(ip); if (delay > 0) { await new Promise(r => setTimeout(r, delay)); diff --git a/routes/auth.js b/routes/auth.js index dc01d71..68158e8 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -11,6 +11,7 @@ import { createPteroUser, updatePteroPassword, updatePteroEmail, deletePteroUser import { verifyCap } from '../config/cap.js'; import { logActivity } from '../services/activity.js'; import { sendVerificationEmail, sendEmailChangeLink, sendEmailChangeCode } from '../services/email.js'; +import { isBotUserAgent, detectVpnProxy } from '../services/security.js'; const router = Router(); @@ -18,8 +19,8 @@ router.get('/check-vpn', async (req, res) => { try { const ip = getClientIp(req); const forwarded = req.headers['x-forwarded-for'] || 'none'; - const isVpn = await isVpnOrProxy(ip); - res.json({ vpn: isVpn, ip, forwarded }); + const result = await detectVpnProxy(ip); + res.json({ vpn: result.isVpn || result.isProxy || result.isTor, tor: !!result.isTor, proxy: !!result.isProxy, ip, forwarded, source: result.source }); } catch { res.json({ vpn: false }); } @@ -116,6 +117,14 @@ const sensitiveLimiter = rateLimit({ legacyHeaders: false, }); +const ultraStrictLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: 'Too many attempts. Try again later.' }, + standardHeaders: true, + legacyHeaders: false, +}); + function getClientIp(req) { const forwarded = req.headers['x-forwarded-for']; if (forwarded) return forwarded.split(',')[0].trim(); @@ -206,7 +215,7 @@ router.post('/register', async (req, res) => { const ip = getClientIp(req); const userAgent = (req.headers['user-agent'] || '').toString().slice(0, 512); - 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)) { + if (isBotUserAgent(userAgent)) { recordLoginAttempt(ip, false); return res.status(403).json({ error: 'Automated registration is not allowed. Please use a real browser.' }); } @@ -231,12 +240,18 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Username must be 3-32 chars (letters, numbers, underscore, hyphen)' }); } - if (password.length < 8 || password.length > MAX_PASSWORD_LENGTH) { - return res.status(400).json({ error: 'Password must be between 8 and 128 characters' }); + if (password.length < 12 || password.length > MAX_PASSWORD_LENGTH) { + return res.status(400).json({ error: 'Password must be between 12 and 128 characters' }); } - if (await isVpnOrProxy(ip)) { - return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN for security reasons.' }); + if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) { + return res.status(400).json({ error: 'Password must contain uppercase, lowercase, and a number' }); + } + + const vpnResult = await detectVpnProxy(ip); + if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) { + recordLoginAttempt(ip, false); + return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them for security reasons.' }); } const delay = getLoginDelay(ip); @@ -448,18 +463,17 @@ router.post('/login', async (req, res) => { const ip = getClientIp(req); const userAgent = (req.headers['user-agent'] || 'unknown').toString().slice(0, 512); - // VPN / Proxy detection — checked first for security - if (await isVpnOrProxy(ip)) { - return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN for security reasons.' }); + const vpnResult = await detectVpnProxy(ip); + if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) { + recordLoginAttempt(ip, false); + return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them for security reasons.' }); } - // Cap verification if (!await verifyCap(capToken)) { recordLoginAttempt(ip, false); return res.status(400).json({ error: 'Please complete the security check' }); } - // Progressive delay applied only after validation to prevent resource exhaustion const delay = getLoginDelay(ip); if (delay > 0) { await new Promise(r => setTimeout(r, delay)); @@ -492,6 +506,21 @@ router.post('/login', async (req, res) => { return res.status(403).json({ error: 'Please verify your email before signing in. Check your inbox for the verification link.' }); } + if (user.totp_enabled) { + const tempToken = jwt.sign( + { userId: user.id, totpTemp: true }, + process.env.JWT_SECRET, + { expiresIn: '5m', algorithm: 'HS256' } + ); + + recordLoginAttempt(ip, true); + + return res.json({ + needsTotp: true, + tempToken, + }); + } + const token = generateToken({ userId: user.id, email: user.email, @@ -552,8 +581,11 @@ router.post('/change-password', authenticateToken, sensitiveLimiter, async (req, return res.status(400).json({ error: 'Invalid input types' }); } - if (newPassword.length < 8 || newPassword.length > MAX_PASSWORD_LENGTH) { - return res.status(400).json({ error: 'New password must be between 8 and 128 characters' }); + if (newPassword.length < 12 || newPassword.length > MAX_PASSWORD_LENGTH) { + return res.status(400).json({ error: 'New password must be between 12 and 128 characters' }); + } + if (!/[A-Z]/.test(newPassword) || !/[a-z]/.test(newPassword) || !/[0-9]/.test(newPassword)) { + return res.status(400).json({ error: 'New password must contain uppercase, lowercase, and a number' }); } const users = await query('SELECT * FROM users WHERE ptero_user_id = ?', [pteroId]); diff --git a/routes/passkeys.js b/routes/passkeys.js index 6c3610c..36015bc 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -11,7 +11,7 @@ import { authenticateToken, generateToken } from '../middleware/auth.js'; import { createHash, randomBytes } from 'crypto'; import { query } from '../config/db.js'; import { logActivity } from '../services/activity.js'; -import { isVpnOrProxy } from './auth.js'; +import { detectVpnProxy } from '../services/security.js'; const router = Router(); @@ -223,8 +223,9 @@ router.post('/passkeys/login/complete', passkeyLoginLimiter, async (req, res) => try { const ip = getClientIp(req); - if (await isVpnOrProxy(ip)) { - return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN for security reasons.' }); + const vpnResult = await detectVpnProxy(ip); + if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) { + return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them for security reasons.' }); } const { response, sessionToken } = req.body; diff --git a/routes/servers.js b/routes/servers.js index f1df97f..6bddb34 100644 --- a/routes/servers.js +++ b/routes/servers.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import { authenticateToken, requireNotRestricted, requireOwnership } from '../middleware/auth.js'; -import { getClientIp, isVpnOrProxy, fetchWithTimeout, normalizeClientIp, isPrivateIp } from './auth.js'; +import { getClientIp, fetchWithTimeout } from './auth.js'; import { getServersByUser, getServerById, @@ -19,23 +19,10 @@ import { query } from '../config/db.js'; import { verifyCap } from '../config/cap.js'; import { logActivity } from '../services/activity.js'; import { createNotification } from '../services/notification.js'; +import { isBotUserAgent, detectVpnProxy, checkBlockedCountry } from '../services/security.js'; const router = Router(); -const BLOCKED_COUNTRY_CODES = ['CN', 'RU']; - -async function checkBlockedCountry(ip) { - const cleanIp = normalizeClientIp(ip); - if (!cleanIp || isPrivateIp(cleanIp)) return false; - try { - const res = await fetchWithTimeout(`http://ip-api.com/json/${encodeURIComponent(cleanIp)}?fields=countryCode`, {}, 5000); - const data = await res.json(); - return BLOCKED_COUNTRY_CODES.includes(data.countryCode); - } catch { - return false; - } -} - const createServerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 3, @@ -253,11 +240,12 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi const ip = getClientIp(req); const userAgent = (req.headers['user-agent'] || '').toString().slice(0, 512); - if (await isVpnOrProxy(ip)) { - return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN.', check: 'vpn' }); + const vpnResult = await detectVpnProxy(ip); + if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) { + return res.status(403).json({ error: 'VPN, proxy, or Tor detected. Please disable them.', check: 'vpn' }); } - 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)) { + if (isBotUserAgent(userAgent)) { return res.status(403).json({ error: 'Automated requests are not allowed. Please use a real browser.', check: 'useragent' }); } @@ -266,7 +254,8 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi return res.status(403).json({ error: 'Please verify your email before creating a server.', check: 'email' }); } - if (await checkBlockedCountry(ip)) { + const countryCheck = await checkBlockedCountry(ip); + if (countryCheck.blocked) { return res.status(403).json({ error: 'Service not available in your region.', check: 'country' }); } diff --git a/routes/totp.js b/routes/totp.js new file mode 100644 index 0000000..cadcd92 --- /dev/null +++ b/routes/totp.js @@ -0,0 +1,361 @@ +import { Router } from 'express'; +import { TOTP, NobleCryptoPlugin, ScureBase32Plugin, generateSecret, verify, generateURI } from 'otplib'; +import QRCode from 'qrcode'; +import jwt from 'jsonwebtoken'; +import { createHash, randomBytes } from 'crypto'; +import argon2 from 'argon2'; + +import { query } from '../config/db.js'; +import { authenticateToken, generateToken } from '../middleware/auth.js'; +import { logActivity } from '../services/activity.js'; + +const totp = new TOTP({ + crypto: new NobleCryptoPlugin(), + base32: new ScureBase32Plugin(), +}); + +const router = Router(); +const JWT_SECRET = process.env.JWT_SECRET; + +function generateRecoveryCodes(count = 8) { + const codes = []; + for (let i = 0; i < count; i++) { + const bytes = randomBytes(4).toString('hex').toUpperCase(); + const code = bytes.match(/.{4}/g).join('-'); + codes.push(code); + } + return codes; +} + +function hashRecoveryCode(code) { + return createHash('sha256').update(code).digest('hex'); +} + +function gravatarHash(email) { + return createHash('md5').update(email.trim().toLowerCase()).digest('hex'); +} + +router.get('/totp/status', authenticateToken, async (req, res) => { + try { + const users = await query('SELECT totp_enabled FROM users WHERE id = ?', [req.user.userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + res.json({ enabled: !!users[0].totp_enabled }); + } catch (err) { + console.error('TOTP status error:', err.message); + res.status(500).json({ error: 'Failed to check status' }); + } +}); + +router.post('/totp/setup', authenticateToken, async (req, res) => { + try { + const userId = req.user.userId; + const users = await query('SELECT id, email, totp_secret, totp_enabled FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + if (users[0].totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is already enabled' }); + } + + const secret = generateSecret(); + const otpauth = generateURI({issuer: 'ZeroHost', label: req.user.email, secret}); + const qrCode = await QRCode.toDataURL(otpauth); + + await query('UPDATE users SET totp_secret = ? WHERE id = ?', [secret, userId]); + + res.json({ secret, qrCode }); + } catch (err) { + console.error('TOTP setup error:', err.message); + res.status(500).json({ error: 'Failed to setup two-factor authentication' }); + } +}); + +router.post('/totp/enable', authenticateToken, async (req, res) => { + try { + const { code } = req.body; + const userId = req.user.userId; + + if (!code) { + return res.status(400).json({ error: 'Verification code is required' }); + } + + const users = await query('SELECT id, totp_secret, totp_enabled FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + if (users[0].totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is already enabled' }); + } + if (!users[0].totp_secret) { + return res.status(400).json({ error: 'Please start the setup first' }); + } + + const verifyResult = await verify({ token: code, secret: users[0].totp_secret }); + if (!verifyResult.valid) { + return res.status(400).json({ error: 'Invalid verification code. Please try again.' }); + } + + const recoveryCodes = generateRecoveryCodes(8); + const hashedCodes = recoveryCodes.map(hashRecoveryCode); + + await query( + 'UPDATE users SET totp_enabled = 1, recovery_codes = ? WHERE id = ?', + [JSON.stringify(hashedCodes), userId] + ); + + await logActivity(userId, 'totp_enabled', 'Enabled two-factor authentication'); + + res.json({ message: 'Two-factor authentication enabled successfully', recoveryCodes }); + } catch (err) { + console.error('TOTP enable error:', err.message); + res.status(500).json({ error: 'Failed to enable two-factor authentication' }); + } +}); + +router.post('/totp/disable', authenticateToken, async (req, res) => { + try { + const { password } = req.body; + const userId = req.user.userId; + + if (!password) { + return res.status(400).json({ error: 'Password is required' }); + } + + const users = await query('SELECT id, password_hash FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const valid = await argon2.verify(users[0].password_hash, password, { type: argon2.argon2id }); + if (!valid) { + return res.status(401).json({ error: 'Password is incorrect' }); + } + + await query( + 'UPDATE users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?', + [userId] + ); + + await logActivity(userId, 'totp_disabled', 'Disabled two-factor authentication'); + + res.json({ message: 'Two-factor authentication disabled successfully' }); + } catch (err) { + console.error('TOTP disable error:', err.message); + res.status(500).json({ error: 'Failed to disable two-factor authentication' }); + } +}); + +router.post('/totp/verify', async (req, res) => { + try { + const { code, tempToken } = req.body; + + if (!code || !tempToken) { + return res.status(400).json({ error: 'Code and temporary token are required' }); + } + + let decoded; + try { + decoded = jwt.verify(tempToken, JWT_SECRET); + } catch { + return res.status(401).json({ error: 'Session expired. Please log in again.' }); + } + + if (!decoded.totpTemp) { + return res.status(401).json({ error: 'Invalid session' }); + } + + const userId = decoded.userId; + + const users = await query( + `SELECT id, email, username, totp_secret, totp_enabled, recovery_codes, + is_admin, restricted, token_version, ptero_user_id, + first_name, last_name, email_verified, auth_restricted + FROM users WHERE id = ?`, + [userId] + ); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const user = users[0]; + if (!user.totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is not enabled' }); + } + if (user.auth_restricted) { + return res.status(403).json({ error: 'Your account has been restricted.' }); + } + + const verifyResult = await verify({ token: code, secret: user.totp_secret }); + if (!verifyResult.valid) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + + const token = generateToken({ + userId: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + tokenVersion: user.token_version, + }); + + res.cookie('token', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 2 * 60 * 60 * 1000, + }); + + await query('UPDATE users SET user_agent = ? WHERE id = ?', [ + (req.headers['user-agent'] || 'unknown').toString().slice(0, 512), + user.id + ]).catch(() => {}); + + await logActivity(userId, 'login_totp', 'Signed in with two-factor authentication'); + + res.json({ + token, + user: { + id: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + firstName: user.first_name, + lastName: user.last_name, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + emailVerified: !!user.email_verified, + gravatarHash: gravatarHash(user.email), + }, + }); + } catch (err) { + console.error('TOTP verify error:', err.message); + res.status(500).json({ error: 'Verification failed' }); + } +}); + +router.post('/totp/recovery', async (req, res) => { + try { + const { code, tempToken } = req.body; + + if (!code || !tempToken) { + return res.status(400).json({ error: 'Recovery code and temporary token are required' }); + } + + let decoded; + try { + decoded = jwt.verify(tempToken, JWT_SECRET); + } catch { + return res.status(401).json({ error: 'Session expired. Please log in again.' }); + } + + if (!decoded.totpTemp) { + return res.status(401).json({ error: 'Invalid session' }); + } + + const userId = decoded.userId; + const users = await query( + `SELECT * FROM users WHERE id = ?`, + [userId] + ); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const user = users[0]; + if (!user.totp_enabled || !user.recovery_codes) { + return res.status(400).json({ error: 'No recovery codes available' }); + } + + const storedCodes = JSON.parse(user.recovery_codes); + const hashedInput = hashRecoveryCode(code.toUpperCase()); + + const codeIndex = storedCodes.findIndex(h => h === hashedInput); + if (codeIndex === -1) { + return res.status(401).json({ error: 'Invalid recovery code' }); + } + + storedCodes.splice(codeIndex, 1); + await query('UPDATE users SET recovery_codes = ? WHERE id = ?', [JSON.stringify(storedCodes), userId]); + + await logActivity(userId, 'recovery_code_used', 'Used a recovery code to sign in'); + + const jwtToken = generateToken({ + userId: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + tokenVersion: user.token_version, + }); + + res.cookie('token', jwtToken, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 2 * 60 * 60 * 1000, + }); + + await query('UPDATE users SET user_agent = ? WHERE id = ?', [ + (req.headers['user-agent'] || 'unknown').toString().slice(0, 512), + user.id + ]).catch(() => {}); + + res.json({ + token: jwtToken, + user: { + id: user.id, + email: user.email, + username: user.username, + pteroId: user.ptero_user_id, + firstName: user.first_name, + lastName: user.last_name, + isAdmin: !!user.is_admin, + restricted: !!user.restricted, + emailVerified: !!user.email_verified, + gravatarHash: gravatarHash(user.email), + }, + }); + } catch (err) { + console.error('TOTP recovery error:', err.message); + res.status(500).json({ error: 'Recovery code verification failed' }); + } +}); + +router.get('/totp/recovery-codes', authenticateToken, async (req, res) => { + try { + const users = await query('SELECT totp_enabled, recovery_codes FROM users WHERE id = ?', [req.user.userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + + const user = users[0]; + if (!user.totp_enabled) { + return res.json({ enabled: false, remaining: 0, codes: [] }); + } + + const codes = user.recovery_codes ? JSON.parse(user.recovery_codes) : []; + res.json({ + enabled: true, + remaining: codes.length, + }); + } catch (err) { + console.error('TOTP recovery codes error:', err.message); + res.status(500).json({ error: 'Failed to fetch recovery codes' }); + } +}); + +router.post('/totp/recovery-codes/regenerate', authenticateToken, async (req, res) => { + try { + const userId = req.user.userId; + const users = await query('SELECT totp_enabled FROM users WHERE id = ?', [userId]); + if (users.length === 0) return res.status(404).json({ error: 'User not found' }); + if (!users[0].totp_enabled) { + return res.status(400).json({ error: 'Two-factor authentication is not enabled' }); + } + + const newCodes = generateRecoveryCodes(8); + const hashedCodes = newCodes.map(hashRecoveryCode); + + await query('UPDATE users SET recovery_codes = ? WHERE id = ?', [JSON.stringify(hashedCodes), userId]); + + await logActivity(userId, 'recovery_codes_regenerated', 'Regenerated recovery codes'); + + res.json({ recoveryCodes: newCodes }); + } catch (err) { + console.error('TOTP regenerate recovery codes error:', err.message); + res.status(500).json({ error: 'Failed to regenerate recovery codes' }); + } +}); + +export default router; diff --git a/server.js b/server.js index da7b575..26e012b 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,7 @@ import crypto from 'crypto'; import authRoutes from './routes/auth.js'; import passkeyRoutes from './routes/passkeys.js'; +import totpRoutes from './routes/totp.js'; import serverRoutes from './routes/servers.js'; import adminRoutes from './routes/admin.js'; import notificationRoutes from './routes/notifications.js'; @@ -36,6 +37,8 @@ import { migrate } from './config/migrate.js'; import { query, closePool, getPoolStatus } from './config/db.js'; import { getRecentActivity } from './services/activity.js'; import { authenticateToken } from './middleware/auth.js'; +import { ensureLogFile, writeLog, startLogCleaner } from './services/fileLogger.js'; +import { advancedBotProtection, vpnProxyProtection, countryBlock, browserIntegrityCheck } from './middleware/security.js'; const app = express(); @@ -62,6 +65,19 @@ app.use((req, res, next) => { next(); }); +app.use((req, res, next) => { + if (req.path.startsWith('/api/')) { + const ip = req.ip || req.socket.remoteAddress || '0.0.0.0'; + writeLog(req.method, req.path, ip); + } + next(); +}); + +app.use('/api', advancedBotProtection()); +app.use('/api', browserIntegrityCheck()); +app.use('/api', vpnProxyProtection()); +app.use('/api', countryBlock()); + const activeRequests = new Map(); app.use((req, res, next) => { @@ -162,7 +178,7 @@ app.use((err, req, res, next) => { }); app.use(cookieParser(process.env.COOKIE_SECRET)); -const csrfExemptPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/passkey/options', '/api/auth/passkey/verify', '/api/auth/passkeys/login/begin', '/api/auth/passkeys/login/complete', '/api/auth/passkeys/register/begin', '/api/auth/passkeys/register/complete']; +const csrfExemptPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/passkey/options', '/api/auth/passkey/verify', '/api/auth/passkeys/login/begin', '/api/auth/passkeys/login/complete', '/api/auth/passkeys/register/begin', '/api/auth/passkeys/register/complete', '/api/auth/totp/verify', '/api/auth/totp/recovery']; app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); if (csrfExemptPaths.includes(req.path)) return next(); @@ -271,6 +287,7 @@ app.get('/api/config', (req, res) => { app.use('/api/auth', authRoutes); app.use('/api/auth', passkeyRoutes); +app.use('/api/auth', totpRoutes); app.use('/api/servers', serverRoutes); app.use('/api/admin', adminRoutes); app.use('/api/notifications', notificationRoutes); @@ -343,10 +360,13 @@ app.use((err, req, res, _next) => { res.status(err.status || 500).json({ error: message, requestId: req.requestId }); }); -migrate().then(() => { +async function startServer() { + await ensureLogFile(); + await migrate(); const server = app.listen(PORT, () => { console.log(`ZeroHost Dashboard running on port ${PORT}`); startScheduler(); + startLogCleaner(); }); function shutdown(signal) { @@ -364,8 +384,10 @@ migrate().then(() => { process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); -}).catch(err => { - console.error('Migration failed:', err); +} + +startServer().catch(err => { + console.error('Startup failed:', err); process.exit(1); }); diff --git a/services/fileLogger.js b/services/fileLogger.js new file mode 100644 index 0000000..aeb76a5 --- /dev/null +++ b/services/fileLogger.js @@ -0,0 +1,177 @@ +import { appendFile, access, constants, readFile, writeFile } from 'fs/promises'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LOG_FILE = resolve(__dirname, '..', 'log.txt'); + +const actionLabels = [ + // Auth + { pattern: /^GET \/api\/auth\/onboarding-status$/, label: 'Statut d\'onboarding' }, + { pattern: /^POST \/api\/auth\/complete-onboarding$/, label: 'Fin d\'onboarding' }, + { pattern: /^POST \/api\/auth\/register$/, label: 'Inscription' }, + { pattern: /^POST \/api\/auth\/login$/, label: 'Connexion' }, + { pattern: /^POST \/api\/auth\/logout$/, label: 'Déconnexion' }, + { pattern: /^POST \/api\/auth\/change-password$/, label: 'Changement de mot de passe' }, + { pattern: /^POST \/api\/auth\/change-email$/, label: 'Demande de changement d\'email' }, + { pattern: /^GET \/api\/auth\/change-email\/verify$/, label: 'Vérification de changement d\'email' }, + { pattern: /^POST \/api\/auth\/change-email\/confirm$/, label: 'Confirmation de changement d\'email' }, + { pattern: /^POST \/api\/auth\/delete-account$/, label: 'Suppression de compte' }, + { pattern: /^GET \/api\/auth\/check-availability$/, label: 'Vérification de disponibilité' }, + { pattern: /^GET \/api\/auth\/check-vpn$/, label: 'Vérification VPN' }, + { pattern: /^GET \/api\/auth\/verify-email$/, label: 'Vérification d\'email' }, + { pattern: /^POST \/api\/auth\/resend-verification$/, label: 'Renvoi de vérification d\'email' }, + { pattern: /^GET \/api\/auth\/export-data$/, label: 'Export de données' }, + + // Passkeys + { pattern: /^POST \/api\/auth\/passkeys\/register\/begin$/, label: 'Début d\'enregistrement de passkey' }, + { pattern: /^POST \/api\/auth\/passkeys\/register\/complete$/, label: 'Enregistrement de passkey' }, + { pattern: /^POST \/api\/auth\/passkeys\/login\/begin$/, label: 'Début de connexion par passkey' }, + { pattern: /^POST \/api\/auth\/passkeys\/login\/complete$/, label: 'Connexion par passkey' }, + { pattern: /^GET \/api\/auth\/passkeys$/, label: 'Liste des passkeys' }, + { pattern: /^DELETE \/api\/auth\/passkeys\/\d+$/, label: 'Suppression de passkey' }, + + // TOTP + { pattern: /^GET \/api\/auth\/totp\/status$/, label: 'Statut TOTP' }, + { pattern: /^POST \/api\/auth\/totp\/setup$/, label: 'Configuration TOTP' }, + { pattern: /^POST \/api\/auth\/totp\/enable$/, label: 'Activation TOTP' }, + { pattern: /^POST \/api\/auth\/totp\/disable$/, label: 'Désactivation TOTP' }, + { pattern: /^POST \/api\/auth\/totp\/verify$/, label: 'Vérification TOTP' }, + { pattern: /^POST \/api\/auth\/totp\/recovery$/, label: 'Code de récupération TOTP' }, + { pattern: /^GET \/api\/auth\/totp\/recovery-codes$/, label: 'Codes de récupération TOTP' }, + { pattern: /^POST \/api\/auth\/totp\/recovery-codes\/regenerate$/, label: 'Régénération de codes TOTP' }, + + // Servers + { pattern: /^GET \/api\/servers\/list$/, label: 'Liste des serveurs' }, + { pattern: /^GET \/api\/servers\/nests$/, label: 'Liste des nests' }, + { pattern: /^GET \/api\/servers\/eggs$/, label: 'Liste des eggs' }, + { pattern: /^GET \/api\/servers\/overview$/, label: 'Aperçu des serveurs' }, + { pattern: /^POST \/api\/servers\/create$/, label: 'Création de serveur' }, + { pattern: /^GET \/api\/servers\/details\/\d+$/, label: 'Détails du serveur' }, + { pattern: /^POST \/api\/servers\/renew\/\d+$/, label: 'Renouvellement de serveur' }, + { pattern: /^PATCH \/api\/servers\/\d+$/, label: 'Renommage de serveur' }, + { pattern: /^POST \/api\/servers\/\d+\/reinstall$/, label: 'Réinstallation de serveur' }, + { pattern: /^DELETE \/api\/servers\/\d+$/, label: 'Suppression de serveur' }, + { pattern: /^POST \/api\/servers\/power\/[A-Za-z0-9]+$/, label: 'Action d\'alimentation du serveur' }, + { pattern: /^GET \/api\/servers\/client-api-key$/, label: 'Vérification de clé API' }, + { pattern: /^PUT \/api\/servers\/client-api-key$/, label: 'Mise à jour de clé API' }, + { pattern: /^DELETE \/api\/servers\/client-api-key$/, label: 'Suppression de clé API' }, + + // Admin - Auth + { pattern: /^POST \/api\/admin\/login$/, label: 'Connexion admin' }, + { pattern: /^GET \/api\/admin\/check$/, label: 'Vérification admin' }, + + // Admin - Servers + { pattern: /^GET \/api\/admin\/servers$/, label: 'Liste des serveurs (admin)' }, + { pattern: /^GET \/api\/admin\/servers\/\d+$/, label: 'Détails du serveur (admin)' }, + { pattern: /^POST \/api\/admin\/servers\/\d+\/suspend$/, label: 'Suspension de serveur (admin)' }, + { pattern: /^POST \/api\/admin\/servers\/\d+\/unsuspend$/, label: 'Réactivation de serveur (admin)' }, + { pattern: /^POST \/api\/admin\/servers\/\d+\/stop$/, label: 'Arrêt de serveur (admin)' }, + { pattern: /^POST \/api\/admin\/servers\/\d+\/renew-now$/, label: 'Expiration forcée de serveur (admin)' }, + { pattern: /^DELETE \/api\/admin\/servers\/\d+$/, label: 'Suppression de serveur (admin)' }, + + // Admin - Nodes + { pattern: /^GET \/api\/admin\/nodes$/, label: 'Liste des nodes (admin)' }, + { pattern: /^GET \/api\/admin\/nodes\/\d+$/, label: 'Détails du node (admin)' }, + { pattern: /^GET \/api\/admin\/nodes\/\d+\/allocations$/, label: 'Allocations du node (admin)' }, + { pattern: /^GET \/api\/admin\/nodes\/\d+\/servers$/, label: 'Serveurs du node (admin)' }, + { pattern: /^GET \/api\/admin\/nodes\/\d+\/settings$/, label: 'Paramètres du node (admin)' }, + { pattern: /^PUT \/api\/admin\/nodes\/\d+\/settings$/, label: 'Modification des paramètres du node (admin)' }, + { pattern: /^GET \/api\/admin\/nodes\/unavailable$/, label: 'Nodes indisponibles (admin)' }, + + // Admin - Users + { pattern: /^GET \/api\/admin\/users$/, label: 'Liste des utilisateurs (admin)' }, + { pattern: /^GET \/api\/admin\/users\/\d+$/, label: 'Détails de l\'utilisateur (admin)' }, + { pattern: /^POST \/api\/admin\/users\/\d+\/toggle-restriction$/, label: 'Restriction d\'utilisateur (admin)' }, + { pattern: /^POST \/api\/admin\/users\/\d+\/toggle-auth-restriction$/, label: 'Restriction d\'auth (admin)' }, + { pattern: /^POST \/api\/admin\/users\/\d+\/toggle-admin$/, label: 'Changement de rôle admin (admin)' }, + { pattern: /^POST \/api\/admin\/users\/\d+\/notify$/, label: 'Notification à un utilisateur (admin)' }, + { pattern: /^POST \/api\/admin\/notify-all$/, label: 'Notification à tous (admin)' }, + { pattern: /^DELETE \/api\/admin\/users\/\d+$/, label: 'Suppression d\'utilisateur (admin)' }, + + // Admin - Stats & Activity + { pattern: /^GET \/api\/admin\/stats$/, label: 'Statistiques (admin)' }, + { pattern: /^GET \/api\/admin\/activity$/, label: 'Journal d\'activité (admin)' }, + + // Admin - Settings Nests + { pattern: /^GET \/api\/admin\/settings\/nests$/, label: 'Paramètres des nests (admin)' }, + { pattern: /^GET \/api\/admin\/settings\/nests\/available$/, label: 'Nests disponibles (admin)' }, + { pattern: /^POST \/api\/admin\/settings\/nests$/, label: 'Ajout de nest (admin)' }, + { pattern: /^PUT \/api\/admin\/settings\/nests\/\d+$/, label: 'Modification de nest (admin)' }, + { pattern: /^DELETE \/api\/admin\/settings\/nests\/\d+$/, label: 'Suppression de nest (admin)' }, + + // Admin - Settings Eggs + { pattern: /^GET \/api\/admin\/settings\/nests\/\d+\/eggs$/, label: 'Eggs du nest (admin)' }, + { pattern: /^GET \/api\/admin\/settings\/eggs\/\d+\/\d+$/, label: 'Paramètres de l\'egg (admin)' }, + { pattern: /^PUT \/api\/admin\/settings\/eggs\/\d+\/\d+$/, label: 'Modification de l\'egg (admin)' }, + { pattern: /^POST \/api\/admin\/settings\/eggs\/\d+\/\d+\/apply-all$/, label: 'Appliquer ressources à tous (admin)' }, + + // Notifications + { pattern: /^GET \/api\/notifications\/$/, label: 'Liste des notifications' }, + { pattern: /^GET \/api\/notifications\/unread-count$/, label: 'Notifications non lues' }, + { pattern: /^PATCH \/api\/notifications\/\d+\/read$/, label: 'Marquer notification comme lue' }, + { pattern: /^PATCH \/api\/notifications\/read-all$/, label: 'Marquer toutes les notifications comme lues' }, + + // General + { pattern: /^GET \/api\/activity$/, label: 'Journal d\'activité' }, + { pattern: /^GET \/api\/health$/, label: 'Vérification de santé' }, + { pattern: /^GET \/api\/config$/, label: 'Configuration' }, +]; + +function getActionLabel(method, path) { + const key = `${method} ${path}`; + for (const { pattern, label } of actionLabels) { + if (pattern.test(key)) return label; + } + return null; +} + +export async function ensureLogFile() { + try { + await access(LOG_FILE, constants.F_OK); + } catch { + await appendFile(LOG_FILE, '', 'utf-8'); + console.log('Created log.txt'); + } +} + +const ONE_YEAR = 365 * 24 * 60 * 60 * 1000; + +export async function cleanOldLogs() { + try { + const content = await readFile(LOG_FILE, 'utf-8'); + if (!content) return; + const cutoff = Date.now() - ONE_YEAR; + const lines = content.split('\n').filter(line => { + if (!line.trim()) return false; + const match = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/); + if (!match) return true; + const ts = new Date(match[1].replace(' ', 'T') + 'Z').getTime(); + return ts >= cutoff; + }); + const cleaned = lines.join('\n') + (lines.length > 0 ? '\n' : ''); + await writeFile(LOG_FILE, cleaned, 'utf-8'); + } catch (err) { + if (err.code !== 'ENOENT') { + console.error('Failed to clean old logs:', err.message); + } + } +} + +export function startLogCleaner() { + cleanOldLogs(); + setInterval(cleanOldLogs, 24 * 60 * 60 * 1000); +} + +export async function writeLog(method, path, ip) { + const label = getActionLabel(method, path); + const date = new Date(); + const timestamp = date.toISOString().replace('T', ' ').slice(0, 19); + const desc = label ? ` (${label})` : ''; + const line = `[${timestamp}] ${method} ${path}${desc} - ${ip}\n`; + try { + await appendFile(LOG_FILE, line, 'utf-8'); + } catch (err) { + console.error('Failed to write to log.txt:', err.message); + } +} diff --git a/services/security.js b/services/security.js new file mode 100644 index 0000000..9b9f23f --- /dev/null +++ b/services/security.js @@ -0,0 +1,530 @@ +import { isIP } from 'net'; +import { resolve } from 'path'; +import { readFile } from 'fs/promises'; + +const CLOUD_PROVIDER_ASNS = new Set([ + 'AS16509', 'AS39111', 'AS45102', 'AS16276', 'AS36351', 'AS13335', + 'AS14618', 'AS16509', 'AS20115', 'AS16509', 'AS14618', 'AS8987', + 'AS26496', 'AS30083', 'AS40065', 'AS46690', 'AS29791', 'AS36492', + 'AS55095', 'AS55059', 'AS13876', 'AS20326', 'AS21342', 'AS22385', + 'AS36352', 'AS20473', 'AS62567', 'AS32780', 'AS394906', 'AS54203', + 'AS53363', 'AS11878', 'AS14061', 'AS46664', 'AS147008', 'AS199524', + 'AS396982', 'AS63949', 'AS60068', 'AS55286', 'AS20454', 'AS53869', + 'AS19551', 'AS8455', 'AS29073', 'AS16302', 'AS21277', 'AS49333', + 'AS58057', 'AS59441', 'AS206264', 'AS61138', +]); + +const BOT_UA_PATTERNS = [ + /curl\//i, /wget\//i, /node-fetch/i, /python-requests/i, + /python-httpx/i, /urllib/i, /aiohttp/i, /go-http-client/i, + /java\/\d+/i, /libcurl/i, /okhttp/i, /httpie/i, + /postmanruntime/i, /insomnia/i, /axios\//i, /fetch\//i, + /ruby\//i, /perl\//i, /php\/\d+/i, /nethttp/i, /http-client/i, + /scrapy/i, /python-urllib/i, /robot/i, /spider/i, /crawler/i, + /masscan/i, /nmap/i, /zgrab/i, /fscan/i, /pure-native/i, + /fasthttp/i, /restsharp/i, /datadog/i, /newrelic/i, + /ahc\//i, /async-http/i, /lwp::simple/i, /www-mechanize/i, + /python-requests\/\d/i, /httpx/i, /httplib2/i, + /selenium/i, /puppeteer/i, /playwright/i, /cypress/i, + /headless/i, /phantomjs/i, /nightmare/i, + /Mozilla\/5\.0\s*$/i, /Mozilla\/4\.0\s*$/i, + /^$/, /^-$/, +]; + +const REQUIRED_BROWSER_HEADERS = { + 'accept': /^text\/html|application\/json|\*\/\*/, + 'accept-language': /^[a-z]{2}(-[A-Z]{2})?(,[a-z]{2}(-[A-Z]{2})?)*$/, + 'sec-fetch-site': /^(none|same-origin|same-site|cross-site)$/, + 'sec-fetch-mode': /^(navigate|same-origin|cors|no-cors)$/, + 'sec-fetch-dest': /^(document|empty|iframe|script)$/, +}; + +const FOUR_SECONDS = 4000; + +export function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) return forwarded.split(',')[0].trim(); + return req.ip || req.socket.remoteAddress || '0.0.0.0'; +} + +export function isPrivateIp(ip) { + const clean = typeof ip === 'string' ? ip.replace(/^::ffff:/, '') : ''; + if (!clean || !isIP(clean)) return false; + if (clean === '127.0.0.1' || clean === '::1' || clean === '0.0.0.0') return true; + if (clean.startsWith('192.168.') || clean.startsWith('10.') || clean.startsWith('172.16.')) return true; + if (/^f[cde][0-9a-f]{2}:/i.test(clean)) return true; + if (clean.startsWith('169.254.')) return true; + return false; +} + +export function normalizeIp(ip) { + if (typeof ip !== 'string') return null; + const trimmed = ip.trim(); + if (!trimmed) return null; + return trimmed.replace(/^::ffff:/, ''); +} + +export function checkHeaders(req) { + const issues = []; + if (!req.headers['accept']) issues.push('missing_accept'); + if (!req.headers['accept-language']) issues.push('missing_accept_language'); + const ua = (req.headers['user-agent'] || '').toLowerCase(); + if (!ua) issues.push('missing_ua'); + if (ua.length > 0 && ua.length < 20) issues.push('short_ua'); + if (ua === 'mozilla/5.0') issues.push('generic_ua'); + if (!req.headers['sec-ch-ua'] && !req.headers['sec-ch-ua-mobile']) { + if (!ua.includes('headless') && !ua.includes('bot')) { + issues.push('missing_sec_ch_ua'); + } + } + return issues; +} + +export function isBotUserAgent(ua) { + if (!ua || typeof ua !== 'string') return true; + const clean = ua.toString().slice(0, 512); + if (clean.length < 10) return true; + if (clean === 'Mozilla/5.0' || clean === 'Mozilla/4.0') return true; + for (const pattern of BOT_UA_PATTERNS) { + if (pattern.test(clean)) return true; + } + return false; +} + +export function isKnownBotIp(ip) { + const clean = typeof ip === 'string' ? ip.replace(/^::ffff:/, '') : ''; + if (!clean || !isIP(clean)) return false; + const parts = clean.split('.').map(Number); + if (parts.length !== 4) return false; + if (parts[0] === 3) return true; + if (parts[0] === 15) return true; + if (parts[0] === 23) return true; + if (parts[0] === 34) return true; + if (parts[0] === 35) return true; + if (parts[0] === 44) return true; + if (parts[0] === 45 && parts[1] === 8) return true; + if (parts[0] === 45 && parts[1] === 14) return true; + if (parts[0] === 45 && parts[1] === 15) return true; + if (parts[0] === 45 && parts[1] === 33) return true; + if (parts[0] === 45 && parts[1] === 40) return true; + if (parts[0] === 45 && parts[1] === 62) return true; + if (parts[0] === 45 && parts[1] === 64) return true; + if (parts[0] === 45 && parts[1] === 79) return true; + if (parts[0] === 45 && parts[1] === 80) return true; + if (parts[0] === 45 && parts[1] === 83) return true; + if (parts[0] === 45 && parts[1] === 88) return true; + if (parts[0] === 45 && parts[1] === 91) return true; + if (parts[0] === 45 && parts[1] === 94) return true; + if (parts[0] === 45 && parts[1] === 128) return true; + if (parts[0] === 45 && parts[1] === 135) return true; + if (parts[0] === 45 && parts[1] === 143) return true; + if (parts[0] === 45 && parts[1] === 148) return true; + if (parts[0] === 45 && parts[1] === 150) return true; + if (parts[0] === 45 && parts[1] === 152) return true; + if (parts[0] === 45 && parts[1] === 153) return true; + if (parts[0] === 45 && parts[1] === 155) return true; + if (parts[0] === 47) return true; + if (parts[0] === 51) return true; + if (parts[0] === 54) return true; + if (parts[0] === 63 && parts[1] === 32) return true; + if (parts[0] === 63 && parts[1] === 35) return true; + if (parts[0] === 64 && parts[1] === 62) return true; + if (parts[0] === 64 && parts[1] === 90) return true; + if (parts[0] === 65) return true; + if (parts[0] === 66) return true; + if (parts[0] === 67) return true; + if (parts[0] === 68) return true; + if (parts[0] === 69) return true; + if (parts[0] === 70) return true; + if (parts[0] === 71) return true; + if (parts[0] === 72) return true; + if (parts[0] === 73) return true; + if (parts[0] === 74) return true; + if (parts[0] === 75) return true; + if (parts[0] === 76) return true; + if (parts[0] === 77) return true; + if (parts[0] === 78) return true; + if (parts[0] === 79) return true; + if (parts[0] === 80) return true; + if (parts[0] === 81) return true; + if (parts[0] === 82) return true; + if (parts[0] === 83) return true; + if (parts[0] === 84) return true; + if (parts[0] === 85) return true; + if (parts[0] === 86) return true; + if (parts[0] === 87) return true; + if (parts[0] === 88) return true; + if (parts[0] === 89) return true; + if (parts[0] === 90) return true; + if (parts[0] === 91) return true; + if (parts[0] === 92) return true; + if (parts[0] === 93) return true; + if (parts[0] === 94) return true; + if (parts[0] === 95) return true; + if (parts[0] === 96) return true; + if (parts[0] === 97) return true; + if (parts[0] === 98) return true; + if (parts[0] === 99) return true; + if (parts[0] === 100) return true; + if (parts[0] === 101) return true; + if (parts[0] === 102) return true; + if (parts[0] === 103) return true; + if (parts[0] === 104) return true; + if (parts[0] === 104 && parts[1] === 16) return true; + if (parts[0] === 104 && parts[1] === 17) return true; + if (parts[0] === 104 && parts[1] === 18) return true; + if (parts[0] === 104 && parts[1] === 19) return true; + if (parts[0] === 104 && parts[1] === 20) return true; + if (parts[0] === 104 && parts[1] === 21) return true; + if (parts[0] === 104 && parts[1] === 22) return true; + if (parts[0] === 104 && parts[1] === 23) return true; + if (parts[0] === 104 && parts[1] === 24) return true; + if (parts[0] === 104 && parts[1] === 25) return true; + if (parts[0] === 104 && parts[1] === 26) return true; + if (parts[0] === 104 && parts[1] === 27) return true; + if (parts[0] === 104 && parts[1] === 28) return true; + if (parts[0] === 105) return true; + if (parts[0] === 106) return true; + if (parts[0] === 107) return true; + if (parts[0] === 108) return true; + if (parts[0] === 109) return true; + if (parts[0] === 110) return true; + if (parts[0] === 111) return true; + if (parts[0] === 112) return true; + if (parts[0] === 113) return true; + if (parts[0] === 114) return true; + if (parts[0] === 115) return true; + if (parts[0] === 116) return true; + if (parts[0] === 117) return true; + if (parts[0] === 118) return true; + if (parts[0] === 119) return true; + if (parts[0] === 120) return true; + if (parts[0] === 121) return true; + if (parts[0] === 122) return true; + if (parts[0] === 123) return true; + if (parts[0] === 124) return true; + if (parts[0] === 125) return true; + if (parts[0] === 126) return true; + if (parts[0] === 128) return true; + if (parts[0] === 129) return true; + if (parts[0] === 130) return true; + if (parts[0] === 131) return true; + if (parts[0] === 132) return true; + if (parts[0] === 133) return true; + if (parts[0] === 134) return true; + if (parts[0] === 135) return true; + if (parts[0] === 136) return true; + if (parts[0] === 137) return true; + if (parts[0] === 138) return true; + if (parts[0] === 139) return true; + if (parts[0] === 140) return true; + if (parts[0] === 141) return true; + if (parts[0] === 142) return true; + if (parts[0] === 143) return true; + if (parts[0] === 144) return true; + if (parts[0] === 145) return true; + if (parts[0] === 146) return true; + if (parts[0] === 147) return true; + if (parts[0] === 148) return true; + if (parts[0] === 149) return true; + if (parts[0] === 150) return true; + if (parts[0] === 151) return true; + if (parts[0] === 152) return true; + if (parts[0] === 153) return true; + if (parts[0] === 154) return true; + if (parts[0] === 155) return true; + if (parts[0] === 156) return true; + if (parts[0] === 157) return true; + if (parts[0] === 158) return true; + if (parts[0] === 159) return true; + if (parts[0] === 160) return true; + if (parts[0] === 161) return true; + if (parts[0] === 162) return true; + if (parts[0] === 163) return true; + if (parts[0] === 164) return true; + if (parts[0] === 165) return true; + if (parts[0] === 166) return true; + if (parts[0] === 167) return true; + if (parts[0] === 168) return true; + if (parts[0] === 169) return true; + if (parts[0] === 170) return true; + if (parts[0] === 171) return true; + if (parts[0] === 172) return true; + if (parts[0] === 173) return true; + if (parts[0] === 174) return true; + if (parts[0] === 175) return true; + if (parts[0] === 176) return true; + if (parts[0] === 177) return true; + if (parts[0] === 178) return true; + if (parts[0] === 179) return true; + if (parts[0] === 180) return true; + if (parts[0] === 181) return true; + if (parts[0] === 182) return true; + if (parts[0] === 183) return true; + if (parts[0] === 184) return true; + if (parts[0] === 185) return true; + if (parts[0] === 186) return true; + if (parts[0] === 187) return true; + if (parts[0] === 188) return true; + if (parts[0] === 189) return true; + if (parts[0] === 190) return true; + if (parts[0] === 191) return true; + if (parts[0] === 192 && parts[1] === 0) return true; + if (parts[0] === 192 && parts[1] === 3) return true; + if (parts[0] === 193) return true; + if (parts[0] === 194) return true; + if (parts[0] === 195) return true; + if (parts[0] === 196) return true; + if (parts[0] === 197) return true; + if (parts[0] === 198) return true; + if (parts[0] === 199) return true; + if (parts[0] === 200) return true; + if (parts[0] === 201) return true; + if (parts[0] === 202) return true; + if (parts[0] === 203) return true; + if (parts[0] === 204) return true; + if (parts[0] === 205) return true; + if (parts[0] === 206) return true; + if (parts[0] === 207) return true; + if (parts[0] === 208) return true; + if (parts[0] === 209) return true; + if (parts[0] === 210) return true; + if (parts[0] === 211) return true; + if (parts[0] === 212) return true; + if (parts[0] === 213) return true; + if (parts[0] === 214) return true; + if (parts[0] === 215) return true; + if (parts[0] === 216) return true; + if (parts[0] === 217) return true; + if (parts[0] === 218) return true; + if (parts[0] === 219) return true; + if (parts[0] === 220) return true; + if (parts[0] === 221) return true; + if (parts[0] === 222) return true; + return false; +} + +export function checkHoneypot(body) { + const honeypotFields = ['website', 'url', 'homepage', 'message2', 'confirm_email', 'fax', 'phone2']; + for (const field of honeypotFields) { + if (body[field] !== undefined && body[field] !== '' && body[field] !== null) { + return { triggered: true, field }; + } + } + return { triggered: false }; +} + +export function validateBrowserSignature(req) { + const score = { total: 0, checks: [] }; + const accept = req.headers['accept'] || ''; + if (/text\/html|application\/json|\*\/\*/.test(accept)) { + score.total += 10; + score.checks.push('accept_pass'); + } else { + score.checks.push('accept_suspicious'); + } + const lang = req.headers['accept-language'] || ''; + if (/^[a-z]{2}(-[A-Z]{2})?(,[a-z]{2}(-[A-Z]{2})?)*$/.test(lang)) { + score.total += 10; + score.checks.push('lang_pass'); + } else { + score.checks.push('lang_suspicious'); + } + const fetchSite = req.headers['sec-fetch-site'] || ''; + if (fetchSite && /^(none|same-origin|same-site|cross-site)$/.test(fetchSite)) { + score.total += 15; + score.checks.push('sec_fetch_site_pass'); + } + const fetchMode = req.headers['sec-fetch-mode'] || ''; + if (fetchMode && /^(navigate|same-origin|cors|no-cors)$/.test(fetchMode)) { + score.total += 15; + score.checks.push('sec_fetch_mode_pass'); + } + const fetchDest = req.headers['sec-fetch-dest'] || ''; + if (fetchDest && /^(document|empty|iframe|script)$/.test(fetchDest)) { + score.total += 15; + score.checks.push('sec_fetch_dest_pass'); + } + const secChUa = req.headers['sec-ch-ua'] || ''; + if (secChUa) { + score.total += 15; + score.checks.push('sec_ch_ua_pass'); + } + const dnt = req.headers['dnt'] || req.headers['sec-gpc'] || ''; + if (dnt) { + score.total += 5; + score.checks.push('dnt_pass'); + } + const ua = (req.headers['user-agent'] || '').toLowerCase(); + if (ua.includes('windows') || ua.includes('mac') || ua.includes('linux') || ua.includes('android') || ua.includes('ios') || ua.includes('iphone') || ua.includes('like mac')) { + score.total += 15; + score.checks.push('os_pass'); + } + score.passed = score.total >= 60; + return score; +} + +export function validateRequestTiming(req) { + const timing = req.headers['x-request-start'] || req.headers['x-timing'] || req.query._t || ''; + if (!timing) return { passed: false, reason: 'no_timing' }; + const startTime = parseInt(timing.toString(), 10); + if (isNaN(startTime) || startTime <= 0) return { passed: false, reason: 'invalid_timing' }; + const elapsed = Date.now() - startTime; + if (elapsed < FOUR_SECONDS) return { passed: false, reason: 'too_fast' }; + return { passed: true, elapsed }; +} + +const CACHE_DURATION = 15 * 60 * 1000; +const VPN_CACHE = new Map(); +const ASN_CACHE = new Map(); + +setInterval(() => { + const cutoff = Date.now() - CACHE_DURATION; + for (const [key, entry] of VPN_CACHE) { + if (entry.timestamp < cutoff) VPN_CACHE.delete(key); + } + for (const [key, entry] of ASN_CACHE) { + if (entry.timestamp < cutoff) ASN_CACHE.delete(key); + } +}, 5 * 60 * 1000); + +async function fetchWithTimeout(url, options = {}, timeout = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +export async function detectVpnProxy(ip) { + const cleanIp = normalizeIp(ip); + if (!cleanIp || isPrivateIp(cleanIp)) return { isVpn: false, isProxy: false, source: 'private' }; + const cached = VPN_CACHE.get(cleanIp); + if (cached && (Date.now() - cached.timestamp < CACHE_DURATION)) { + return cached.result; + } + let result = { isVpn: false, isProxy: false, source: 'none' }; + let asn = null; + try { + const res = await fetchWithTimeout(`http://ip-api.com/json/${encodeURIComponent(cleanIp)}?fields=proxy,hosting,isp,org,as,query`); + const data = await res.json(); + if (data.proxy === true || data.hosting === true) { + result = { isVpn: true, isProxy: data.proxy === true, source: 'ip-api' }; + } + asn = data.as || null; + if (asn && CLOUD_PROVIDER_ASNS.has(asn)) { + result = { isVpn: true, isProxy: true, source: 'asn', asn }; + } + } catch {} + if (asn) { + ASN_CACHE.set(cleanIp, { asn, timestamp: Date.now() }); + } + if (!result.isVpn) { + try { + const res = await fetchWithTimeout(`https://ipinfo.io/${encodeURIComponent(cleanIp)}/json`); + const data = await res.json(); + const org = (data.org || '').toLowerCase(); + if (org.includes('vpn') || org.includes('proxy') || org.includes('tor') || org.includes('datacenter') || org.includes('cloud') || org.includes('hosting')) { + result = { isVpn: true, isProxy: true, source: 'ipinfo' }; + } + if (!asn && data.asn) { + const asnCode = data.asn.split(' ')[0]; + if (CLOUD_PROVIDER_ASNS.has(asnCode)) { + result = { isVpn: true, isProxy: true, source: 'asn', asn: asnCode }; + } + } + } catch {} + } + if (!result.isVpn) { + try { + const res = await fetchWithTimeout(`https://vpnapi.io/api/${encodeURIComponent(cleanIp)}`); + const data = await res.json(); + if (data.security && (data.security.vpn || data.security.proxy || data.security.tor)) { + result = { isVpn: !!data.security.vpn, isProxy: !!data.security.proxy, isTor: !!data.security.tor, source: 'vpnapi' }; + } + } catch {} + } + if (!result.isVpn) { + try { + const torDnsbl = await resolveTorDnsbl(cleanIp); + if (torDnsbl) { + result = { isVpn: true, isTor: true, source: 'dnsbl' }; + } + } catch {} + } + VPN_CACHE.set(cleanIp, { result, timestamp: Date.now() }); + return result; +} + +async function resolveTorDnsbl(ip) { + try { + const parts = ip.split('.'); + if (parts.length !== 4) return false; + const reverseHost = `${parts[3]}.${parts[2]}.${parts[1]}.${parts[0]}.tor.dan.me.uk`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const res = await fetch(`http://${reverseHost}`, { signal: controller.signal }); + return res.status === 127; + } catch { + return false; + } finally { + clearTimeout(timer); + } + } catch { + return false; + } +} + +export function checkSuspiciousQueryParams(req) { + const query = req.query || {}; + const suspicious = ['debug', 'test', 'bypass', 'admin', 'sudo', 'cmd', 'exec', 'command', 'eval', 'system', 'shell', 'sql', 'union', 'select', 'from', 'where', 'drop', 'alter', 'create', 'insert', 'delete', 'update', '../', '..\\', '%00', '