Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 107 additions & 40 deletions middleware/security.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,94 @@ import {
normalizeIp,
isBotUserAgent,
isKnownBotIp,
isIpSuspicious,
checkHeaders,
checkHoneypot,
validateBrowserSignature,
detectVpnProxy,
checkSuspiciousQueryParams,
checkBlockedCountry,
checkConcurrentRequests,
validateRequestTiming,
checkReferrer,
checkBodySuspicious,
checkIpBlacklists,
calculateOverallRisk,
recordFailedAction,
isDisposableEmail,
checkPasswordBreach,
verifySubmitToken,
} 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();
const SENSITIVE_PATHS = [
'/api/auth/register', '/api/auth/login', '/api/auth/change-password',
'/api/auth/change-email', '/api/auth/delete-account',
'/api/servers/create', '/api/admin/login',
];

export function advancedBotProtection(required = false) {
export function advancedBotProtection() {
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);
const isPost = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.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 });
}
if (isIpSuspicious(ip) && isPost) {
return res.status(403).json({ error: 'Access denied due to suspicious activity.', requestId: req.requestId });
}

const { allowed: concurrentOk, count: concurrentCount } = checkConcurrentRequests(ip, 8);
const { allowed: concurrentOk } = checkConcurrentRequests(ip, 6);
if (!concurrentOk && isPost) {
return res.status(429).json({ error: 'Too many simultaneous requests. Slow down.', requestId: req.requestId });
return res.status(429).json({ error: 'Too many simultaneous requests.', 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 });
recordFailedAction(ip);
return res.status(403).json({ error: 'Automated requests are not allowed.', 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);
const { flagged: queryFlagged } = checkSuspiciousQueryParams(req);
if (queryFlagged && isPost) {
console.warn(`[SECURITY] Suspicious query param "${param}" containing "${pattern}" from IP ${ip}`);
recordFailedAction(ip);
return res.status(400).json({ error: 'Invalid request parameters', requestId: req.requestId });
}

const { triggered: honeypotTriggered, field: honeypotField } = checkHoneypot(req.body);
const { triggered: honeypotTriggered } = checkHoneypot(req.body);
if (honeypotTriggered && isPost) {
console.warn(`[SECURITY] Honeypot triggered on field "${honeypotField}" from IP ${ip}`);
recordFailedAction(ip);
return res.status(400).json({ error: 'Invalid form submission', requestId: req.requestId });
}

const bodyCheck = checkBodySuspicious(req.body);
if (bodyCheck.flagged && isPost) {
recordFailedAction(ip);
return res.status(400).json({ error: 'Request contains invalid content', requestId: req.requestId });
}

if (isPost && SENSITIVE_PATHS.includes(req.path)) {
const referrerCheck = checkReferrer(req);
if (!referrerCheck.passed) {
recordFailedAction(ip);
return res.status(403).json({ error: 'Invalid request origin', requestId: req.requestId });
}
}

if (isPost) {
const risk = calculateOverallRisk(req);
if (risk.level === 'high') {
recordFailedAction(ip);
return res.status(403).json({ error: 'Request blocked for security reasons', requestId: req.requestId });
}
}

next();
} catch (err) {
console.error('[SECURITY] Middleware error:', err.message);
Expand All @@ -90,17 +110,26 @@ export function vpnProxyProtection() {
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})`);
const [vpnResult, blacklistResult] = await Promise.all([
detectVpnProxy(cleanIp),
req.path.startsWith('/api/auth/') || req.path.startsWith('/api/servers/') ? checkIpBlacklists(cleanIp) : { listed: false },
]);
if (vpnResult.isVpn || vpnResult.isProxy || vpnResult.isTor) {
recordFailedAction(ip);
return res.status(403).json({
error: result.isTor
error: vpnResult.isTor
? 'Tor network access is not allowed.'
: 'VPN or proxy detected. Please disable your VPN for security reasons.',
requestId: req.requestId,
});
}
if (blacklistResult.listed) {
recordFailedAction(ip);
return res.status(403).json({
error: 'Access denied. Your IP has been flagged.',
requestId: req.requestId,
});
}
next();
} catch (err) {
console.error('[SECURITY] VPN proxy middleware error:', err.message);
Expand All @@ -119,7 +148,7 @@ export function countryBlock() {
const ip = getClientIp(req);
const { blocked, countryCode } = await checkBlockedCountry(ip);
if (blocked) {
console.warn(`[SECURITY] Blocked request from ${countryCode} IP ${ip}`);
recordFailedAction(ip);
return res.status(403).json({ error: 'Service not available in your region.', requestId: req.requestId });
}
next();
Expand All @@ -138,14 +167,13 @@ export function browserIntegrityCheck() {
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(', ')}`);
recordFailedAction(getClientIp(req));
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}`);
if (signature.total < 30 && issues.length > 0) {
recordFailedAction(getClientIp(req));
return res.status(403).json({ error: 'Browser verification failed. Please use a modern browser.', requestId: req.requestId });
}
next();
} catch (err) {
Expand All @@ -155,13 +183,52 @@ export function browserIntegrityCheck() {
};
}

export function disposableEmailCheck() {
return async (req, res, next) => {
try {
if (!req.path.startsWith('/api/')) return next();
const email = req.body?.email;
if (!email || typeof email !== 'string') return next();
if (await isDisposableEmail(email)) {
return res.status(403).json({ error: 'Temporary email addresses are not allowed.', requestId: req.requestId });
}
next();
} catch (err) {
console.error('[SECURITY] Disposable email check error:', err.message);
next();
}
};
}

export function passwordBreachCheck() {
return async (req, res, next) => {
try {
if (!req.path.startsWith('/api/')) return next();
const password = req.body?.password || req.body?.newPassword;
if (!password || typeof password !== 'string') return next();
if (password.length < 6) return next();
const { breached } = await checkPasswordBreach(password);
if (breached) {
return res.status(400).json({
error: 'This password has been exposed in a data breach. Please choose a different password.',
requestId: req.requestId,
});
}
next();
} catch (err) {
console.error('[SECURITY] Password breach check 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)}`);
recordFailedAction(ip, action.includes('login') ? 'login' : 'action');
}
return origJson(body);
};
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zerohost-dashboard",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1654,7 +1654,7 @@ async function renderDashboard() {
<div style="padding:8px 12px 0;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">

</div>
<div style="padding:4px 0 8px;text-align:center;font-size:0.7rem;color:var(--text-muted);letter-spacing:0.05em">v1.2.0</div>
<div style="padding:4px 0 8px;text-align:center;font-size:0.7rem;color:var(--text-muted);letter-spacing:0.05em">v1.2.1</div>
</div>
<div class="sidebar-resizer" id="sidebar-resizer"></div>
</aside>
Expand Down
5 changes: 4 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ 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';
import { advancedBotProtection, vpnProxyProtection, countryBlock, browserIntegrityCheck, disposableEmailCheck, passwordBreachCheck } from './middleware/security.js';

const app = express();

Expand Down Expand Up @@ -285,6 +285,9 @@ app.get('/api/config', (req, res) => {
});
});

app.use('/api/auth/register', disposableEmailCheck());
app.use('/api/auth/register', passwordBreachCheck());
app.use('/api/auth/change-password', passwordBreachCheck());
app.use('/api/auth', authRoutes);
app.use('/api/auth', passkeyRoutes);
app.use('/api/auth', totpRoutes);
Expand Down
Loading
Loading