From 3102702f35655942bde311bcd9490ff40a4559ea Mon Sep 17 00:00:00 2001 From: mempko Date: Mon, 16 Mar 2026 19:39:26 -0700 Subject: [PATCH 1/9] fix: Linux desktop app freeze caused by restrictive CSP The Tauri CSP only allowed 'self' for script-src/default-src. After redirecting from tauri://localhost to the Go backend at http://127.0.0.1, the CSP blocked resources from the backend origin, causing the WebKitGTK webview to freeze on Linux. Add http://127.0.0.1:* to all CSP directives and include 'unsafe-inline'/'unsafe-eval' for scripts so the SPA can load and run after navigation to the backend. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index d6de4651..1c8eccd7 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none';" + "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self' http://127.0.0.1:* 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:; style-src 'self' http://127.0.0.1:* 'unsafe-inline'; font-src 'self' http://127.0.0.1:* data:; object-src 'none'; base-uri 'none';" } }, "bundle": { From fc7a3d4fd7a138183d85702185ae04b0dc09de63 Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 08:29:46 -0700 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20tighten=20CSP=20=E2=80=94=20drop=20u?= =?UTF-8?q?nsafe-eval/unsafe-inline=20for=20scripts,=20restore=20frame-anc?= =?UTF-8?q?estors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 'unsafe-inline' and 'unsafe-eval' from script-src (not needed by Svelte/Vite production builds) and restore the frame-ancestors 'none' directive that was dropped in the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1c8eccd7..594e5566 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self' http://127.0.0.1:* 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:; style-src 'self' http://127.0.0.1:* 'unsafe-inline'; font-src 'self' http://127.0.0.1:* data:; object-src 'none'; base-uri 'none';" + "csp": "default-src 'self' http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:; style-src 'self' http://127.0.0.1:* 'unsafe-inline'; font-src 'self' http://127.0.0.1:* data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none';" } }, "bundle": { From e287e0b866815b027373ffb3d87db4f8e477ead6 Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 11:40:06 -0700 Subject: [PATCH 3/9] fix: add runtime CSP middleware to pin port, tighten Tauri script-src The Tauri compile-time CSP must use http://127.0.0.1:* in default-src because the Go server port is unknown at build time. To prevent this wildcard from allowing script execution from arbitrary local ports, the Tauri CSP now sets an explicit `script-src 'self'` (no wildcard). A new Go-side cspMiddleware sets a port-pinned Content-Security-Policy header on all non-API responses. Since CSPs are additive (most restrictive wins), the runtime policy intersects with Tauri's broad default-src to narrow connect-src/default-src to the exact port. - Tauri CSP: explicit script-src 'self' (no wildcard port for scripts) - Tauri CSP: wildcard only in default-src and connect-src (needed for cross-origin page load and API/SSE access) - Go server: cspMiddleware as outermost handler wrapper pins exact port - Tests: verify CSP present on SPA routes, absent on API routes Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/tauri.conf.json | 2 +- internal/server/middleware_test.go | 81 ++++++++++++++++++++++++++++++ internal/server/server.go | 39 ++++++++++++-- 3 files changed, 116 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 594e5566..98be344e 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": "default-src 'self' http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:; style-src 'self' http://127.0.0.1:* 'unsafe-inline'; font-src 'self' http://127.0.0.1:* data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none';" + "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self'; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'" } }, "bundle": { diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index 456ff636..cae60f5e 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -156,6 +156,87 @@ func TestMiddlewareTimeout(t *testing.T) { } } +func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + host string + port int + wantCSP bool + wantParts []string + }{ + { + name: "SPA_root_gets_CSP", + path: "/", + host: "127.0.0.1", + port: 8081, + wantCSP: true, + wantParts: []string{ + "http://127.0.0.1:8081", + "ws://127.0.0.1:8081", + "frame-ancestors 'none'", + }, + }, + { + name: "SPA_subpath_gets_CSP", + path: "/sessions/abc", + host: "127.0.0.1", + port: 9090, + wantCSP: true, + wantParts: []string{ + "http://127.0.0.1:9090", + "ws://127.0.0.1:9090", + }, + }, + { + name: "API_route_no_CSP", + path: "/api/v1/sessions", + host: "127.0.0.1", + port: 8081, + wantCSP: false, + }, + { + name: "API_subpath_no_CSP", + path: "/api/v1/stats", + host: "127.0.0.1", + port: 8081, + wantCSP: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := cspMiddleware(tt.host, tt.port, inner) + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + csp := w.Header().Get("Content-Security-Policy") + if tt.wantCSP { + if csp == "" { + t.Fatal("expected CSP header, got empty") + } + for _, part := range tt.wantParts { + if !strings.Contains(csp, part) { + t.Errorf("CSP missing %q; got %q", part, csp) + } + } + } else { + if csp != "" { + t.Errorf("expected no CSP header on API route, got %q", csp) + } + } + }) + } +} + func TestCORSMiddlewareMergesVaryHeader(t *testing.T) { t.Parallel() diff --git a/internal/server/server.go b/internal/server/server.go index dce1eeb3..bbbe5c12 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -358,11 +358,13 @@ func (s *Server) Handler() http.Handler { if bindAll { bindAllIPs = localInterfaceIPs() } - h := s.authMiddleware( - hostCheckMiddleware( - allowedHosts, bindAll, s.cfg.Port, bindAllIPs, - corsMiddleware( - allowedOrigins, bindAll, s.cfg.Port, bindAllIPs, logMiddleware(s.mux), + h := cspMiddleware(s.cfg.Host, s.cfg.Port, + s.authMiddleware( + hostCheckMiddleware( + allowedHosts, bindAll, s.cfg.Port, bindAllIPs, + corsMiddleware( + allowedOrigins, bindAll, s.cfg.Port, bindAllIPs, logMiddleware(s.mux), + ), ), ), ) @@ -392,6 +394,33 @@ func (s *Server) Handler() http.Handler { return h } +// cspMiddleware sets a Content-Security-Policy header on non-API +// responses. The policy pins the exact host:port origin so that +// even if Tauri's compile-time CSP uses a wildcard port, the +// intersection narrows to the actual runtime port. +func cspMiddleware(host string, port int, next http.Handler) http.Handler { + origin := fmt.Sprintf("http://%s:%d", host, port) + wsOrigin := fmt.Sprintf("ws://%s:%d", host, port) + policy := fmt.Sprintf( + "default-src 'self' %[1]s; "+ + "script-src 'self' %[1]s; "+ + "connect-src 'self' %[1]s %[2]s; "+ + "img-src 'self' %[1]s data:; "+ + "style-src 'self' %[1]s 'unsafe-inline'; "+ + "font-src 'self' %[1]s data:; "+ + "object-src 'none'; "+ + "frame-ancestors 'none'; "+ + "base-uri 'none'", + origin, wsOrigin, + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Security-Policy", policy) + } + next.ServeHTTP(w, r) + }) +} + // buildAllowedHosts returns the set of Host header values that // are legitimate for this server. This defends against DNS // rebinding attacks where an attacker's domain resolves to From e3edffa23097a35df48f4d46879bda051ea9c7f9 Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 11:55:53 -0700 Subject: [PATCH 4/9] fix: handle IPv6, bind-all, and public origins in CSP middleware Address review feedback: cspMiddleware was using fmt.Sprintf to build origins, which produced invalid URIs for IPv6 (http://::1:8081) and wrong origins for bind-all mode (http://0.0.0.0:8081). - Use httpOrigin()/net.JoinHostPort for correct IPv6 bracketing - Mirror buildAllowedOrigins loopback logic for 0.0.0.0/:: hosts - Include publicOrigins with https->wss mapping for proxy/TLS setups - Add test cases for IPv6, bind-all, and public origin scenarios Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/server/middleware_test.go | 53 ++++++++++++++++--- internal/server/server.go | 81 ++++++++++++++++++++++++------ 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index cae60f5e..3770ee0e 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -160,12 +160,13 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { t.Parallel() tests := []struct { - name string - path string - host string - port int - wantCSP bool - wantParts []string + name string + path string + host string + port int + publicOrigins []string + wantCSP bool + wantParts []string }{ { name: "SPA_root_gets_CSP", @@ -204,6 +205,44 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { port: 8081, wantCSP: false, }, + { + name: "IPv6_loopback_brackets", + path: "/", + host: "::1", + port: 8081, + wantCSP: true, + wantParts: []string{ + "http://[::1]:8081", + "ws://[::1]:8081", + // Also includes 127.0.0.1 variant + "http://127.0.0.1:8081", + }, + }, + { + name: "BindAll_includes_loopback", + path: "/", + host: "0.0.0.0", + port: 8080, + wantCSP: true, + wantParts: []string{ + "http://127.0.0.1:8080", + "ws://127.0.0.1:8080", + "http://localhost:8080", + }, + }, + { + name: "PublicOrigin_included", + path: "/", + host: "127.0.0.1", + port: 8081, + publicOrigins: []string{"https://view.example.com"}, + wantCSP: true, + wantParts: []string{ + "http://127.0.0.1:8081", + "https://view.example.com", + "wss://view.example.com", + }, + }, } for _, tt := range tests { @@ -212,7 +251,7 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - handler := cspMiddleware(tt.host, tt.port, inner) + handler := cspMiddleware(tt.host, tt.port, tt.publicOrigins, inner) req := httptest.NewRequest(http.MethodGet, tt.path, nil) w := httptest.NewRecorder() diff --git a/internal/server/server.go b/internal/server/server.go index bbbe5c12..1117beff 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -358,7 +358,7 @@ func (s *Server) Handler() http.Handler { if bindAll { bindAllIPs = localInterfaceIPs() } - h := cspMiddleware(s.cfg.Host, s.cfg.Port, + h := cspMiddleware(s.cfg.Host, s.cfg.Port, s.cfg.PublicOrigins, s.authMiddleware( hostCheckMiddleware( allowedHosts, bindAll, s.cfg.Port, bindAllIPs, @@ -398,21 +398,8 @@ func (s *Server) Handler() http.Handler { // responses. The policy pins the exact host:port origin so that // even if Tauri's compile-time CSP uses a wildcard port, the // intersection narrows to the actual runtime port. -func cspMiddleware(host string, port int, next http.Handler) http.Handler { - origin := fmt.Sprintf("http://%s:%d", host, port) - wsOrigin := fmt.Sprintf("ws://%s:%d", host, port) - policy := fmt.Sprintf( - "default-src 'self' %[1]s; "+ - "script-src 'self' %[1]s; "+ - "connect-src 'self' %[1]s %[2]s; "+ - "img-src 'self' %[1]s data:; "+ - "style-src 'self' %[1]s 'unsafe-inline'; "+ - "font-src 'self' %[1]s data:; "+ - "object-src 'none'; "+ - "frame-ancestors 'none'; "+ - "base-uri 'none'", - origin, wsOrigin, - ) +func cspMiddleware(host string, port int, publicOrigins []string, next http.Handler) http.Handler { + policy := buildCSPPolicy(host, port, publicOrigins) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Security-Policy", policy) @@ -421,6 +408,68 @@ func cspMiddleware(host string, port int, next http.Handler) http.Handler { }) } +// buildCSPPolicy constructs the Content-Security-Policy string. +// It uses the same loopback/bind-all logic as buildAllowedOrigins +// to handle IPv6 bracketing, 0.0.0.0/:: normalization, and +// public origins (proxy/TLS). +func buildCSPPolicy(host string, port int, publicOrigins []string) string { + // Collect HTTP and WS origins using net.JoinHostPort for + // correct IPv6 bracket formatting. + httpSrcs := []string{"'self'"} + wsSrcs := []string{} + + addOrigin := func(h string) { + for _, o := range httpOrigin(h, port) { + httpSrcs = append(httpSrcs, o) + wsSrcs = append(wsSrcs, strings.Replace(o, "http://", "ws://", 1)) + } + } + + addOrigin(host) + // Mirror buildAllowedOrigins: when binding to loopback, + // include the other loopback variant. When binding to all + // interfaces, include all loopback origins. + switch host { + case "127.0.0.1": + addOrigin("localhost") + case "localhost": + addOrigin("127.0.0.1") + case "0.0.0.0", "::": + addOrigin("127.0.0.1") + addOrigin("localhost") + addOrigin("::1") + case "::1": + addOrigin("127.0.0.1") + addOrigin("localhost") + } + + for _, origin := range publicOrigins { + httpSrcs = append(httpSrcs, origin) + wsSrcs = append(wsSrcs, + strings.NewReplacer( + "https://", "wss://", + "http://", "ws://", + ).Replace(origin), + ) + } + + httpList := strings.Join(httpSrcs, " ") + connectList := strings.Join(append(httpSrcs, wsSrcs...), " ") + + return fmt.Sprintf( + "default-src %[1]s; "+ + "script-src %[1]s; "+ + "connect-src %[2]s; "+ + "img-src %[1]s data:; "+ + "style-src %[1]s 'unsafe-inline'; "+ + "font-src %[1]s data:; "+ + "object-src 'none'; "+ + "frame-ancestors 'none'; "+ + "base-uri 'none'", + httpList, connectList, + ) +} + // buildAllowedHosts returns the set of Host header values that // are legitimate for this server. This defends against DNS // rebinding attacks where an attacker's domain resolves to From 2eb4ad6770ca5f59897fbe4d313604c6cad28844 Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 14:58:59 -0700 Subject: [PATCH 5/9] fix: restore http://127.0.0.1:* to all Tauri CSP resource directives The previous commit removed the wildcard from script-src, img-src, style-src, and font-src in the Tauri CSP, causing the UI to freeze. Root cause: 'self' in the Tauri CSP resolves to tauri://localhost (the Tauri origin), NOT the Go server origin. After the webview navigates to http://127.0.0.1:{port}, resources served by the Go server are cross-origin from Tauri's perspective. Without http://127.0.0.1:* in each directive, WebKitGTK blocks scripts, styles, images, and fonts from the Go server. The wildcard port is unavoidable in the Tauri compile-time CSP. The Go server's runtime CSP (script-src 'self', where 'self' = http://127.0.0.1:8081) provides the port-pinned tightening via CSP intersection. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 98be344e..1738440e 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self'; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'" + "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self' http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:; style-src 'self' http://127.0.0.1:* 'unsafe-inline'; font-src 'self' http://127.0.0.1:* data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'" } }, "bundle": { From c96f664f69c5bc645bd75d8ad58898e5926ec9ac Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 17:41:49 -0700 Subject: [PATCH 6/9] fix: include pinned server origin in all CSP directives WebKitGTK in a Tauri webview does not resolve 'self' to the Go server origin after navigating from tauri://localhost. Using only 'self' in script-src/default-src causes the UI to freeze because WebKitGTK blocks all resources from http://127.0.0.1:{port}. The pinned server origin (exact host:port, not a wildcard) is now included in all resource directives. Public origins and LAN IPs remain restricted to connect-src only, so the script execution surface is limited to the server's own origin. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/server/middleware_test.go | 52 +++++++++++++++----- internal/server/server.go | 77 ++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 38 deletions(-) diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index 3770ee0e..d1133416 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -165,17 +165,21 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { host string port int publicOrigins []string + bindAllIPs map[string]bool wantCSP bool wantParts []string + wantAbsent []string // substrings that must NOT appear }{ { - name: "SPA_root_gets_CSP", + name: "SPA_root_gets_CSP_with_pinned_origin", path: "/", host: "127.0.0.1", port: 8081, wantCSP: true, wantParts: []string{ - "http://127.0.0.1:8081", + "script-src 'self' http://127.0.0.1:8081", + "default-src 'self' http://127.0.0.1:8081", + "connect-src 'self' http://127.0.0.1:8081", "ws://127.0.0.1:8081", "frame-ancestors 'none'", }, @@ -212,36 +216,55 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { port: 8081, wantCSP: true, wantParts: []string{ - "http://[::1]:8081", + "script-src 'self' http://[::1]:8081", + "connect-src", "ws://[::1]:8081", - // Also includes 127.0.0.1 variant "http://127.0.0.1:8081", }, }, { - name: "BindAll_includes_loopback", - path: "/", - host: "0.0.0.0", - port: 8080, + name: "BindAll_connect_src_includes_LAN_IPs", + path: "/", + host: "0.0.0.0", + port: 8080, + bindAllIPs: map[string]bool{ + "127.0.0.1": true, + "::1": true, + "192.168.1.5": true, + }, wantCSP: true, wantParts: []string{ + // Pinned origin in all directives + "script-src 'self' http://0.0.0.0:8080", + // LAN IPs in connect-src + "http://192.168.1.5:8080", + "ws://192.168.1.5:8080", "http://127.0.0.1:8080", - "ws://127.0.0.1:8080", "http://localhost:8080", }, + wantAbsent: []string{ + // LAN IPs must NOT be in script-src + "script-src 'self' http://0.0.0.0:8080 http://192", + }, }, { - name: "PublicOrigin_included", + name: "PublicOrigin_in_connect_src_only", path: "/", host: "127.0.0.1", port: 8081, publicOrigins: []string{"https://view.example.com"}, wantCSP: true, wantParts: []string{ - "http://127.0.0.1:8081", + // Pinned origin in script-src + "script-src 'self' http://127.0.0.1:8081", + // Public origin in connect-src "https://view.example.com", "wss://view.example.com", }, + wantAbsent: []string{ + // Public origin must NOT be in script-src + "script-src 'self' http://127.0.0.1:8081 https://view", + }, }, } @@ -251,7 +274,7 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - handler := cspMiddleware(tt.host, tt.port, tt.publicOrigins, inner) + handler := cspMiddleware(tt.host, tt.port, tt.publicOrigins, tt.bindAllIPs, inner) req := httptest.NewRequest(http.MethodGet, tt.path, nil) w := httptest.NewRecorder() @@ -267,6 +290,11 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { t.Errorf("CSP missing %q; got %q", part, csp) } } + for _, absent := range tt.wantAbsent { + if strings.Contains(csp, absent) { + t.Errorf("CSP should not contain %q; got %q", absent, csp) + } + } } else { if csp != "" { t.Errorf("expected no CSP header on API route, got %q", csp) diff --git a/internal/server/server.go b/internal/server/server.go index 1117beff..965f7182 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -358,7 +358,7 @@ func (s *Server) Handler() http.Handler { if bindAll { bindAllIPs = localInterfaceIPs() } - h := cspMiddleware(s.cfg.Host, s.cfg.Port, s.cfg.PublicOrigins, + h := cspMiddleware(s.cfg.Host, s.cfg.Port, s.cfg.PublicOrigins, bindAllIPs, s.authMiddleware( hostCheckMiddleware( allowedHosts, bindAll, s.cfg.Port, bindAllIPs, @@ -398,8 +398,8 @@ func (s *Server) Handler() http.Handler { // responses. The policy pins the exact host:port origin so that // even if Tauri's compile-time CSP uses a wildcard port, the // intersection narrows to the actual runtime port. -func cspMiddleware(host string, port int, publicOrigins []string, next http.Handler) http.Handler { - policy := buildCSPPolicy(host, port, publicOrigins) +func cspMiddleware(host string, port int, publicOrigins []string, bindAllIPs map[string]bool, next http.Handler) http.Handler { + policy := buildCSPPolicy(host, port, publicOrigins, bindAllIPs) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Security-Policy", policy) @@ -412,40 +412,57 @@ func cspMiddleware(host string, port int, publicOrigins []string, next http.Hand // It uses the same loopback/bind-all logic as buildAllowedOrigins // to handle IPv6 bracketing, 0.0.0.0/:: normalization, and // public origins (proxy/TLS). -func buildCSPPolicy(host string, port int, publicOrigins []string) string { - // Collect HTTP and WS origins using net.JoinHostPort for - // correct IPv6 bracket formatting. - httpSrcs := []string{"'self'"} - wsSrcs := []string{} - - addOrigin := func(h string) { +// +// The server's own origin (host:port) is included explicitly in +// all directives because WebKitGTK in a Tauri webview may not +// resolve 'self' to the Go server origin after navigating from +// tauri://localhost. Public origins and LAN IPs are restricted +// to connect-src only to limit the script execution surface. +func buildCSPPolicy(host string, port int, publicOrigins []string, bindAllIPs map[string]bool) string { + // serverOrigin is the pinned http origin for the configured + // host:port, used in all directives so resources load + // correctly regardless of how the webview resolves 'self'. + serverOrigin := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + + // connectSrcs collects additional origins for connect-src + // (fetch, SSE, WebSocket) — loopback variants, LAN IPs, + // and public/proxy origins. + connectHTTP := []string{} + connectWS := []string{} + + addConnectOrigin := func(h string) { for _, o := range httpOrigin(h, port) { - httpSrcs = append(httpSrcs, o) - wsSrcs = append(wsSrcs, strings.Replace(o, "http://", "ws://", 1)) + connectHTTP = append(connectHTTP, o) + connectWS = append(connectWS, strings.Replace(o, "http://", "ws://", 1)) } } - addOrigin(host) // Mirror buildAllowedOrigins: when binding to loopback, // include the other loopback variant. When binding to all - // interfaces, include all loopback origins. + // interfaces, include all loopback origins plus every + // concrete interface IP. switch host { case "127.0.0.1": - addOrigin("localhost") + addConnectOrigin("localhost") case "localhost": - addOrigin("127.0.0.1") + addConnectOrigin("127.0.0.1") case "0.0.0.0", "::": - addOrigin("127.0.0.1") - addOrigin("localhost") - addOrigin("::1") + addConnectOrigin("127.0.0.1") + addConnectOrigin("localhost") + addConnectOrigin("::1") + for ip := range bindAllIPs { + if ip != "127.0.0.1" && ip != "::1" { + addConnectOrigin(ip) + } + } case "::1": - addOrigin("127.0.0.1") - addOrigin("localhost") + addConnectOrigin("127.0.0.1") + addConnectOrigin("localhost") } for _, origin := range publicOrigins { - httpSrcs = append(httpSrcs, origin) - wsSrcs = append(wsSrcs, + connectHTTP = append(connectHTTP, origin) + connectWS = append(connectWS, strings.NewReplacer( "https://", "wss://", "http://", "ws://", @@ -453,8 +470,16 @@ func buildCSPPolicy(host string, port int, publicOrigins []string) string { ) } - httpList := strings.Join(httpSrcs, " ") - connectList := strings.Join(append(httpSrcs, wsSrcs...), " ") + // resource-src: 'self' + pinned server origin (for all resource types) + resourceSrc := "'self' " + serverOrigin + + // connect-src: resource-src + loopback/LAN/public origins + ws variants + connectParts := []string{resourceSrc} + wsOrigin := "ws://" + net.JoinHostPort(host, strconv.Itoa(port)) + connectParts = append(connectParts, wsOrigin) + connectParts = append(connectParts, connectHTTP...) + connectParts = append(connectParts, connectWS...) + connectSrc := strings.Join(connectParts, " ") return fmt.Sprintf( "default-src %[1]s; "+ @@ -466,7 +491,7 @@ func buildCSPPolicy(host string, port int, publicOrigins []string) string { "object-src 'none'; "+ "frame-ancestors 'none'; "+ "base-uri 'none'", - httpList, connectList, + resourceSrc, connectSrc, ) } From 3faf7f79e14d34433af10c7f19d4a5062ffba86b Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 20:34:47 -0700 Subject: [PATCH 7/9] fix: restore webview focus after native GTK dialog dismissal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux/WebKitGTK, dismissing a native GTK dialog (e.g. the auto-update confirmation) can leave the webview frozen — it renders but does not process input events. Fix by calling set_focus() on the main webview window in every dialog callback. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/lib.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ba614573..8286c156 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -622,6 +622,16 @@ fn setup_menu(app: &mut App) -> Result<(), DynError> { Ok(()) } +/// Restore input focus to the main webview after a native GTK dialog +/// is dismissed. On Linux/WebKitGTK, native dialogs can leave the +/// webview in a frozen state where it renders but does not process +/// input events. +fn restore_webview_focus(handle: &AppHandle) { + if let Some(window) = handle.get_webview_window("main") { + let _ = window.set_focus(); + } +} + static UPDATE_CHECK_ACTIVE: AtomicBool = AtomicBool::new(false); // Guard that clears UPDATE_CHECK_ACTIVE on drop, ensuring the @@ -654,11 +664,12 @@ async fn check_for_updates(handle: &AppHandle, silent: bool) { .is_err() { if !silent { + let h = handle.clone(); handle .dialog() .message("An update check is already in progress.") .title("Update Check") - .show(|_| {}); + .show(move |_| restore_webview_focus(&h)); } return; } @@ -669,11 +680,12 @@ async fn check_for_updates(handle: &AppHandle, silent: bool) { Err(err) => { eprintln!("[agentsview] updater unavailable: {err}"); if !silent { + let h = handle.clone(); handle .dialog() .message("Could not check for updates. The updater is not configured.") .title("Update Check") - .show(|_| {}); + .show(move |_| restore_webview_focus(&h)); } return; } @@ -684,11 +696,12 @@ async fn check_for_updates(handle: &AppHandle, silent: bool) { Err(err) => { eprintln!("[agentsview] update check failed: {err}"); if !silent { + let h = handle.clone(); handle .dialog() .message("Could not check for updates. Please try again later.") .title("Update Check") - .show(|_| {}); + .show(move |_| restore_webview_focus(&h)); } return; } @@ -696,11 +709,12 @@ async fn check_for_updates(handle: &AppHandle, silent: bool) { let Some(update) = update else { if !silent { + let h = handle.clone(); handle .dialog() .message("You're running the latest version.") .title("No Updates Available") - .show(|_| {}); + .show(move |_| restore_webview_focus(&h)); } return; }; @@ -722,6 +736,7 @@ async fn check_for_updates(handle: &AppHandle, silent: bool) { if let Err(err) = update.download_and_install(|_, _| {}, || {}).await { eprintln!("[agentsview] update install failed: {err}"); + let h = handle.clone(); handle .dialog() .message( @@ -729,7 +744,7 @@ async fn check_for_updates(handle: &AppHandle, silent: bool) { Please try downloading manually from the releases page.", ) .title("Update Failed") - .show(|_| {}); + .show(move |_| restore_webview_focus(&h)); return; } @@ -752,12 +767,14 @@ async fn dialog_confirm( message: &str, ) -> bool { let (tx, rx) = tokio::sync::oneshot::channel(); + let h = handle.clone(); handle .dialog() .message(message) .title(title) .buttons(MessageDialogButtons::OkCancel) .show(move |confirmed| { + restore_webview_focus(&h); let _ = tx.send(confirmed); }); rx.await.unwrap_or(false) From ac090d4fa4609d8d344152d1fea1d3da6f8d2993 Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 21:03:03 -0700 Subject: [PATCH 8/9] fix: allow Google Fonts in CSP and disable Tauri compile-time CSP Add Google Fonts CDN to style-src and font-src directives so the frontend can load web fonts. Remove unnecessary frame-ancestors directive. Set Tauri csp to null since the Go server handles CSP at runtime, avoiding double-policy conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/tauri.conf.json | 2 +- internal/server/middleware_test.go | 6 +++++- internal/server/server.go | 5 ++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1738440e..38a55655 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self' http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:; style-src 'self' http://127.0.0.1:* 'unsafe-inline'; font-src 'self' http://127.0.0.1:* data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'" + "csp": null } }, "bundle": { diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index d1133416..e5276f7d 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -181,7 +181,11 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { "default-src 'self' http://127.0.0.1:8081", "connect-src 'self' http://127.0.0.1:8081", "ws://127.0.0.1:8081", - "frame-ancestors 'none'", + "style-src 'self' http://127.0.0.1:8081 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' http://127.0.0.1:8081 data: https://fonts.gstatic.com", + }, + wantAbsent: []string{ + "frame-ancestors", }, }, { diff --git a/internal/server/server.go b/internal/server/server.go index 965f7182..20ab4789 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -486,10 +486,9 @@ func buildCSPPolicy(host string, port int, publicOrigins []string, bindAllIPs ma "script-src %[1]s; "+ "connect-src %[2]s; "+ "img-src %[1]s data:; "+ - "style-src %[1]s 'unsafe-inline'; "+ - "font-src %[1]s data:; "+ + "style-src %[1]s 'unsafe-inline' https://fonts.googleapis.com; "+ + "font-src %[1]s data: https://fonts.gstatic.com; "+ "object-src 'none'; "+ - "frame-ancestors 'none'; "+ "base-uri 'none'", resourceSrc, connectSrc, ) From eec9d61eefa85b7e4011f39b1d993f3212368c6c Mon Sep 17 00:00:00 2001 From: mempko Date: Tue, 17 Mar 2026 22:57:52 -0700 Subject: [PATCH 9/9] fix: restore compile-time CSP, add clickjacking protection, and delay focus restore - Restore restrictive compile-time CSP in tauri.conf.json with localhost and Google Fonts entries needed for WebKitGTK compatibility - Add frame-ancestors 'none' to runtime CSP middleware and X-Frame-Options: DENY as defense in depth against clickjacking - Delay webview focus restoration by 100ms after GTK dialog dismissal so the dialog fully releases focus before set_focus() fires Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/lib.rs | 14 +++++++++++--- desktop/src-tauri/tauri.conf.json | 2 +- internal/server/middleware_test.go | 8 +++++--- internal/server/server.go | 4 +++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8286c156..185326ab 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -627,9 +627,17 @@ fn setup_menu(app: &mut App) -> Result<(), DynError> { /// webview in a frozen state where it renders but does not process /// input events. fn restore_webview_focus(handle: &AppHandle) { - if let Some(window) = handle.get_webview_window("main") { - let _ = window.set_focus(); - } + let handle = handle.clone(); + // Delay focus restoration so the native GTK dialog has time to + // fully close and release window focus. Without this, set_focus() + // fires while the dialog still owns focus and the webview stays + // unresponsive. + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + if let Some(window) = handle.get_webview_window("main") { + let _ = window.set_focus(); + } + }); } static UPDATE_CHECK_ACTIVE: AtomicBool = AtomicBool::new(false); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 38a55655..e6d682df 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self' http://127.0.0.1:* http://localhost:*; script-src 'self' http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: http://127.0.0.1:* http://localhost:*; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com http://127.0.0.1:* http://localhost:*; font-src 'self' data: https://fonts.gstatic.com http://127.0.0.1:* http://localhost:*; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" } }, "bundle": { diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index e5276f7d..f84cf230 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -183,9 +183,7 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { "ws://127.0.0.1:8081", "style-src 'self' http://127.0.0.1:8081 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' http://127.0.0.1:8081 data: https://fonts.gstatic.com", - }, - wantAbsent: []string{ - "frame-ancestors", + "frame-ancestors 'none'", }, }, { @@ -299,6 +297,10 @@ func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) { t.Errorf("CSP should not contain %q; got %q", absent, csp) } } + xfo := w.Header().Get("X-Frame-Options") + if xfo != "DENY" { + t.Errorf("expected X-Frame-Options DENY, got %q", xfo) + } } else { if csp != "" { t.Errorf("expected no CSP header on API route, got %q", csp) diff --git a/internal/server/server.go b/internal/server/server.go index 20ab4789..b6f5c848 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -403,6 +403,7 @@ func cspMiddleware(host string, port int, publicOrigins []string, bindAllIPs map return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Security-Policy", policy) + w.Header().Set("X-Frame-Options", "DENY") } next.ServeHTTP(w, r) }) @@ -489,7 +490,8 @@ func buildCSPPolicy(host string, port int, publicOrigins []string, bindAllIPs ma "style-src %[1]s 'unsafe-inline' https://fonts.googleapis.com; "+ "font-src %[1]s data: https://fonts.gstatic.com; "+ "object-src 'none'; "+ - "base-uri 'none'", + "base-uri 'none'; "+ + "frame-ancestors 'none'", resourceSrc, connectSrc, ) }