fix(web-ui): security hardening, SPA nav, and modularization
Ship the in-progress ES-module refactor of the web-ui (new static/modules/ layout, Usage/Settings pages, uplot-based dashboard) alongside a round of security and UX fixes: - main.go: add CSP + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy middleware on every response; WS CheckOrigin now requires Origin host to match Host (blocks cross-site WebSocket hijacking); upgrade client before dialing upstream so origin check runs first; fatal on unparseable AGENTMON_QUERY_BASE. - app.js: delegated click handler intercepts same-origin <a> clicks for SPA navigation (prev. every nav link caused a full page reload, dropping WS + in-memory state); delegated .copy-btn[data-copy] handler replaces inline onclick=; removed window.navigate / window.copyToClipboard globals and the duplicated handleGlobalSearch. - modules/nav-signal.js: per-route AbortController so in-flight fetches are cancelled when the user navigates away, preventing stale toasts and wasted renders. - modules/api.js: honours the nav signal by default; AbortError is silent. - modules/router.js: resets the nav controller on every route; dropped the fixed 80ms transition delay; breadcrumbs no longer emit inline onclick= (delegated handler picks them up). - modules/utils.js: renderCopyButton emits data-copy=\"...\" instead of nesting a JS string inside an HTML attribute — fixes an XSS where values containing ' broke out via ' decoding. Verified: go build clean; `node --check` clean on all modified modules; manual curl probes confirm security headers present on every response and WS upgrade returns 403 for cross-origin/missing Origin while 101 for same-origin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
// ── router.js — SPA routing, navigation, breadcrumbs ─────
|
||||
// Circular imports with page modules are safe: all cross-module
|
||||
// accesses happen inside function bodies, never at module init time.
|
||||
|
||||
import { escapeHTML } from './utils.js';
|
||||
import { agentsState } from './state.js';
|
||||
import { resetNavController } from './nav-signal.js';
|
||||
|
||||
import { renderDashboard, cleanup as cleanupDashboard } from './pages/dashboard.js';
|
||||
import { renderSessions, cleanup as cleanupSessions } from './pages/sessions.js';
|
||||
import { renderSession, cleanup as cleanupSessionDetail } from './pages/session-detail.js';
|
||||
import { renderRun, cleanup as cleanupRunDetail } from './pages/run-detail.js';
|
||||
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
|
||||
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
|
||||
import { renderSettings } from './pages/settings.js';
|
||||
import { renderUsage } from './pages/usage.js';
|
||||
|
||||
// Exported so all page modules can write into it without querying the DOM each time
|
||||
export const app = document.getElementById('app');
|
||||
|
||||
let currentRouteToken = 0;
|
||||
|
||||
export function isRouteCurrent(token) {
|
||||
return token === currentRouteToken;
|
||||
}
|
||||
|
||||
export function cleanupLiveViews() {
|
||||
cleanupInfra();
|
||||
cleanupAgents();
|
||||
cleanupSessions();
|
||||
cleanupSessionDetail();
|
||||
cleanupRunDetail();
|
||||
cleanupDashboard();
|
||||
}
|
||||
|
||||
export function route() {
|
||||
const routeToken = ++currentRouteToken;
|
||||
resetNavController();
|
||||
cleanupLiveViews();
|
||||
renderBreadcrumbs();
|
||||
|
||||
app.classList.add('transitioning');
|
||||
const path = window.location.pathname;
|
||||
|
||||
const safeRender = (fn) => {
|
||||
if (!isRouteCurrent(routeToken)) return;
|
||||
Promise.resolve().then(() => {
|
||||
if (!isRouteCurrent(routeToken)) return null;
|
||||
return fn(routeToken);
|
||||
}).catch(err => {
|
||||
if (!isRouteCurrent(routeToken)) return;
|
||||
if (err && err.name === 'AbortError') return; // route changed mid-fetch
|
||||
console.error('Render error:', err);
|
||||
app.innerHTML = `
|
||||
<div class="error-boundary">
|
||||
<h2>Something went wrong</h2>
|
||||
<p>An error occurred while rendering this page.</p>
|
||||
<pre class="error-boundary-detail">${escapeHTML(err.message)}</pre>
|
||||
<a href="/" class="back-link">Back to Dashboard</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
};
|
||||
|
||||
if (path === '/') {
|
||||
safeRender((token) => renderDashboard(token));
|
||||
} else if (path === '/sessions') {
|
||||
safeRender((token) => renderSessions(token));
|
||||
} else if (path.startsWith('/agents/')) {
|
||||
const agentKey = decodeURIComponent(path.split('/agents/')[1]);
|
||||
safeRender((token) => renderAgents(agentKey, token));
|
||||
} else if (path.startsWith('/agents')) {
|
||||
safeRender((token) => renderAgents(undefined, token));
|
||||
} else if (path.startsWith('/infrastructure')) {
|
||||
safeRender((token) => renderInfrastructure(token));
|
||||
} else if (path.startsWith('/sessions/')) {
|
||||
safeRender((token) => renderSession(path.split('/sessions/')[1], token));
|
||||
} else if (path.startsWith('/runs/')) {
|
||||
safeRender((token) => renderRun(path.split('/runs/')[1], token));
|
||||
} else if (path === '/settings') {
|
||||
safeRender((token) => renderSettings(token));
|
||||
} else if (path === '/usage') {
|
||||
safeRender((token) => renderUsage(token));
|
||||
} else {
|
||||
app.innerHTML = '<div class="not-found"><h2>Page not found</h2><p>The page you\'re looking for doesn\'t exist.</p><a href="/" class="back-link">Go to Dashboard</a></div>';
|
||||
}
|
||||
updateActiveNav();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (isRouteCurrent(routeToken)) app.classList.remove('transitioning');
|
||||
});
|
||||
}
|
||||
|
||||
export function renderBreadcrumbs() {
|
||||
const el = document.getElementById('breadcrumbs');
|
||||
if (!el) return;
|
||||
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const items = [{ label: 'Dashboard', path: '/' }];
|
||||
let currentPath = '';
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
currentPath += '/' + part;
|
||||
let label = part.charAt(0).toUpperCase() + part.slice(1);
|
||||
|
||||
// Special labels for IDs
|
||||
if (part.length > 20 || /^[a-f0-9-]{32,}$/.test(part)) {
|
||||
label = part.substring(0, 8) + '…';
|
||||
}
|
||||
|
||||
// For /agents/:key, show the human-readable agent name
|
||||
if (parts[0] === 'agents' && i === 1) {
|
||||
const agentKey = decodeURIComponent(part);
|
||||
const agent = agentsState && agentsState.agents && agentsState.agents[agentKey];
|
||||
label = (agent && agent.name) ? agent.name : agentKey;
|
||||
}
|
||||
|
||||
if (i === parts.length - 1) {
|
||||
items.push({ label, current: true });
|
||||
} else {
|
||||
items.push({ label, path: currentPath });
|
||||
}
|
||||
});
|
||||
|
||||
// No inline onclick — the delegated <a> click handler in app.js picks these up.
|
||||
el.innerHTML = items.map(item => {
|
||||
if (item.current) {
|
||||
return `<span class="current">${escapeHTML(item.label)}</span>`;
|
||||
}
|
||||
return `<a href="${escapeHTML(item.path)}">${escapeHTML(item.label)}</a><span class="sep">/</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function navigate(path) {
|
||||
history.pushState(null, '', path);
|
||||
route();
|
||||
}
|
||||
|
||||
export function updateActiveNav() {
|
||||
const path = window.location.pathname;
|
||||
document.querySelectorAll('header nav a').forEach(a => {
|
||||
const href = a.getAttribute('href');
|
||||
const isActive = href === '/'
|
||||
? path === '/'
|
||||
: path.startsWith(href);
|
||||
a.classList.toggle('active', isActive);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', route);
|
||||
Reference in New Issue
Block a user