Skip to content

Commit 40ab8ac

Browse files
committed
Add server creation security checks (VPN, UA, email, country) with verification overlay
1 parent d185964 commit 40ab8ac

4 files changed

Lines changed: 283 additions & 4 deletions

File tree

public/css/style.css

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4607,3 +4607,126 @@ tbody tr:hover {
46074607
border-radius: 4px;
46084608
}
46094609

4610+
/* ===== VERIFICATION OVERLAY ===== */
4611+
.verification-overlay {
4612+
position: fixed;
4613+
inset: 0;
4614+
z-index: 9999;
4615+
display: flex;
4616+
align-items: center;
4617+
justify-content: center;
4618+
background: rgba(0, 0, 0, 0.85);
4619+
backdrop-filter: blur(8px);
4620+
opacity: 0;
4621+
transition: opacity 0.3s ease;
4622+
pointer-events: none;
4623+
}
4624+
4625+
.verification-overlay.open {
4626+
opacity: 1;
4627+
pointer-events: all;
4628+
}
4629+
4630+
.verification-card {
4631+
background: var(--bg-card);
4632+
border: 1px solid var(--border);
4633+
border-radius: var(--radius-lg);
4634+
padding: 48px 40px;
4635+
max-width: 420px;
4636+
width: 90%;
4637+
text-align: center;
4638+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
4639+
}
4640+
4641+
.verification-spinner {
4642+
width: 48px;
4643+
height: 48px;
4644+
border: 3px solid rgba(255, 255, 255, 0.1);
4645+
border-top-color: var(--accent-1);
4646+
border-radius: 50%;
4647+
animation: spin 0.8s linear infinite;
4648+
margin: 0 auto 24px;
4649+
}
4650+
4651+
.verification-title {
4652+
font-size: 1.25rem;
4653+
font-weight: 600;
4654+
margin-bottom: 6px;
4655+
color: var(--text-primary);
4656+
}
4657+
4658+
.verification-subtitle {
4659+
font-size: 0.875rem;
4660+
color: var(--text-secondary);
4661+
margin-bottom: 28px;
4662+
}
4663+
4664+
.verification-steps {
4665+
display: flex;
4666+
flex-direction: column;
4667+
gap: 14px;
4668+
text-align: left;
4669+
}
4670+
4671+
.verification-step {
4672+
display: flex;
4673+
align-items: center;
4674+
gap: 12px;
4675+
padding: 10px 14px;
4676+
border-radius: var(--radius-sm);
4677+
background: var(--bg-tertiary);
4678+
font-size: 0.85rem;
4679+
color: var(--text-muted);
4680+
transition: all var(--transition);
4681+
}
4682+
4683+
.verification-step.active {
4684+
color: var(--text-primary);
4685+
background: rgba(238, 129, 50, 0.08);
4686+
border: 1px solid rgba(238, 129, 50, 0.2);
4687+
}
4688+
4689+
.verification-step.done {
4690+
color: var(--accent-green);
4691+
}
4692+
4693+
.verification-step.failed {
4694+
color: var(--accent-red);
4695+
}
4696+
4697+
.verification-step-icon {
4698+
width: 16px;
4699+
height: 16px;
4700+
flex-shrink: 0;
4701+
display: flex;
4702+
align-items: center;
4703+
justify-content: center;
4704+
}
4705+
4706+
.vstep-spinner {
4707+
display: inline-block;
4708+
width: 14px;
4709+
height: 14px;
4710+
border: 2px solid rgba(238, 129, 50, 0.25);
4711+
border-top-color: var(--accent-1);
4712+
border-radius: 50%;
4713+
animation: spin 0.7s linear infinite;
4714+
}
4715+
4716+
.vstep-pending {
4717+
display: inline-block;
4718+
width: 14px;
4719+
height: 14px;
4720+
border: 2px solid var(--text-muted);
4721+
border-radius: 50%;
4722+
opacity: 0.4;
4723+
}
4724+
4725+
.vstep-check, .vstep-cross {
4726+
flex-shrink: 0;
4727+
}
4728+
4729+
.verification-success-icon, .verification-error-icon {
4730+
margin-bottom: 16px;
4731+
}
4732+

public/js/app.js

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2917,6 +2917,122 @@ function isValidServerName(name) {
29172917
return name && name.length >= 1 && name.length <= 255 && /^[a-zA-Z0-9 _.-]+$/.test(name);
29182918
}
29192919

2920+
function showVerificationPage(name) {
2921+
const overlay = document.createElement('div');
2922+
overlay.id = 'verification-overlay';
2923+
overlay.className = 'verification-overlay';
2924+
overlay.innerHTML = html`
2925+
<div class="verification-card">
2926+
<div class="verification-spinner"></div>
2927+
<h2 class="verification-title">Creating "${escapeHtml(name)}"</h2>
2928+
<p class="verification-subtitle">Performing security checks...</p>
2929+
<div class="verification-steps" id="verification-steps">
2930+
<div class="verification-step active" id="vstep-vpn">
2931+
<div class="verification-step-icon"><span class="vstep-spinner"></span></div>
2932+
<span>Checking connection security</span>
2933+
</div>
2934+
<div class="verification-step" id="vstep-ua">
2935+
<div class="verification-step-icon"><span class="vstep-pending"></span></div>
2936+
<span>Verifying browser</span>
2937+
</div>
2938+
<div class="verification-step" id="vstep-email">
2939+
<div class="verification-step-icon"><span class="vstep-pending"></span></div>
2940+
<span>Checking email verification</span>
2941+
</div>
2942+
<div class="verification-step" id="vstep-country">
2943+
<div class="verification-step-icon"><span class="vstep-pending"></span></div>
2944+
<span>Verifying region</span>
2945+
</div>
2946+
<div class="verification-step" id="vstep-create">
2947+
<div class="verification-step-icon"><span class="vstep-pending"></span></div>
2948+
<span>Creating server</span>
2949+
</div>
2950+
</div>
2951+
</div>
2952+
`;
2953+
document.body.appendChild(overlay);
2954+
requestAnimationFrame(() => overlay.classList.add('open'));
2955+
2956+
let stepIndex = 0;
2957+
const steps = ['vstep-vpn', 'vstep-ua', 'vstep-email', 'vstep-country', 'vstep-create'];
2958+
const interval = setInterval(() => {
2959+
if (stepIndex < steps.length) {
2960+
const el = document.getElementById(steps[stepIndex]);
2961+
if (el) {
2962+
el.classList.remove('active');
2963+
el.classList.add('done');
2964+
el.querySelector('.vstep-spinner, .vstep-pending').outerHTML = '<svg class="vstep-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-green)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
2965+
}
2966+
stepIndex++;
2967+
if (stepIndex < steps.length) {
2968+
const next = document.getElementById(steps[stepIndex]);
2969+
if (next) {
2970+
next.classList.add('active');
2971+
next.querySelector('.vstep-pending').outerHTML = '<span class="vstep-spinner"></span>';
2972+
}
2973+
}
2974+
} else {
2975+
clearInterval(interval);
2976+
}
2977+
}, 600);
2978+
2979+
overlay._interval = interval;
2980+
}
2981+
2982+
function showVerificationSuccess(name) {
2983+
const overlay = document.getElementById('verification-overlay');
2984+
if (!overlay) return;
2985+
clearInterval(overlay._interval);
2986+
2987+
const steps = overlay.querySelectorAll('.verification-step');
2988+
steps.forEach(s => {
2989+
s.classList.remove('active');
2990+
s.classList.add('done');
2991+
const icon = s.querySelector('.vstep-spinner, .vstep-pending');
2992+
if (icon) icon.outerHTML = '<svg class="vstep-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-green)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
2993+
});
2994+
2995+
overlay.querySelector('.verification-card').innerHTML = html`
2996+
<svg class="verification-success-icon" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--accent-green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
2997+
<h2 class="verification-title">Server Created!</h2>
2998+
<p class="verification-subtitle">"${escapeHtml(name)}" is now being set up.</p>
2999+
`;
3000+
}
3001+
3002+
function showVerificationError(message) {
3003+
const overlay = document.getElementById('verification-overlay');
3004+
if (!overlay) return;
3005+
clearInterval(overlay._interval);
3006+
3007+
const steps = overlay.querySelectorAll('.verification-step.active');
3008+
steps.forEach(s => {
3009+
s.classList.remove('active');
3010+
s.classList.add('failed');
3011+
const icon = s.querySelector('.vstep-spinner');
3012+
if (icon) icon.outerHTML = '<svg class="vstep-cross" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-red)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
3013+
});
3014+
3015+
const card = overlay.querySelector('.verification-card');
3016+
card.innerHTML = html`
3017+
<svg class="verification-error-icon" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--accent-red)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
3018+
<h2 class="verification-title" style="color:var(--accent-red)">Verification Failed</h2>
3019+
<p class="verification-subtitle">${escapeHtml(message)}</p>
3020+
<button class="btn btn-primary" id="verification-retry-btn" style="margin-top:16px">Go Back</button>
3021+
`;
3022+
3023+
document.getElementById('verification-retry-btn').addEventListener('click', () => {
3024+
overlay.remove();
3025+
});
3026+
}
3027+
3028+
function removeVerificationOverlay() {
3029+
const overlay = document.getElementById('verification-overlay');
3030+
if (overlay) {
3031+
clearInterval(overlay._interval);
3032+
overlay.remove();
3033+
}
3034+
}
3035+
29203036
async function handleWizardCreate() {
29213037
const btn = $('#wizard-create-btn');
29223038
btn.disabled = true;
@@ -2935,16 +3051,21 @@ async function handleWizardCreate() {
29353051
const environment = {};
29363052
const dockerImage = createState.selectedDockerImage || '';
29373053

3054+
showVerificationPage(name);
3055+
29383056
try {
29393057
const capToken = document.querySelector('[name="cap-token"]')?.value || '';
29403058
await api('/servers/create', {
29413059
method: 'POST',
29423060
body: JSON.stringify({ name, nestId: nest.pteroNestId, eggId: egg.eggId, environment, capToken, dockerImage }),
29433061
});
2944-
showToast(`Server "${name}" created successfully!`, 'success');
2945-
navigateTo('servers');
3062+
showVerificationSuccess(name);
3063+
setTimeout(() => {
3064+
removeVerificationOverlay();
3065+
navigateTo('servers');
3066+
}, 2000);
29463067
} catch (err) {
2947-
showToast(err.message, 'error');
3068+
showVerificationError(err.message);
29483069
btn.disabled = false;
29493070
btn.innerHTML = '<i data-lucide="plus" style="width:16px;height:16px"></i> Create Server';
29503071
initIcons();

routes/auth.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,6 @@ router.get('/export-data', authenticateToken, sensitiveLimiter, async (req, res)
911911
}
912912
});
913913

914-
export { getClientIp };
914+
export { getClientIp, fetchWithTimeout, normalizeClientIp, isPrivateIp };
915915

916916
export default router;

routes/servers.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Router } from 'express';
22
import rateLimit from 'express-rate-limit';
33
import { authenticateToken, requireNotRestricted, requireOwnership } from '../middleware/auth.js';
4+
import { getClientIp, isVpnOrProxy, fetchWithTimeout, normalizeClientIp, isPrivateIp } from './auth.js';
45
import {
56
getServersByUser,
67
getServerById,
@@ -21,6 +22,20 @@ import { createNotification } from '../services/notification.js';
2122

2223
const router = Router();
2324

25+
const BLOCKED_COUNTRY_CODES = ['CN', 'RU'];
26+
27+
async function checkBlockedCountry(ip) {
28+
const cleanIp = normalizeClientIp(ip);
29+
if (!cleanIp || isPrivateIp(cleanIp)) return false;
30+
try {
31+
const res = await fetchWithTimeout(`http://ip-api.com/json/${encodeURIComponent(cleanIp)}?fields=countryCode`, {}, 5000);
32+
const data = await res.json();
33+
return BLOCKED_COUNTRY_CODES.includes(data.countryCode);
34+
} catch {
35+
return false;
36+
}
37+
}
38+
2439
const createServerLimiter = rateLimit({
2540
windowMs: 60 * 60 * 1000,
2641
max: 3,
@@ -228,6 +243,26 @@ router.post('/create', authenticateToken, requireNotRestricted, createServerLimi
228243
return res.status(400).json({ error: 'Please complete the security check' });
229244
}
230245

246+
const ip = getClientIp(req);
247+
const userAgent = (req.headers['user-agent'] || '').toString().slice(0, 512);
248+
249+
if (await isVpnOrProxy(ip)) {
250+
return res.status(403).json({ error: 'VPN or proxy detected. Please disable your VPN.', check: 'vpn' });
251+
}
252+
253+
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)) {
254+
return res.status(403).json({ error: 'Automated requests are not allowed. Please use a real browser.', check: 'useragent' });
255+
}
256+
257+
const userRows = await query('SELECT email_verified FROM users WHERE id = ?', [req.user.userId]);
258+
if (!userRows[0]?.email_verified) {
259+
return res.status(403).json({ error: 'Please verify your email before creating a server.', check: 'email' });
260+
}
261+
262+
if (await checkBlockedCountry(ip)) {
263+
return res.status(403).json({ error: 'Service not available in your region.', check: 'country' });
264+
}
265+
231266
// Check if nest or egg is unavailable
232267
try {
233268
const [nestRow] = await query('SELECT unavailable FROM nests WHERE ptero_nest_id = ?', [nestId]);

0 commit comments

Comments
 (0)