From d23d0b816f44d21ae0e90c63ddedf828a4b94685 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:32:39 +0200 Subject: [PATCH 01/11] Add CSRF protection middleware (double-submit cookie pattern) --- server.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/server.js b/server.js index 8ea5e2e..c8a0d9d 100644 --- a/server.js +++ b/server.js @@ -162,6 +162,30 @@ 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']; +app.use((req, res, next) => { + if (!req.path.startsWith('/api/')) return next(); + if (csrfExemptPaths.includes(req.path)) return next(); + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + const token = crypto.randomBytes(32).toString('hex'); + if (!req.cookies['XSRF-TOKEN']) { + res.cookie('XSRF-TOKEN', token, { + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + httpOnly: false, + maxAge: 24 * 60 * 60 * 1000, + }); + } + return next(); + } + const headerToken = req.headers['x-csrf-token']; + const cookieToken = req.cookies['XSRF-TOKEN']; + if (!headerToken || !cookieToken || headerToken !== cookieToken) { + return res.status(403).json({ error: 'Invalid CSRF token', requestId: req.requestId }); + } + next(); +}); + app.use((req, res, next) => { res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('X-Robots-Tag', 'noindex, nofollow'); From a7822fc6fa934c71b124b5aae552a734d629cfe7 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:33:28 +0200 Subject: [PATCH 02/11] Add rate limiting to static/admin routes --- server.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index c8a0d9d..6f83b48 100644 --- a/server.js +++ b/server.js @@ -221,6 +221,15 @@ const activityLimiter = rateLimit({ trustProxy: trustProxy ? 1 : 0, }); +const staticLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + message: { error: 'Too many requests' }, + standardHeaders: true, + legacyHeaders: false, + trustProxy: trustProxy ? 1 : 0, +}); + app.use('/api/auth/login', authLimiter); app.use('/api/auth/register', authLimiter); app.use('/api/activity', activityLimiter); @@ -311,17 +320,17 @@ app.get('/api/health', async (req, res) => { app.use(express.static(path.join(__dirname, 'public'))); -app.get('/admin/*', (req, res) => { +app.get('/admin/*', staticLimiter, (req, res) => { res.set('X-Robots-Tag', 'noindex, nofollow'); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -app.get('/admin', (req, res) => { +app.get('/admin', staticLimiter, (req, res) => { res.set('X-Robots-Tag', 'noindex, nofollow'); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -app.get('*', (req, res) => { +app.get('*', staticLimiter, (req, res) => { if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'Not found' }); } From 3f01fb06c548245506703cedb9cd868a8a9a9041 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:33:49 +0200 Subject: [PATCH 03/11] Sanitize img.src assignments to prevent XSS via javascript: URLs --- public/js/admin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/admin.js b/public/js/admin.js index 36c4908..7b72bab 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1870,7 +1870,7 @@ async function renderAdminEggSettings(nestId, eggId) { const val = $a('#egg-logo').value; if (val) { if (preview) preview.style.display = 'block'; - if (img) img.src = val; + if (img && /^https?:\/\/|^data:|^blob:/.test(val)) img.src = val; } else { if (preview) preview.style.display = 'none'; } @@ -2228,7 +2228,7 @@ function showRenameNestModal(nestId, currentName, currentLogo, currentDescriptio const img = preview?.querySelector('img'); if (logoInput.value) { if (preview) preview.style.display = 'block'; - if (img) img.src = logoInput.value; + if (img && /^https?:\/\/|^data:|^blob:/.test(logoInput.value)) img.src = logoInput.value; } else { if (preview) preview.style.display = 'none'; } From bd110c10aed9b94b4a486f096d10341002ceb99b Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:34:56 +0200 Subject: [PATCH 04/11] Escape exception messages before inserting into innerHTML (XSS prevention) --- public/js/admin.js | 12 ++++++------ public/js/app.js | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/public/js/admin.js b/public/js/admin.js index 7b72bab..ed0cb48 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -589,7 +589,7 @@ async function fetchAdminServers(pageNum) { } } catch (err) { const tbody = $a('#admin-servers-tbody'); - if (tbody) tbody.innerHTML = `Error: ${err.message}`; + if (tbody) tbody.innerHTML = `Error: ${escapeHtml(err.message)}`; } initIcons(); } @@ -1151,7 +1151,7 @@ async function renderAdminDashboard() { }); } } catch (err) { - el.innerHTML = `
Error
${err.message}
`; + el.innerHTML = `
Error
${escapeHtml(err.message)}
`; } } @@ -1212,7 +1212,7 @@ async function renderAdminUsers() { `).join(''); } catch (err) { const tbody = $a('#admin-users-tbody'); - if (tbody) tbody.innerHTML = `Error: ${err.message}`; + if (tbody) tbody.innerHTML = `Error: ${escapeHtml(err.message)}`; } initIcons(); } @@ -1639,7 +1639,7 @@ async function renderAdminEggsSettings() { }); } catch (err) { const tbody = $a('#admin-nests-tbody'); - if (tbody) tbody.innerHTML = `Error: ${err.message}`; + if (tbody) tbody.innerHTML = `Error: ${escapeHtml(err.message)}`; } } @@ -1737,7 +1737,7 @@ async function renderAdminNestEggs(nestId) { }); } catch (err) { const tbody = $a('#admin-eggs-tbody'); - if (tbody) tbody.innerHTML = `Error: ${err.message}`; + if (tbody) tbody.innerHTML = `Error: ${escapeHtml(err.message)}`; } initIcons(); } @@ -2381,7 +2381,7 @@ async function renderAdminNodes() { }).join(''); } catch (err) { const tbody = $a('#admin-nodes-tbody'); - if (tbody) tbody.innerHTML = `Error: ${err.message}`; + if (tbody) tbody.innerHTML = `Error: ${escapeHtml(err.message)}`; } } diff --git a/public/js/app.js b/public/js/app.js index 90fc2e9..a13d2a5 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -3296,7 +3296,7 @@ async function loadPasskeys() { initIcons(); } catch (err) { const list = $('#passkey-list'); - if (list) list.innerHTML = `

Failed to load passkeys: ${err.message}

`; + if (list) list.innerHTML = `

Failed to load passkeys: ${escapeHtml(err.message)}

`; } } From 27e683db1ec47498026945b05cf7fa908f8008b9 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:35:09 +0200 Subject: [PATCH 05/11] Add explicit workflow permissions (contents: read) --- .github/workflows/deploy-beta.yml | 3 +++ .github/workflows/deploy-main.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index fa7402b..8938c6e 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -4,6 +4,9 @@ on: push: branches: [beta] +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml index 4b4461d..8aa3d58 100644 --- a/.github/workflows/deploy-main.yml +++ b/.github/workflows/deploy-main.yml @@ -4,6 +4,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-latest From c760077426a2c222ca464a06296ea6f28e5aac90 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:37:07 +0200 Subject: [PATCH 06/11] Bump version to 1.0.9 (sidebar + package.json) --- package.json | 2 +- public/js/app.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2e7abd3..81b96bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.0.8", + "version": "1.0.9", "private": true, "type": "module", "scripts": { diff --git a/public/js/app.js b/public/js/app.js index a13d2a5..1e87ffb 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1496,7 +1496,7 @@ async function renderDashboard() {
-
v1.0.8
+
v1.0.9
From 49472224485e149ccbb2d703c88dc694589dda56 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:39:30 +0200 Subject: [PATCH 07/11] Validate image URLs via URL constructor to prevent XSS --- public/js/admin.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/public/js/admin.js b/public/js/admin.js index ed0cb48..00a3104 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1870,7 +1870,9 @@ async function renderAdminEggSettings(nestId, eggId) { const val = $a('#egg-logo').value; if (val) { if (preview) preview.style.display = 'block'; - if (img && /^https?:\/\/|^data:|^blob:/.test(val)) img.src = val; + if (img) { + try { img.src = new URL(val).href; } catch { img.src = ''; } + } } else { if (preview) preview.style.display = 'none'; } @@ -2228,7 +2230,9 @@ function showRenameNestModal(nestId, currentName, currentLogo, currentDescriptio const img = preview?.querySelector('img'); if (logoInput.value) { if (preview) preview.style.display = 'block'; - if (img && /^https?:\/\/|^data:|^blob:/.test(logoInput.value)) img.src = logoInput.value; + if (img) { + try { img.src = new URL(logoInput.value).href; } catch { img.src = ''; } + } } else { if (preview) preview.style.display = 'none'; } From 5c018f73d595ca10e62b921070409ed61185352d Mon Sep 17 00:00:00 2001 From: ddrayko Date: Wed, 15 Jul 2026 15:39:40 +0200 Subject: [PATCH 08/11] Add protocol whitelist for image URL validation --- public/js/admin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/admin.js b/public/js/admin.js index 00a3104..6fb6e0e 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1871,7 +1871,7 @@ async function renderAdminEggSettings(nestId, eggId) { if (val) { if (preview) preview.style.display = 'block'; if (img) { - try { img.src = new URL(val).href; } catch { img.src = ''; } + try { const u = new URL(val); if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'data:' || u.protocol === 'blob:') img.src = u.href; else img.src = ''; } catch { img.src = ''; } } } else { if (preview) preview.style.display = 'none'; @@ -2231,7 +2231,7 @@ function showRenameNestModal(nestId, currentName, currentLogo, currentDescriptio if (logoInput.value) { if (preview) preview.style.display = 'block'; if (img) { - try { img.src = new URL(logoInput.value).href; } catch { img.src = ''; } + try { const u = new URL(logoInput.value); if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'data:' || u.protocol === 'blob:') img.src = u.href; else img.src = ''; } catch { img.src = ''; } } } else { if (preview) preview.style.display = 'none'; From ecc7b287a579a5dcda51c2f0bb4bd5b593a228cc Mon Sep 17 00:00:00 2001 From: ddrayko <189259481+ddrayko@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:21:06 +0000 Subject: [PATCH 09/11] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index aff50c7..bc29cc1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Game server management dashboard for the ZeroHost platform. Provides user-facing image -[![ZHSL](https://img.zero-host.org/assets/github.png)](https://github.com/ZeroHost-Code/legal/blob/main/zhsl.md) +> [!NOTE] +> The dashboard is now in "cruise mode", instead of dozens of commits a day, expect around 2-3 commits per day going forward, as I'm juggling other projects on the side. That said, there will still be days with bigger pushes when needed. --- From dab2953998514dc96e91a7e13507dcb260f170cb Mon Sep 17 00:00:00 2001 From: ddrayko <189259481+ddrayko@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:21:37 +0000 Subject: [PATCH 10/11] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc29cc1..c03d2b8 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ Game server management dashboard for the ZeroHost platform. Provides user-facing server lifecycle management (creation, renewal, suspension, deletion) backed by a panel API. -image - > [!NOTE] > The dashboard is now in "cruise mode", instead of dozens of commits a day, expect around 2-3 commits per day going forward, as I'm juggling other projects on the side. That said, there will still be days with bigger pushes when needed. +image + --- ## Table of Contents From 795247595b78f21dfce6a65e7abbb5db09a9b3d3 Mon Sep 17 00:00:00 2001 From: ddrayko Date: Thu, 16 Jul 2026 12:28:57 +0200 Subject: [PATCH 11/11] Add search bar to admin servers page (filter by server name or owner) --- public/js/admin.js | 23 ++++++++++++++++++++++- routes/admin.js | 32 ++++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/public/js/admin.js b/public/js/admin.js index 6fb6e0e..c298f61 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -501,17 +501,23 @@ function updateAdminNav() { } let adminServersPage = 1; +let adminServersSearch = ''; async function renderAdminServers() { document.querySelectorAll('.admin-page').forEach(p => p.classList.remove('active')); const el = $a('#admin-page-servers'); if (!el) return; el.classList.add('active'); + adminServersSearch = ''; el.innerHTML = ahtml` +
+ + +
@@ -531,6 +537,19 @@ async function renderAdminServers() { `; + const searchInput = $a('#admin-servers-search'); + if (searchInput) { + let searchTimer; + searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + adminServersSearch = searchInput.value.trim(); + adminServersPage = 1; + fetchAdminServers(1); + }, 400); + }); + } + await fetchAdminServers(adminServersPage); } @@ -539,9 +558,11 @@ async function fetchAdminServers(pageNum) { const limit = 10; const offset = (pageNum - 1) * limit; const paginationEl = $a('#admin-servers-pagination'); + const params = new URLSearchParams({ limit, offset }); + if (adminServersSearch) params.set('search', adminServersSearch); try { - const data = await adminApi(`/servers?limit=${limit}&offset=${offset}`); + const data = await adminApi(`/servers?${params.toString()}`); const tbody = $a('#admin-servers-tbody'); if (!tbody) return; diff --git a/routes/admin.js b/routes/admin.js index c417ed3..3fbe430 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -138,11 +138,16 @@ router.get('/servers', authenticateToken, requireAdmin, async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50); const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); + const search = (req.query.search || '').trim().toLowerCase(); - const result = await getAllServers(limit, offset); - const { servers: paginatedServers, total } = result; - const page = Math.floor(offset / limit) + 1; - const totalPages = Math.ceil(total / limit) || 1; + let allServers; + if (search) { + const result = await getAllServers(); + allServers = Array.isArray(result) ? result : result.servers || []; + } else { + const result = await getAllServers(limit, offset); + allServers = result.servers || []; + } const users = await query('SELECT id, email, username, ptero_user_id FROM users'); const userMap = {}; @@ -150,7 +155,7 @@ router.get('/servers', authenticateToken, requireAdmin, async (req, res) => { userMap[u.ptero_user_id] = { id: u.id, email: u.email, username: u.username }; } - for (const s of paginatedServers) { + for (const s of allServers) { s.owner = userMap[s.user] || { id: null, email: 'Unknown', username: 'Unknown' }; try { const meta = await query('SELECT * FROM server_meta WHERE ptero_server_id = ?', [s.id]); @@ -160,7 +165,22 @@ router.get('/servers', authenticateToken, requireAdmin, async (req, res) => { } } - res.json({ servers: paginatedServers, total, page, totalPages, limit }); + let filtered = allServers; + if (search) { + filtered = allServers.filter(s => { + const name = (s.name || '').toLowerCase(); + const ownerName = (s.owner?.username || '').toLowerCase(); + const ownerEmail = (s.owner?.email || '').toLowerCase(); + return name.includes(search) || ownerName.includes(search) || ownerEmail.includes(search); + }); + } + + const filteredTotal = filtered.length; + const page = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(filteredTotal / limit) || 1; + const sliced = filtered.slice(offset, offset + limit); + + res.json({ servers: sliced, total: filteredTotal, page, totalPages, limit }); } catch (err) { console.error('Admin servers list error:', err.message); res.status(500).json({ error: 'Failed to fetch servers' });