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:
+59
-11
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user