diff --git a/middleware/security.js b/middleware/security.js index 9e18194..8be7369 100644 --- a/middleware/security.js +++ b/middleware/security.js @@ -126,6 +126,7 @@ export function vpnProxyProtection() { } if (blacklistResult.listed) { recordFailedAction(ip); + console.warn(`[DNSBL] IP ${cleanIp} blacklisted on: ${blacklistResult.blacklists.join(', ')}`); return res.status(403).json({ error: 'Access denied. Your IP has been flagged.', requestId: req.requestId, diff --git a/package.json b/package.json index 287f963..e4204ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zerohost-dashboard", - "version": "1.2.7", + "version": "1.2.8", "private": true, "type": "module", "scripts": { diff --git a/public/css/style.css b/public/css/style.css index f985499..826a399 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -3688,6 +3688,7 @@ 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-passkey_renamed { background: rgba(245,158,11,0.15); color: #f59e0b; } .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; } diff --git a/public/js/app.js b/public/js/app.js index 9a07241..1283e30 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1662,7 +1662,7 @@ async function renderDashboard() {
-
v1.2.7
+
v1.2.8
@@ -1933,6 +1933,10 @@ function renderSidebarNav() { Discord + + + Ko-fi + Leave a Review @@ -2426,6 +2430,7 @@ let activityIcons = { passkey_registered: '', email_verified: '', passkey_deleted: '', + passkey_renamed: '', totp_enabled: '', totp_disabled: '', login_totp: '', @@ -2465,6 +2470,7 @@ function getActionLabel(action) { passkey_registered: 'Passkey registered', email_verified: 'Email verified', passkey_deleted: 'Passkey deleted', + passkey_renamed: 'Passkey renamed', totp_enabled: 'Two-factor authentication enabled', totp_disabled: 'Two-factor authentication disabled', login_totp: 'Signed in with 2FA code', @@ -3599,18 +3605,29 @@ async function loadPasskeys() { } list.innerHTML = data.passkeys.map(p => html`
-
- -
-
${p.name || 'Passkey'}
+
+ +
+
+ ${escapeHtml(p.name || 'Passkey')} + +
${formatDate(p.created_at)}
- +
`).join(''); list.querySelectorAll('[data-passkey-id]').forEach(btn => { - btn.addEventListener('click', () => handleDeletePasskey(btn.dataset.passkeyId)); + if (btn.tagName === 'BUTTON') { + btn.addEventListener('click', () => handleDeletePasskey(btn.dataset.passkeyId)); + } + }); + list.querySelectorAll('.passkey-name-row').forEach(row => { + row.addEventListener('click', (e) => { + e.stopPropagation(); + startRenamePasskey(row); + }); }); initIcons(); } catch (err) { @@ -3619,6 +3636,52 @@ async function loadPasskeys() { } } +async function startRenamePasskey(row) { + const id = row.dataset.passkeyId; + const textEl = row.querySelector('.passkey-name-text'); + const currentName = textEl.textContent; + + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentName; + input.maxLength = 255; + input.style.cssText = 'font-size:0.9rem;font-weight:500;padding:2px 6px;border:1px solid var(--accent-1);border-radius:4px;background:var(--bg-primary);color:var(--text-primary);outline:none;width:100%;min-width:60px'; + + textEl.replaceWith(input); + input.focus(); + input.select(); + + const finish = async (save) => { + if (save) { + const newName = input.value.trim(); + if (newName && newName !== currentName) { + try { + await api(`/auth/passkeys/${id}`, { + method: 'PATCH', + body: JSON.stringify({ name: newName }), + }); + showToast('Passkey renamed', 'success'); + loadPasskeys(); + return; + } catch (err) { + showToast(err.message, 'error'); + } + } + } + const span = document.createElement('span'); + span.className = 'passkey-name-text'; + span.style.cssText = 'font-size:0.9rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; + span.textContent = currentName; + input.replaceWith(span); + }; + + input.addEventListener('blur', () => finish(true)); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); +} + async function handleRegisterPasskey() { const btn = $('#register-passkey-btn'); const status = $('#passkey-status'); diff --git a/routes/passkeys.js b/routes/passkeys.js index 36015bc..08ca936 100644 --- a/routes/passkeys.js +++ b/routes/passkeys.js @@ -344,6 +344,38 @@ router.get('/passkeys', authenticateToken, passkeyRegisterLimiter, async (req, r } }); +router.patch('/passkeys/:id', authenticateToken, passkeyRegisterLimiter, async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid passkey ID' }); + } + + const { name } = req.body; + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ error: 'Name is required' }); + } + const trimmed = name.trim().slice(0, 255); + + const passkeys = await query( + 'SELECT id FROM passkeys WHERE id = ? AND user_id = ?', + [id, req.user.userId] + ); + + if (!passkeys.length) { + return res.status(404).json({ error: 'Passkey not found' }); + } + + await query('UPDATE passkeys SET name = ? WHERE id = ?', [trimmed, id]); + await logActivity(req.user.userId, 'passkey_renamed', 'Renamed a passkey'); + + res.json({ success: true, name: trimmed }); + } catch (err) { + console.error('Passkey rename error:', err.message); + res.status(500).json({ error: 'Failed to rename passkey' }); + } +}); + router.delete('/passkeys/:id', authenticateToken, passkeyRegisterLimiter, async (req, res) => { try { const id = parseInt(req.params.id, 10); diff --git a/server.js b/server.js index d8cf8fa..651ef08 100644 --- a/server.js +++ b/server.js @@ -73,6 +73,9 @@ app.use((req, res, next) => { next(); }); +app.use(express.json({ limit: '1mb' })); +app.use(express.urlencoded({ extended: false, limit: '1mb' })); + app.use('/api', advancedBotProtection()); app.use('/api', browserIntegrityCheck()); app.use('/api', vpnProxyProtection()); @@ -138,9 +141,6 @@ app.use(cors({ allowedHeaders: ['Content-Type', 'Authorization'], })); -app.use(express.json({ limit: '1mb' })); -app.use(express.urlencoded({ extended: false, limit: '1mb' })); - app.use((req, res, next) => { if (req.path.startsWith('/api/') && !['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { return res.status(405).json({ error: 'Method not allowed', requestId: req.requestId }); diff --git a/services/fileLogger.js b/services/fileLogger.js index aeb76a5..5a1e471 100644 --- a/services/fileLogger.js +++ b/services/fileLogger.js @@ -29,6 +29,7 @@ const actionLabels = [ { 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: /^PATCH \/api\/auth\/passkeys\/\d+$/, label: 'Renommage de passkey' }, { pattern: /^DELETE \/api\/auth\/passkeys\/\d+$/, label: 'Suppression de passkey' }, // TOTP diff --git a/services/security.js b/services/security.js index 55d155f..de9432b 100644 --- a/services/security.js +++ b/services/security.js @@ -1,4 +1,5 @@ import { isIP } from 'net'; +import dns from 'dns'; import { randomBytes } from 'crypto'; import { resolve } from 'path'; import { readFile } from 'fs/promises'; @@ -120,19 +121,11 @@ async function checkDnsbl(ip, dnsbl) { if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return false; if (octets[0] === 192 && octets[1] === 168) return false; const reverseHost = `${octets[3]}.${octets[2]}.${octets[1]}.${octets[0]}.${dnsbl}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - try { - const res = await fetch(`http://${reverseHost}`, { - signal: controller.signal, - headers: { 'Accept': 'text/plain' }, - }); - return res.status >= 127; - } catch { - return false; - } finally { - clearTimeout(timer); - } + const addresses = await Promise.race([ + dns.promises.resolve4(reverseHost), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]); + return addresses.some(addr => addr.startsWith('127.0.0.')); } catch { return false; }