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,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';
|
||||
}
|
||||
Reference in New Issue
Block a user