Files
dashcaddy/status/js/totp-auth.js
T
Hermes 94ee76e9de [grade=A urn:ump:josd2g7kqv476vux6euigmygdkijya2s4tsbyivtobw5ftzuf6ba] fix(DC-170): revive 19 CSP-blocked inline handlers across the dashboard — audit P0-3
The meta CSP (no unsafe-inline/unsafe-hashes) blocks ALL inline event
handlers; 19 controls were silently dead in every modern browser.

Revived:
- Disk Safety: toolbar button, injected modal Close/Save/Clean Up, and
  the 5 settings sliders (oninput -> input listeners; value labels
  never updated)
- AI chat FAB click; hover scale moved to a #ai-chat-fab:hover rule
- TOTP/login logo broken-image fallbacks: capture-phase error
  delegation + WeakSet + complete/naturalWidth race guard in
  totp-auth.js; updateTotpLogo never resurrects a broken logo
  (6 onerror attrs stripped from index.html + auth-gate.js templates)
- favicon-preview data-URI fallback (same race guard)
- app-selector folder-chip remove buttons (data-remove-folder)
- premium upsells in backup-restore + workflows (data-upgrade-msg) —
  their old onclick also called scrollToSection('license'), a function
  that has never existed anywhere in the codebase; now opens the real
  license UI via window.openLicenseModal (license.js)
- health-check Edit/Delete dispatch the same CustomEvents via
  data-health-action/data-service-id
- smart-arr Test button via data-smart-test

Verified: zero inline handler attributes remain across index.html +
hand-written js (grep); node --check clean x12; status suite 68/68;
dist rebuilt (SW dashcaddy-shell-36d9aa3364, CSP hash unchanged).
r1 grade C (missed sliders + logo onerror) -> r2 grade A:
urn:ump:mptri3qhywc2e47f2ba4qjafhwm3olzvrvt5vphxoyhea3vgt32a /
urn:ump:josd2g7kqv476vux6euigmygdkijya2s4tsbyivtobw5ftzuf6ba
2026-10-28 00:48:32 -07:00

206 lines
8.5 KiB
JavaScript

// ===== TOTP AUTHENTICATION GATE =====
(function() {
// Audit P0-3 (2026-10-27): broken-logo hiding used inline onerror, which
// the meta CSP blocks (dead handler). Track failures via capture-phase
// delegation (catches injected <img>s at any time) + a load-state sweep,
// and never resurrect a broken logo in updateTotpLogo().
const brokenLogos = new WeakSet();
document.addEventListener('error', (e) => {
const t = e.target;
if (t && t.tagName === 'IMG' && t.classList.contains('totp-logo')) {
brokenLogos.add(t);
t.style.display = 'none';
}
}, true);
document.querySelectorAll('.totp-logo').forEach((img) => {
if (img.complete && img.naturalWidth === 0) { brokenLogos.add(img); img.style.display = 'none'; }
});
function updateTotpLogo() {
const card = document.querySelector('.totp-card');
if (!card) return;
const bg = getComputedStyle(card).backgroundColor;
const m = bg.match(/\d+/g);
if (!m) return;
const lum = (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255;
const dark = card.querySelector('.totp-logo-dark');
const light = card.querySelector('.totp-logo-light');
if (dark) dark.style.display = (lum > 0.5 || brokenLogos.has(dark)) ? 'none' : '';
if (light) light.style.display = (lum <= 0.5 || brokenLogos.has(light)) ? 'none' : '';
}
function showTotpOverlay() {
const overlay = document.getElementById('totp-overlay');
if (overlay) {
overlay.classList.add('show');
setTimeout(updateTotpLogo, 50);
const firstInput = overlay.querySelector('.totp-digits input');
if (firstInput) setTimeout(() => firstInput.focus(), 100);
}
// Refresh the "Lost access?" recovery link visibility based on server state.
// Hides itself if TOTP is healthy; shows if unreadable/corrupt. The user
// can still click it even when healthy — but the panel will explain there's
// no recovery needed. Cheaper than gating it.
if (typeof window._refreshRecoveryLink === 'function') {
window._refreshRecoveryLink();
}
}
function hideTotpOverlay() {
const overlay = document.getElementById('totp-overlay');
if (overlay) overlay.classList.remove('show');
}
function buildSsoHandoffTarget(redirect, token) {
const parsed = new URL(redirect, window.location.origin);
if (parsed.origin === window.location.origin) return parsed.toString();
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
if (parsed.protocol !== 'https:' || !isPrivateHost) return null;
if (!token) return parsed.toString();
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
parsed.pathname = '/dashcaddy-sso';
parsed.search = '';
parsed.hash = '';
parsed.searchParams.set('token', token);
parsed.searchParams.set('return', returnPath);
return parsed.toString();
}
// Setup digit input UX
const container = document.getElementById('totp-digits');
if (container) {
const inputs = container.querySelectorAll('input');
inputs.forEach((input, idx) => {
input.addEventListener('input', (e) => {
const val = e.target.value.replace(/\D/g, '');
e.target.value = val.slice(0, 1);
if (val && idx < inputs.length - 1) inputs[idx + 1].focus();
const code = Array.from(inputs).map(i => i.value).join('');
if (code.length === 6) submitTotpCode(code);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && idx > 0) {
inputs[idx - 1].focus();
inputs[idx - 1].value = '';
}
});
input.addEventListener('paste', (e) => {
e.preventDefault();
const pasted = (e.clipboardData.getData('text') || '').replace(/\D/g, '');
if (pasted.length >= 6) {
inputs.forEach((inp, i) => { inp.value = pasted[i] || ''; });
inputs[5].focus();
submitTotpCode(pasted.slice(0, 6));
}
});
});
}
async function submitTotpCode(code) {
const errorEl = document.getElementById('totp-error');
errorEl.textContent = 'Verifying...';
errorEl.className = 'totp-error verifying';
const redirect = safeSessionGet('totp_redirect');
let serviceId = null;
if (redirect) {
try {
const parsed = new URL(redirect, window.location.origin);
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const candidate = parsed.hostname.slice(0, -suffix.length);
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
}
try {
const res = await secureFetch('/api/v1/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, serviceId })
});
const data = await res.json();
if (data.success) {
errorEl.textContent = '';
// Update cached CSRF token from TOTP response (server rotates it on login)
if (data.csrfToken) {
csrfToken = data.csrfToken;
}
hideTotpOverlay();
// Check if redirected here from another service
if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
// .sami is an unregistered TLD, so browsers silently drop the
// Domain=.sami session cookie on any OTHER *.sami subdomain (they
// treat "sami" as the effective public suffix, same protection
// that blocks a Domain=.com supercookie). The target service can't
// see our session cookie no matter how it's built, so instead we
// hand it a one-time token in the URL; its login page exchanges
// that for its own host-only cookie via /auth/sso-exchange.
const target = buildSsoHandoffTarget(redirect, data.ssoToken);
if (!target) return;
window.location.href = target;
return;
}
// Initialize dashboard
if (typeof window.initializeDashboard === 'function') {
window.initializeDashboard();
}
// Resume live updates after mid-session re-auth. The auth-loss
// handlers latched polling + SSE off when the session expired
// (grid.js sets _dcAuthLost, live-events.js latches the stream
// closed); a fresh login must clear both and reconnect, or the
// dashboard stays frozen on stale data until a manual F5.
window._dcAuthLost = false;
if (typeof window._sseResume === 'function') window._sseResume();
else if (typeof window._sseReconnect === 'function') window._sseReconnect();
if (typeof window.refreshAll === 'function') window.refreshAll();
} else {
errorEl.textContent = data.error || 'Invalid code';
errorEl.className = 'totp-error';
const inputs = document.querySelectorAll('#totp-digits input');
inputs.forEach(i => { i.value = ''; });
inputs[0]?.focus();
}
} catch (e) {
errorEl.textContent = 'Connection error';
errorEl.className = 'totp-error';
}
}
// Handle ?auth=required redirect from Caddy SSO.
// DC-049: if the auth-gate module will handle this (multi-provider
// installs), skip our TOTP-only branch entirely. Single-provider
// TOTP-only installs continue to use this path because auth-gate.js
// delegates back to window._showTotpOverlay().
const __dc_049_skip = !!(window.__dc_049_handled);
if (!__dc_049_skip && urlParams.get('auth') === 'required') {
const returnUrl = urlParams.get('return');
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);
}
window._showTotpOverlay = showTotpOverlay;
})();