Compare commits

...

1 Commits

Author SHA1 Message Date
J
9695a7ab8e fix(attachments): relax frame-ancestors on local /uploads static route (MUL-3821)
Self-hosted local-disk deployments serve document previews straight from
the public /uploads/* static route. That route inherited the global
`frame-ancestors 'none'` CSP from the middleware, so iframe-based previews
(PDF/HTML) were blocked by the browser — only the /api/attachments/*
download endpoint had been exempted (#4635 / #4679).

Serve /uploads/* through a new Handler.ServeLocalUpload that applies the
same preview security headers as the download endpoint
(setAttachmentPreviewSecurityHeaders), so the relaxed, config-aware
`frame-ancestors 'self' <configured origins>` policy applies to both
same-origin and split frontend/backend origin setups. Inline <img>
rendering is unaffected (frame-ancestors does not gate images); cloud
storage (S3/CloudFront) never hits this route.

Adds regression tests covering the relaxed CSP on /uploads and the
non-local-storage 404 guard.

Refs #4477

Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 13:21:35 +08:00
3 changed files with 89 additions and 6 deletions

View File

@@ -584,12 +584,14 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
realtime.HandleWebSocket(hub, mc, pr, slugResolver, w, r)
})
// Local file serving (when using local storage)
if local, ok := store.(*storage.LocalStorage); ok {
r.Get("/uploads/*", func(w http.ResponseWriter, r *http.Request) {
file := strings.TrimPrefix(r.URL.Path, "/uploads/")
local.ServeFile(w, r, file)
})
// Local file serving (when using local storage). Served through the
// handler so /uploads/* carries the same preview security headers as the
// /api/attachments download endpoint; self-hosted split-origin/same-origin
// clients can then iframe-preview PDFs/HTML fetched straight from the
// static route instead of hitting the global frame-ancestors 'none' CSP.
// See MUL-3821 / #4477.
if _, ok := store.(*storage.LocalStorage); ok {
r.Get("/uploads/*", h.ServeLocalUpload)
}
// Auth (public) — per-IP rate limiting.

View File

@@ -722,6 +722,24 @@ func shouldProxyAttachmentURL(rawURL string) bool {
return false
}
// ServeLocalUpload serves a local-disk object from the public /uploads/*
// route. It carries the same preview security headers as the authenticated
// download endpoint so self-hosted deployments — same-origin or split
// frontend/backend origins — can inline-render images and iframe-preview
// documents (PDF/HTML) fetched straight from the static route. Without these
// headers the global "frame-ancestors 'none'" policy blocks those previews.
// See MUL-3821 / #4477.
func (h *Handler) ServeLocalUpload(w http.ResponseWriter, r *http.Request) {
local, ok := h.Storage.(*storage.LocalStorage)
if !ok {
http.NotFound(w, r)
return
}
h.setAttachmentPreviewSecurityHeaders(w)
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
local.ServeFile(w, r, key)
}
func (h *Handler) proxyAttachmentDownload(w http.ResponseWriter, r *http.Request, att db.Attachment, key string) {
reader, err := h.Storage.GetReader(r.Context(), key)
if err != nil {

View File

@@ -15,6 +15,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -22,6 +24,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/storage"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -1264,3 +1267,63 @@ func TestIsDurablePublicURL(t *testing.T) {
})
}
}
// TestServeLocalUpload_RelaxesFrameAncestorsForPreview covers the self-hosted
// local-disk case where document previews (PDF/HTML) are fetched straight from
// the public /uploads/* static route. That route inherits the global
// "frame-ancestors 'none'" CSP from the middleware, which blocks iframe
// previews; ServeLocalUpload must overwrite it with the same relaxed preview
// policy the /api/attachments download endpoint uses. See MUL-3821 / #4477.
func TestServeLocalUpload_RelaxesFrameAncestorsForPreview(t *testing.T) {
dir := t.TempDir()
key := "workspaces/ws-1/preview.pdf"
full := filepath.Join(dir, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
const body = "%PDF-1.7 local-disk preview"
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
t.Setenv("LOCAL_UPLOAD_DIR", dir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "")
local := storage.NewLocalStorageFromEnv()
if local == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
h := &Handler{
Storage: local,
cfg: Config{AttachmentFrameAncestors: []string{"https://app.example.test"}},
}
req := httptest.NewRequest(http.MethodGet, "/uploads/"+key, nil)
w := httptest.NewRecorder()
// Simulate the global CSP middleware having already stamped the strict
// policy on the response before the static route runs.
w.Header().Set("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'")
h.ServeLocalUpload(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%q", w.Code, w.Body.String())
}
if got := w.Body.String(); got != body {
t.Fatalf("body = %q, want %q", got, body)
}
requireAttachmentPreviewCSP(t, w.Header(), "https://app.example.test")
}
// TestServeLocalUpload_NonLocalStorage404 guards the defensive branch: the
// /uploads/* route is only registered under local storage, but the handler
// must not serve anything when the backing store is not local disk.
func TestServeLocalUpload_NonLocalStorage404(t *testing.T) {
h := &Handler{Storage: &mockStorage{}}
req := httptest.NewRequest(http.MethodGet, "/uploads/workspaces/ws-1/x.png", nil)
w := httptest.NewRecorder()
h.ServeLocalUpload(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}