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 &#39; 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:
William Valentin
2026-04-23 15:36:12 -07:00
parent 41b7165800
commit 184aa5e6cb
20 changed files with 5129 additions and 4216 deletions
+67
View File
@@ -0,0 +1,67 @@
// ── api.js — fetch wrapper, toast, clipboard ─────────────
import { getNavSignal } from './nav-signal.js';
export async function api(path, opts) {
opts = opts || {};
const signal = opts.signal !== undefined ? opts.signal : getNavSignal();
let resp;
try {
resp = await fetch('/api' + path, { signal });
} catch (err) {
// Route changed or caller aborted — swallow silently, no toast.
if (err && err.name === 'AbortError') throw err;
showToast('Network error: ' + (err && err.message ? err.message : 'fetch failed'), 'error');
throw err;
}
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const msg = body.error || 'Request failed (' + resp.status + ')';
showToast(msg, 'error');
throw new Error(msg);
}
return resp.json();
}
export function showToast(message, type) {
// Limit to 3 stacked toasts
const existing = document.querySelectorAll('.toast');
if (existing.length >= 3) existing[0].remove();
const toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'info');
toast.textContent = message;
document.body.appendChild(toast);
// Stack: offset each toast by its position
const stackToasts = () => {
document.querySelectorAll('.toast').forEach((t, i) => {
t.style.bottom = (2 + i * 3.5) + 'rem';
});
};
stackToasts();
requestAnimationFrame(() => toast.classList.add('visible'));
setTimeout(() => {
toast.classList.remove('visible');
setTimeout(() => {
toast.remove();
stackToasts();
}, 300);
}, 4000);
}
export function copyToClipboard(text, el) {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
showToast('Copied to clipboard', 'success');
if (el) {
const originalText = el.textContent;
el.textContent = 'Copied!';
setTimeout(() => { el.textContent = originalText; }, 1500);
}
}).catch(err => {
console.error('Failed to copy:', err);
showToast('Copy failed', 'error');
});
}