Fix 7 frontend security vulnerabilities (4 critical, 3 high)

- Escape all innerHTML assignments with user/external data across 12 JS files
- Upgrade credential encryption: per-value IV, key moved to sessionStorage
- Fix open redirect in TOTP auth via proper URL hostname validation
- Remove sensitive DNS topology data from localStorage cache
- Add security regression test suite (51 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 01:29:04 -08:00
parent 59b6d7d360
commit 52577b11ed
13 changed files with 874 additions and 96 deletions

View File

@@ -135,7 +135,7 @@
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--ok-fg)';
} catch (e) {
resultDiv.innerHTML = `❌ Export failed: ${e.message}`;
resultDiv.innerHTML = `❌ Export failed: ${escapeHtml(e.message)}`;
resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--bad-fg)';
@@ -179,14 +179,14 @@
previewContent.innerHTML = html;
previewDiv.style.display = 'block';
} else {
resultDiv.innerHTML = `⚠️ Invalid backup file: ${preview.error}`;
resultDiv.innerHTML = `⚠️ Invalid backup file: ${escapeHtml(preview.error)}`;
resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
resultDiv.style.border = '1px solid #f39c12';
previewDiv.style.display = 'none';
}
} catch (e) {
resultDiv.innerHTML = `❌ Could not read file: ${e.message}`;
resultDiv.innerHTML = `❌ Could not read file: ${escapeHtml(e.message)}`;
resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--bad-fg)';
@@ -216,16 +216,16 @@
resultDiv.style.border = '1px solid var(--ok-fg)';
setTimeout(() => location.reload(), 2000);
} else {
resultDiv.innerHTML = `⚠️ ${data.message}`;
resultDiv.innerHTML = `⚠️ ${escapeHtml(data.message)}`;
if (data.results?.errors?.length > 0) {
resultDiv.innerHTML += '<br><small>' + data.results.errors.map(e => `${e.file}: ${e.error}`).join(', ') + '</small>';
resultDiv.innerHTML += '<br><small>' + data.results.errors.map(e => `${escapeHtml(e.file)}: ${escapeHtml(e.error)}`).join(', ') + '</small>';
}
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
resultDiv.style.border = '1px solid #f39c12';
}
resultDiv.style.display = 'block';
} catch (e) {
resultDiv.innerHTML = `❌ Restore failed: ${e.message}`;
resultDiv.innerHTML = `❌ Restore failed: ${escapeHtml(e.message)}`;
resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--bad-fg)';
@@ -280,7 +280,7 @@
document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule);
document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow);
} catch (e) {
scheduleContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: ${e.message}</div>`;
scheduleContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: ${escapeHtml(e.message)}</div>`;
}
}
@@ -309,7 +309,7 @@
});
const data = await res.json();
if (resultEl) {
resultEl.innerHTML = data.success ? '✅ Schedule saved' : `⚠️ ${data.error}`;
resultEl.innerHTML = data.success ? '✅ Schedule saved' : `⚠️ ${escapeHtml(data.error)}`;
resultEl.style.display = 'block';
resultEl.style.background = data.success ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = data.success ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)';
@@ -317,7 +317,7 @@
}
} catch (e) {
if (resultEl) {
resultEl.innerHTML = `${e.message}`;
resultEl.innerHTML = `${escapeHtml(e.message)}`;
resultEl.style.display = 'block';
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)';
@@ -343,7 +343,7 @@
resultEl.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--ok-fg)';
} else {
resultEl.innerHTML = `⚠️ ${data.error}`;
resultEl.innerHTML = `⚠️ ${escapeHtml(data.error)}`;
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)';
}
@@ -352,7 +352,7 @@
loadBackupHistory();
} catch (e) {
if (resultEl) {
resultEl.innerHTML = `${e.message}`;
resultEl.innerHTML = `${escapeHtml(e.message)}`;
resultEl.style.display = 'block';
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)';
@@ -378,10 +378,10 @@
const sizeMB = bk.size ? (bk.size / 1024 / 1024).toFixed(2) : '?';
html += `<div style="padding: 10px 12px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-weight: 500;">${bk.name || 'backup'}</span>
<span style="font-weight: 500;">${escapeHtml(bk.name || 'backup')}</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="status-badge ${bk.status === 'success' ? 'success' : 'down'}">${bk.status}</span>
${bk.status === 'success' ? `<button onclick="window.__restoreServerBackup('${bk.id}')" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>` : ''}
<span class="status-badge ${bk.status === 'success' ? 'success' : 'down'}">${escapeHtml(bk.status)}</span>
${bk.status === 'success' ? `<button onclick="window.__restoreServerBackup('${escapeHtml(bk.id)}')" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>` : ''}
</div>
</div>
<div style="font-size: 0.75rem; color: var(--muted);">
@@ -393,7 +393,7 @@
html += '</div>';
historyContainer.innerHTML = html;
} catch (e) {
historyContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
historyContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
}
}