From 0c2f93bcd14c4dd41cd27efb774cfebe76034399 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 29 Jun 2026 12:10:52 +0800 Subject: [PATCH] fix: allow same-origin attachment previews (#4679) --- server/internal/middleware/csp.go | 31 ++++++++++++++++--- server/internal/middleware/csp_test.go | 42 +++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/server/internal/middleware/csp.go b/server/internal/middleware/csp.go index 4fefa2a5e..a218e674f 100644 --- a/server/internal/middleware/csp.go +++ b/server/internal/middleware/csp.go @@ -1,20 +1,43 @@ package middleware -import "net/http" +import ( + "net/http" + "strings" +) -const cspHeader = "default-src 'self'; " + +const cspBaseHeader = "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' https: data:; " + - "connect-src 'self' wss:; " + + "connect-src 'self' wss:; " + +const cspHeader = cspBaseHeader + "frame-ancestors 'none'; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self'" +const attachmentPreviewCSPHeader = cspBaseHeader + + "frame-ancestors 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'" + func ContentSecurityPolicy(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", cspHeader) + w.Header().Set("Content-Security-Policy", contentSecurityPolicyForRequest(r)) next.ServeHTTP(w, r) }) } + +func contentSecurityPolicyForRequest(r *http.Request) string { + if isAttachmentPreviewDocumentPath(r.URL.Path) { + return attachmentPreviewCSPHeader + } + return cspHeader +} + +func isAttachmentPreviewDocumentPath(path string) bool { + return strings.HasPrefix(path, "/api/attachments/") && + (strings.HasSuffix(path, "/download") || strings.HasSuffix(path, "/content")) +} diff --git a/server/internal/middleware/csp_test.go b/server/internal/middleware/csp_test.go index 35ab93eba..830171fb5 100644 --- a/server/internal/middleware/csp_test.go +++ b/server/internal/middleware/csp_test.go @@ -17,16 +17,48 @@ func TestContentSecurityPolicy(t *testing.T) { handler.ServeHTTP(rec, req) csp := rec.Header().Get("Content-Security-Policy") - if csp == "" { - t.Fatal("Content-Security-Policy header is missing") - } - - required := []string{ + assertCSPDirectives(t, csp, []string{ "script-src 'self'", "object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", + }) +} + +func TestContentSecurityPolicyAllowsSameOriginAttachmentPreviews(t *testing.T) { + handler := ContentSecurityPolicy(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + for _, path := range []string{ + "/api/attachments/019f0dae-0315-79b7-b653-f55d6af90403/download", + "/api/attachments/019f0dae-0315-79b7-b653-f55d6af90403/content", + } { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + csp := rec.Header().Get("Content-Security-Policy") + assertCSPDirectives(t, csp, []string{ + "script-src 'self'", + "object-src 'none'", + "frame-ancestors 'self'", + "base-uri 'self'", + "form-action 'self'", + }) + if strings.Contains(csp, "frame-ancestors 'none'") { + t.Fatalf("attachment preview CSP must not block same-origin iframe embedding; got: %s", csp) + } + }) + } +} + +func assertCSPDirectives(t *testing.T, csp string, required []string) { + t.Helper() + if csp == "" { + t.Fatal("Content-Security-Policy header is missing") } for _, directive := range required { if !strings.Contains(csp, directive) {