Files
dashcaddy/status/js/core/credentials.js
Sami 52577b11ed 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>
2026-03-07 01:29:04 -08:00

430 lines
18 KiB
JavaScript

// ========== CREDENTIAL MANAGEMENT ==========
(function () {
// Inject the token-management modal HTML
injectModal('token-management-modal', `
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>🔑 DNS Credentials</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates.
</p>
<!-- DNS1 Credentials -->
<div class="token-section">
<h4 class="token-section-title">DNS1 (Windows)</h4>
<div class="token-grid">
<div class="token-field">
<label for="dns1-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="dns1-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns1-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns1-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="dns1-admin-username">\u{1F527} Admin:</label>
<input type="text" id="dns1-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns1-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns1-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="dns1-token-status"></div>
</div>
<!-- DNS2 Credentials -->
<div class="token-section">
<h4 class="token-section-title">DNS2 (Linux)</h4>
<div class="token-grid">
<div class="token-field">
<label for="dns2-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="dns2-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns2-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns2-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="dns2-admin-username">\u{1F527} Admin:</label>
<input type="text" id="dns2-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns2-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns2-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="dns2-token-status"></div>
</div>
<!-- DNS3 Credentials -->
<div class="token-section">
<h4 class="token-section-title">DNS3 (AlmaLinux)</h4>
<div class="token-grid">
<div class="token-field">
<label for="dns3-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="dns3-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns3-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns3-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="dns3-admin-username">\u{1F527} Admin:</label>
<input type="text" id="dns3-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns3-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns3-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="dns3-token-status"></div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
<button id="token-cancel">Cancel</button>
<button id="token-save" class="btn-accent">Save</button>
</div>
</div>
</div>
`);
// 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() {
// 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();
// 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++) {
// 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);
}
// Prepend IV to ciphertext so we can decrypt later
return ivHex + ':' + btoa(result);
}
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++) {
const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length);
result += String.fromCharCode(charCode);
}
return result;
} catch (e) {
return '';
}
}
// Credential storage functions
function getCredential(dnsId, tokenType, credType) {
const encrypted = safeGet(`${dnsId}-${tokenType}-${credType}-enc`);
return credentialDecrypt(encrypted, ENCRYPTION_KEY);
}
function setCredential(dnsId, tokenType, credType, value) {
const key = `${dnsId}-${tokenType}-${credType}-enc`;
if (value) {
safeSet(key, credentialEncrypt(value, ENCRYPTION_KEY));
} else {
safeRemove(key);
}
}
function getToken(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'token');
}
function getUsername(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'username');
}
function setToken(dnsId, tokenType, token) {
setCredential(dnsId, tokenType, 'token', token);
}
function setUsername(dnsId, tokenType, username) {
setCredential(dnsId, tokenType, 'username', username);
}
function getAllCredentials() {
return {
dns1: {
readonly: { username: getUsername('dns1', 'readonly'), token: getToken('dns1', 'readonly') },
admin: { username: getUsername('dns1', 'admin'), token: getToken('dns1', 'admin') }
},
dns2: {
readonly: { username: getUsername('dns2', 'readonly'), token: getToken('dns2', 'readonly') },
admin: { username: getUsername('dns2', 'admin'), token: getToken('dns2', 'admin') }
},
dns3: {
readonly: { username: getUsername('dns3', 'readonly'), token: getToken('dns3', 'readonly') },
admin: { username: getUsername('dns3', 'admin'), token: getToken('dns3', 'admin') }
}
};
}
function clearAllCredentials() {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
['readonly', 'admin'].forEach(tokenType => {
['token', 'username'].forEach(credType => {
safeRemove(`${dnsId}-${tokenType}-${credType}-enc`);
});
});
safeRemove(`${dnsId}-token-enc`);
safeRemove(`${dnsId}-username-enc`);
});
}
function getStoredCredentials(dnsId) {
const readonlyToken = getToken(dnsId, 'readonly');
const readonlyUsername = getUsername(dnsId, 'readonly');
const adminToken = getToken(dnsId, 'admin');
const adminUsername = getUsername(dnsId, 'admin');
const oldToken = credentialDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY);
const oldUsername = credentialDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY);
return {
username: adminUsername || readonlyUsername || oldUsername,
token: adminToken || readonlyToken || oldToken,
readonlyToken: readonlyToken || oldToken,
readonlyUsername: readonlyUsername || oldUsername,
adminToken: adminToken || oldToken,
adminUsername: adminUsername || oldUsername
};
}
// Token Management Modal handlers
document.getElementById('manage-tokens')?.addEventListener('click', () => {
const modal = document.getElementById('token-management-modal');
const creds = getAllCredentials();
// Populate fields with existing credentials
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = creds[dnsId].readonly.username;
document.getElementById(`${dnsId}-readonly-token`).value = creds[dnsId].readonly.token;
document.getElementById(`${dnsId}-admin-username`).value = creds[dnsId].admin.username;
document.getElementById(`${dnsId}-admin-token`).value = creds[dnsId].admin.token;
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
modal.classList.add('show');
});
// Toggle password visibility
document.querySelectorAll('.token-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const input = document.getElementById(targetId);
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '\u{1F648}';
} else {
input.type = 'password';
btn.textContent = '\u{1F441}';
}
});
});
document.getElementById('token-save')?.addEventListener('click', async () => {
// Save all credentials to localStorage
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
// Build per-server credentials payload for backend sync
const servers = {};
let hasAnyCreds = false;
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
const entry = {};
const roUser = document.getElementById(`${dnsId}-readonly-username`).value.trim();
const roPass = document.getElementById(`${dnsId}-readonly-token`).value.trim();
const adminUser = document.getElementById(`${dnsId}-admin-username`).value.trim();
const adminPass = document.getElementById(`${dnsId}-admin-token`).value.trim();
if (roUser && roPass) {
entry.readonly = { username: roUser, password: roPass };
hasAnyCreds = true;
}
if (adminUser && adminPass) {
entry.admin = { username: adminUser, password: adminPass };
hasAnyCreds = true;
}
if (Object.keys(entry).length > 0) {
servers[dnsId] = entry;
}
});
if (hasAnyCreds) {
// Show syncing status
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = 'Verifying...';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
try {
const res = await secureFetch('/api/v1/dns/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const data = await res.json();
if (data.results) {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results[dnsId];
if (result?.success) {
statusEl.textContent = '\u2713 Verified & saved';
statusEl.className = 'token-status success';
} else if (result?.partial) {
statusEl.textContent = '\u2713 ' + result.partial;
statusEl.className = 'token-status success';
} else {
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
statusEl.className = 'token-status error';
}
});
} else if (data.success) {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
}
});
} else {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
}
} catch (e) {
console.error('Failed to sync DNS credentials to backend:', e);
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
}
} else {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
}
// Auto-close after delay if all succeeded
setTimeout(() => {
const allGood = ['dns1', 'dns2', 'dns3'].every(dnsId => {
const status = document.getElementById(`${dnsId}-token-status`).textContent;
return !status || status.includes('\u2713');
});
if (allGood) closeModal('token-management-modal');
}, 1500);
});
document.getElementById('token-cancel')?.addEventListener('click', () => {
closeModal('token-management-modal');
});
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
clearAllCredentials();
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
try {
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
} catch (_) {}
}
});
// Close modal on backdrop click
document.getElementById('token-management-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'token-management-modal') {
e.target.classList.remove('show');
}
});
// Window exports
window.getToken = getToken;
window.getUsername = getUsername;
window.setToken = setToken;
window.setUsername = setUsername;
window.getAllCredentials = getAllCredentials;
window.getCredential = getCredential;
window.setCredential = setCredential;
window.getEncryptionKey = getEncryptionKey;
})();