diff --git a/canvas/src/__tests__/csp-nonce.test.ts b/canvas/src/__tests__/csp-nonce.test.ts index a76235aaa..317a3a466 100644 --- a/canvas/src/__tests__/csp-nonce.test.ts +++ b/canvas/src/__tests__/csp-nonce.test.ts @@ -41,6 +41,12 @@ describe("buildCsp — production", () => { expect(csp).toContain("object-src 'none'"); }); + it("allows blob: in frame-src for authenticated PDF previews", () => { + const frameSrc = csp.match(/frame-src[^;]*/)?.[0] ?? ""; + expect(frameSrc).toContain("'self'"); + expect(frameSrc).toContain("blob:"); + }); + it("locks base-uri to 'self' (prevents base-tag injection)", () => { expect(csp).toContain("base-uri 'self'"); }); diff --git a/canvas/src/middleware.ts b/canvas/src/middleware.ts index 9442eb999..f904a6459 100644 --- a/canvas/src/middleware.ts +++ b/canvas/src/middleware.ts @@ -12,7 +12,9 @@ import type { NextRequest } from "next/server"; * • style-src retains 'unsafe-inline': React Flow positions nodes via * element-level style="" attributes which cannot be nonce'd; CSS injection * is significantly lower risk than script injection and is acceptable here. - * • object-src / base-uri / frame-ancestors locked to 'none'/'self'. + * • object-src locked to 'none'; frame-src allows self + blob: for + * browser-native PDF previews backed by authenticated Blob URLs. + * • base-uri / frame-ancestors locked to 'self'/'none'. * • upgrade-insecure-requests forces HTTPS on mixed-content. * * Development — permissive policy: @@ -61,6 +63,7 @@ export function buildCsp(nonce: string, isDev: boolean): string { "img-src 'self' blob: data:", "font-src 'self'", "object-src 'none'", + "frame-src 'self' blob:", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", diff --git a/workspace-server/internal/middleware/securityheaders.go b/workspace-server/internal/middleware/securityheaders.go index 2f96ff14d..c9aa1470e 100644 --- a/workspace-server/internal/middleware/securityheaders.go +++ b/workspace-server/internal/middleware/securityheaders.go @@ -23,7 +23,7 @@ var apiPrefixes = []string{ "/settings", "/bundles", "/org", - "/orgs", // #610 — per-org plugin allowlist routes + "/orgs", // #610 — per-org plugin allowlist routes "/templates", "/plugins", "/webhooks", @@ -95,6 +95,7 @@ func SecurityHeaders() gin.HandlerFunc { "script-src 'self' 'unsafe-inline'; "+ "style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data: blob:; "+ + "frame-src 'self' blob:; "+ "connect-src 'self' ws: wss:; "+ "font-src 'self' data:") } diff --git a/workspace-server/internal/middleware/securityheaders_test.go b/workspace-server/internal/middleware/securityheaders_test.go index 885eef4fb..2f3e6d4b8 100644 --- a/workspace-server/internal/middleware/securityheaders_test.go +++ b/workspace-server/internal/middleware/securityheaders_test.go @@ -57,6 +57,7 @@ func TestSecurityHeaders(t *testing.T) { "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", + "frame-src 'self' blob:", "connect-src 'self' ws: wss:", "font-src 'self' data:", } { @@ -195,6 +196,9 @@ func TestCSPCanvasRoutesGetPermissivePolicy(t *testing.T) { if strings.Contains(csp, "'unsafe-eval'") { t.Errorf("canvas path %q: CSP must not contain 'unsafe-eval', got %q", path, csp) } + if !strings.Contains(csp, "frame-src 'self' blob:") { + t.Errorf("canvas path %q: CSP should allow blob: frames for PDF previews, got %q", path, csp) + } }) } } @@ -267,7 +271,7 @@ func TestIsAPIPath(t *testing.T) { {"/ws", true}, {"/events", true}, {"/approvals", true}, - {"/orgs", true}, // #610 allowlist routes + {"/orgs", true}, // #610 allowlist routes {"/orgs/org-1/plugins/allowlist", true}, // Sub-paths {"/workspaces/abc-123", true},