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
+76
View File
@@ -0,0 +1,76 @@
// ── ws.js — WebSocket connection and subscription ────────
let ws = null;
let wsStatus = 'disconnected';
let wsReconnectTimeout = null;
let wsReconnectDelay = 1000;
const wsCallbacks = new Set();
export function getWsURL() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return protocol + '//' + window.location.host + '/api/v1/ws';
}
export function connectWS() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
try {
ws = new WebSocket(getWsURL());
ws.onopen = () => {
console.log('WebSocket connected');
wsStatus = 'connected';
wsReconnectDelay = 1000;
updateWSIndicator();
wsCallbacks.forEach(cb => cb({ type: 'connected' }));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
wsCallbacks.forEach(cb => cb({ type: 'message', data }));
} catch (e) {
console.error('Failed to parse WS message:', e);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
wsStatus = 'reconnecting';
updateWSIndicator();
wsCallbacks.forEach(cb => cb({ type: 'disconnected' }));
wsReconnectTimeout = setTimeout(connectWS, wsReconnectDelay);
wsReconnectDelay = Math.min(wsReconnectDelay * 1.5, 30000);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
} catch (e) {
console.error('Failed to connect WebSocket:', e);
wsReconnectTimeout = setTimeout(connectWS, wsReconnectDelay);
wsReconnectDelay = Math.min(wsReconnectDelay * 1.5, 30000);
}
}
export function subscribeWS(callback) {
wsCallbacks.add(callback);
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectWS();
}
return () => wsCallbacks.delete(callback);
}
export function updateWSIndicator() {
const dot = document.getElementById('ws-dot');
if (!dot) return;
dot.className = 'ws-dot ' + wsStatus;
const labels = {
connected: 'Live — WebSocket connected',
reconnecting: 'Reconnecting…',
disconnected: 'Disconnected',
};
dot.title = labels[wsStatus] || 'Unknown';
}