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
+59 -11
View File
@@ -19,13 +19,59 @@ import (
var staticFiles embed.FS
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
// Only accept WebSocket upgrades whose Origin matches the Host header.
// This blocks CSWSH: a third-party page can't open the live event
// firehose just because the user has this UI in another tab.
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
return u.Host == r.Host
},
}
// securityHeaders sets sensible defaults on every response. The CSP allows
// the external CDNs currently referenced from index.html (fonts, uPlot).
// Remove 'unsafe-inline' from script-src once the theme-init snippet in
// index.html is moved to a file.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'",
"style-src 'self' https://fonts.googleapis.com https://cdn.jsdelivr.net 'unsafe-inline'",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data:",
"connect-src 'self' ws: wss:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
}, "; "))
next.ServeHTTP(w, r)
})
}
func main() {
addr := envDefault("AGENTMON_UI_ADDR", ":8082")
queryAPIBase := envDefault("AGENTMON_QUERY_BASE", "http://query-api")
queryURL, err := url.Parse(queryAPIBase)
if err != nil {
log.Fatalf("invalid AGENTMON_QUERY_BASE %q: %v", queryAPIBase, err)
}
if queryURL.Host == "" {
log.Fatalf("AGENTMON_QUERY_BASE %q has no host", queryAPIBase)
}
mux := http.NewServeMux()
// Health check
@@ -35,7 +81,6 @@ func main() {
})
// API proxy to query-api
queryURL, _ := url.Parse(queryAPIBase)
proxy := httputil.NewSingleHostReverseProxy(queryURL)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
@@ -45,20 +90,23 @@ func main() {
// WebSocket proxy to query-api
mux.HandleFunc("/api/v1/ws", func(w http.ResponseWriter, r *http.Request) {
queryWSURL := "ws://" + queryURL.Host + "/v1/ws"
conn, _, err := websocket.DefaultDialer.Dial(queryWSURL, nil)
if err != nil {
http.Error(w, "failed to connect to upstream WebSocket", http.StatusBadGateway)
return
}
defer conn.Close()
// Upgrade first so CheckOrigin runs before we touch the upstream.
// On rejection, Upgrade has already written the response; just bail.
uiConn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer uiConn.Close()
queryWSURL := "ws://" + queryURL.Host + "/v1/ws"
conn, _, err := websocket.DefaultDialer.Dial(queryWSURL, nil)
if err != nil {
_ = uiConn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "upstream unavailable"))
return
}
defer conn.Close()
var uiMu, upstreamMu sync.Mutex
// upstream → UI
@@ -133,7 +181,7 @@ func main() {
})
log.Printf("web-ui listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
log.Fatal(http.ListenAndServe(addr, securityHeaders(mux)))
}
func envDefault(key, def string) string {