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:
@@ -284,7 +284,7 @@
|
||||
const header = document.createElement('div');
|
||||
header.className = 'app-category-header';
|
||||
const categoryInfo = apiCategories?.[category] || {};
|
||||
header.innerHTML = `${categoryInfo.icon || ''} ${category}`;
|
||||
header.innerHTML = `${escapeHtml(categoryInfo.icon || '')} ${escapeHtml(category)}`;
|
||||
if (categoryInfo.color) {
|
||||
header.style.borderBottomColor = categoryInfo.color;
|
||||
}
|
||||
@@ -310,7 +310,7 @@
|
||||
}20; color: ${
|
||||
app.difficulty === 'Easy' ? '#2ecc71' :
|
||||
app.difficulty === 'Intermediate' ? '#f39c12' : '#e74c3c'
|
||||
};">${app.difficulty}</div>` : '';
|
||||
};">${escapeHtml(app.difficulty)}</div>` : '';
|
||||
|
||||
option.innerHTML = `
|
||||
<div class="app-option-icon">${escapeHtml(app.icon || '📦')}</div>
|
||||
@@ -488,7 +488,7 @@
|
||||
btn.type = 'button';
|
||||
const isSelected = autoPaths.includes(mount.hostPath);
|
||||
btn.style.cssText = `padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${isSelected ? '40%' : '15%'}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`;
|
||||
btn.innerHTML = `<span style="font-weight: 500;">${mount.folderName}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${mount.sourceImage}</span>`;
|
||||
btn.innerHTML = `<span style="font-weight: 500;">${escapeHtml(mount.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(mount.sourceImage)}</span>`;
|
||||
btn.title = `${mount.hostPath} (from ${mount.sourceContainer})`;
|
||||
btn.onclick = () => {
|
||||
const currentPaths = mediaPathInput.value.split(',').map(p => p.trim()).filter(p => p);
|
||||
|
||||
@@ -79,13 +79,13 @@
|
||||
const ok = e.outcome === 'success';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(e.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${e.ip || '-'}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${e.action || '-'}</td>`;
|
||||
html += `<td style="padding: 6px;">${e.resource || '-'}</td>`;
|
||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(e.ip || '-')}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(e.action || '-')}</td>`;
|
||||
html += `<td style="padding: 6px;">${escapeHtml(e.resource || '-')}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'}</span></td>`;
|
||||
html += '</tr>';
|
||||
if (e.details && Object.keys(e.details).length > 0) {
|
||||
html += `<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${JSON.stringify(e.details, null, 2)}</pre></td></tr>`;
|
||||
html += `<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(e.details, null, 2))}</pre></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div style="padding: 20px; color: var(--bad-fg);">❌ Error loading logs: ${error.message}</div>`;
|
||||
content.innerHTML = `<div style="padding: 20px; color: var(--bad-fg);">❌ Error loading logs: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,13 @@ const DC = {
|
||||
};
|
||||
|
||||
// ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) =====
|
||||
// Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API
|
||||
const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null');
|
||||
const SITE = {
|
||||
tld: (_cachedCfg && _cachedCfg.tld) || '.home',
|
||||
dnsIp: (_cachedCfg && _cachedCfg.dnsIp) || '',
|
||||
dnsPort: (_cachedCfg && _cachedCfg.dnsPort) || DC.DEFAULTS.DNS_PORT,
|
||||
dnsServers: (_cachedCfg && _cachedCfg.dnsServers) || {},
|
||||
dnsIp: '',
|
||||
dnsPort: DC.DEFAULTS.DNS_PORT,
|
||||
dnsServers: {},
|
||||
configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab',
|
||||
domain: (_cachedCfg && _cachedCfg.domain) || '',
|
||||
defaults: (_cachedCfg && _cachedCfg.defaults) || {},
|
||||
@@ -53,11 +54,11 @@ const SITE = {
|
||||
if (c.domain) SITE.domain = c.domain;
|
||||
if (c.defaults) SITE.defaults = c.defaults;
|
||||
if (c.routingMode) SITE.routingMode = c.routingMode;
|
||||
// Cache config so next page load uses correct TLD even if API is slow
|
||||
// Cache only non-sensitive display config (TLD, domain, routing mode)
|
||||
// DNS IPs and server topology are NOT cached — fetched from API each load
|
||||
localStorage.setItem('dashcaddy_site_config', JSON.stringify({
|
||||
tld: SITE.tld, dnsIp: SITE.dnsIp, dnsPort: SITE.dnsPort, dnsServers: SITE.dnsServers,
|
||||
configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults,
|
||||
routingMode: SITE.routingMode
|
||||
tld: SITE.tld, configurationType: SITE.configurationType,
|
||||
domain: SITE.domain, routingMode: SITE.routingMode
|
||||
}));
|
||||
// Render DNS cards dynamically based on configured servers
|
||||
renderDnsCards();
|
||||
@@ -100,23 +101,24 @@ function renderDnsCards() {
|
||||
|
||||
const firstChild = topRow.firstElementChild;
|
||||
dnsIds.forEach(id => {
|
||||
const label = (SITE.dnsServers[id].name || id).toUpperCase();
|
||||
const safeId = escapeHtml(id);
|
||||
const label = escapeHtml((SITE.dnsServers[id].name || id).toUpperCase());
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.setAttribute('data-app', id);
|
||||
card.setAttribute('data-status', 'off');
|
||||
card.innerHTML =
|
||||
`<span id="${id}-dot" class="dot bad at-bl"></span>`
|
||||
`<span id="${safeId}-dot" class="dot bad at-bl"></span>`
|
||||
+ `<div class="row"><div class="logo-wrap">${svgIcon}</div>`
|
||||
+ `<span class="name">${label}</span><span class="spacer"></span>`
|
||||
+ `<span id="${id}-pill" class="badge off">OFF</span></div>`
|
||||
+ `<div class="response-row"><span id="${id}-time" class="response-time">--</span></div>`
|
||||
+ `<span id="${safeId}-pill" class="badge off">OFF</span></div>`
|
||||
+ `<div class="response-row"><span id="${safeId}-time" class="response-time">--</span></div>`
|
||||
+ `<div class="btn-row">`
|
||||
+ `<button id="${id}-restart" class="restart-btn">Restart</button>`
|
||||
+ `<button id="${id}-update" class="update-btn" title="Update DNS server">⬆️</button>`
|
||||
+ `<button id="${id}-open">Open</button>`
|
||||
+ `<button id="${id}-logs" class="logs-btn">Logs</button>`
|
||||
+ `<button id="${id}-settings" class="settings-btn">⚙️</button>`
|
||||
+ `<button id="${safeId}-restart" class="restart-btn">Restart</button>`
|
||||
+ `<button id="${safeId}-update" class="update-btn" title="Update DNS server">⬆️</button>`
|
||||
+ `<button id="${safeId}-open">Open</button>`
|
||||
+ `<button id="${safeId}-logs" class="logs-btn">Logs</button>`
|
||||
+ `<button id="${safeId}-settings" class="settings-btn">⚙️</button>`
|
||||
+ `</div>`;
|
||||
topRow.insertBefore(card, firstChild);
|
||||
});
|
||||
|
||||
@@ -137,15 +137,15 @@
|
||||
const u7d = s.uptime?.['7d'] ?? '-';
|
||||
const avgRt = s.avgResponseTime != null ? Math.round(s.avgResponseTime) + 'ms' : '-';
|
||||
const lastCheck = s.timestamp ? timeAgo(s.timestamp) : '-';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${s.serviceId}">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${s.name || s.serviceId}</td>`;
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(s.serviceId)}">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(s.name || s.serviceId)}</td>`;
|
||||
html += `<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${dotColor}; margin-right: 6px;"></span>${isUp ? 'Up' : 'Down'}</td>`;
|
||||
html += `<td style="padding: 8px; color: ${typeof u24 === 'number' ? uptimeColor(u24) : 'var(--muted)'};">${typeof u24 === 'number' ? u24.toFixed(1) + '%' : u24}</td>`;
|
||||
html += `<td style="padding: 8px; color: ${typeof u7d === 'number' ? uptimeColor(u7d) : 'var(--muted)'};">${typeof u7d === 'number' ? u7d.toFixed(1) + '%' : u7d}</td>`;
|
||||
html += `<td style="padding: 8px;">${avgRt}</td>`;
|
||||
html += `<td style="padding: 8px; color: var(--muted);">${lastCheck}</td>`;
|
||||
html += '</tr>';
|
||||
html += `<tr id="health-detail-${s.serviceId}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`;
|
||||
html += `<tr id="health-detail-${escapeHtml(s.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`;
|
||||
}
|
||||
html += '</table>';
|
||||
statusContainer.innerHTML = html;
|
||||
@@ -183,12 +183,12 @@
|
||||
detailRow.querySelector('td').innerHTML = '<div class="panel-empty">No detailed stats available for this period.</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
detailRow.querySelector('td').innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
detailRow.querySelector('td').innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
statusContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${e.message}</div>`;
|
||||
statusContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +209,10 @@
|
||||
for (const inc of open) {
|
||||
html += `<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 500;">${inc.serviceId}</span>
|
||||
<span style="font-weight: 500;">${escapeHtml(inc.serviceId)}</span>
|
||||
<span>${severityBadge(inc.severity)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${inc.message}</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(inc.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(inc.createdAt)} · ${inc.occurrences || 1} occurrence(s)</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -231,8 +231,8 @@
|
||||
const resolved = inc.status === 'resolved';
|
||||
const dur = resolved && inc.duration ? (inc.duration < 60000 ? Math.round(inc.duration / 1000) + 's' : Math.round(inc.duration / 60000) + 'm') : '-';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 6px;">${inc.serviceId}</td>`;
|
||||
html += `<td style="padding: 6px;">${inc.type}</td>`;
|
||||
html += `<td style="padding: 6px;">${escapeHtml(inc.serviceId)}</td>`;
|
||||
html += `<td style="padding: 6px;">${escapeHtml(inc.type)}</td>`;
|
||||
html += `<td style="padding: 6px;">${severityBadge(inc.severity)}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${resolved ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${inc.status}</span></td>`;
|
||||
html += `<td style="padding: 6px;">${dur}</td>`;
|
||||
@@ -244,7 +244,7 @@
|
||||
|
||||
incidentsContainer.innerHTML = html || '<div class="panel-empty"><span class="empty-icon">🚨</span>No incidents recorded yet.</div>';
|
||||
} catch (e) {
|
||||
incidentsContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
incidentsContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,18 +262,18 @@
|
||||
for (const s of services) {
|
||||
const isUp = s.status === 'up';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${s.name || s.serviceId}</td>`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(s.name || s.serviceId)}</td>`;
|
||||
html += `<td style="padding: 8px; color: ${isUp ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${isUp ? 'Up' : 'Down'}</td>`;
|
||||
html += `<td style="padding: 8px;">${s.sla?.target ? s.sla.target + '%' : '-'}</td>`;
|
||||
html += `<td style="padding: 8px; text-align: right;">`;
|
||||
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${s.serviceId}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`;
|
||||
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${s.serviceId}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`;
|
||||
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(s.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`;
|
||||
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(s.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`;
|
||||
html += '</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
configContainer.innerHTML = html;
|
||||
} catch (e) {
|
||||
configContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
configContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
container.innerHTML = html;
|
||||
lastUpdateSpan.textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--bad-fg);">❌ Failed to load stats: ${e.message}</div>`;
|
||||
container.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--bad-fg);">❌ Failed to load stats: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +104,21 @@
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('auth') === 'required') {
|
||||
const returnUrl = urlParams.get('return');
|
||||
if (returnUrl && returnUrl.includes(SITE.tld)) {
|
||||
safeSessionSet('totp_redirect', returnUrl);
|
||||
if (returnUrl) {
|
||||
// Validate redirect URL: must be same-origin or hostname must end with our TLD
|
||||
// (prevents open redirect via includes() bypass like evil.com?q=.sami)
|
||||
try {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
const hostname = parsed.hostname;
|
||||
const isSameOrigin = parsed.origin === window.location.origin;
|
||||
const tldSuffix = SITE.tld.startsWith('.') ? SITE.tld : '.' + SITE.tld;
|
||||
const isOurTld = hostname.endsWith(tldSuffix) || hostname === tldSuffix.substring(1);
|
||||
if (isSameOrigin || isOurTld) {
|
||||
safeSessionSet('totp_redirect', returnUrl);
|
||||
}
|
||||
} catch (_) {
|
||||
// Invalid URL — reject redirect
|
||||
}
|
||||
}
|
||||
// Clean URL
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
@@ -74,13 +74,13 @@
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
||||
for (const u of updates) {
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${u.containerName}</td>`;
|
||||
html += `<td style="padding: 8px; color: var(--muted);">${u.imageName}</td>`;
|
||||
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${u.currentDigest}</code></td>`;
|
||||
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${u.latestDigest}</code></td>`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.containerName)}</td>`;
|
||||
html += `<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</td>`;
|
||||
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.currentDigest)}</code></td>`;
|
||||
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.latestDigest)}</code></td>`;
|
||||
html += `<td style="padding: 8px; text-align: right;">`;
|
||||
html += `<button class="update-now-btn" data-id="${u.containerId}" data-name="${u.containerName}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`;
|
||||
html += `<button class="rollback-btn" data-id="${u.containerId}" data-name="${u.containerName}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`;
|
||||
html += `<button class="update-now-btn" data-id="${escapeHtml(u.containerId)}" data-name="${escapeHtml(u.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`;
|
||||
html += `<button class="rollback-btn" data-id="${escapeHtml(u.containerId)}" data-name="${escapeHtml(u.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`;
|
||||
html += '</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
@@ -143,7 +143,7 @@
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
availableContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
availableContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,19 +180,19 @@
|
||||
const dur = h.duration ? (h.duration < 1000 ? h.duration + 'ms' : Math.round(h.duration / 1000) + 's') : '-';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(h.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${h.containerName}</td>`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${h.imageName}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(h.containerName)}</td>`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${escapeHtml(h.imageName)}</td>`;
|
||||
html += `<td style="padding: 6px;">${dur}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓ success' : '✗ failed'}</span></td>`;
|
||||
html += '</tr>';
|
||||
if (!ok && h.error) {
|
||||
html += `<tr><td colspan="5" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">${h.error}</td></tr>`;
|
||||
html += `<tr><td colspan="5" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">${escapeHtml(h.error)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
html += '</table>';
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,17 +212,17 @@
|
||||
for (const c of containers) {
|
||||
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
|
||||
const cid = c.containerId || c.Id;
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${cid}">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${name}</td>`;
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(cid)}">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(name)}</td>`;
|
||||
html += `<td style="padding: 8px;">
|
||||
<select class="auto-schedule" data-id="${cid}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<select class="auto-schedule" data-id="${escapeHtml(cid)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value="">Disabled</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select></td>`;
|
||||
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${cid}" checked /></td>`;
|
||||
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${cid}" data-name="${name}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
|
||||
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}" checked /></td>`;
|
||||
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(cid)}" data-name="${escapeHtml(name)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
@@ -257,7 +257,7 @@
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
autoContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
autoContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
weatherWidget.temp.textContent = `${weather.temp}${tempSuffix}`;
|
||||
weatherWidget.condition.textContent = weather.condition;
|
||||
weatherWidget.wind.textContent = `Wind: ${weather.windSpeed} ${windLabel} ${weather.windDir}`;
|
||||
weatherWidget.icon.innerHTML = `<span class="weather-emoji">${weather.icon}</span>`;
|
||||
weatherWidget.icon.innerHTML = `<span class="weather-emoji">${escapeHtml(weather.icon)}</span>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Weather update error:', error);
|
||||
|
||||
Reference in New Issue
Block a user