fix(csp): allow generated-image R2 host in img-src so image-gen results render #3128
Reference in New Issue
Block a user
Delete Branch "fix/csp-img-src-generated-images"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The bug
The tenant chat UI at
https://<slug>.moleculesai.app/served a CSP withimg-src 'self' blob: data:. Generated images from the image-gen capability socket (RFC #3105) are returned as time-boxed, SigV4-presigned R2 GET URLs on<bucket>.<cf-account-hash>.r2.cloudflarestorage.com. The chat renders them as<img src="https://…r2.cloudflarestorage.com/…">, which the browser blocked because that host was not inimg-src— broken thumbnail despite a validimage/png(HTTP 200, ~1.1MB). Pure CSP failure, not a content failure.Fix
Add the generated-image R2 host to
img-srcin both CSP emitters that cover the canvas HTML page:canvas/src/middleware.ts— Next.js per-request CSP (the value seen in the bug report).workspace-server/internal/middleware/securityheaders.go— the Go front-door canvas-route CSP.The combined tenant image returns both headers and browsers enforce the intersection of multiple CSP headers, so the host must be present in both or it is still blocked.
Why a wildcard (with an optional pin)
The exact host =
MOLECULE_IMAGE_GEN_BUCKET+ the CF R2 account hash (MOLECULE_IMAGE_GEN_ENDPOINT), both control-plane deploy config not known to the canvas build. So:NEXT_PUBLIC_IMAGE_GEN_R2_HOST(canvas) /MOLECULE_IMAGE_GEN_R2_HOST(Go) — tightest policy, preferred when known.https://*.r2.cloudflarestorage.com.Security rationale
Only
img-src(image display) is widened.connect-srcis unchanged, sofetch()/XHR to R2 stays blocked — there is no data-exfiltration channel via this directive. The R2 URLs are short-lived, SigV4-presigned GETs scoped to a single object key the agent already legitimately holds; permitting the<img>to render them grants no new capability beyond viewing an image the user's own agent produced.script-src/style-src/connect-src/etc. are untouched.Tests
canvas/src/__tests__/csp-nonce.test.ts: img-src contains the R2 host (dev + prod);buildImgSrc()default wildcard vs pinned override; connect-src does NOT contain any R2 host.workspace-server/.../securityheaders_test.go: canvas CSP img-src has the R2 host (default + pinned), connect-src does not, API routes keep strictdefault-src 'self'.Both suites green locally;
go vet+tsc --noEmitclean.Deploy / verify
Tenant UI CSP reaches prod via the standard core build → tenant image deploy. After deploy, verify:
Then generate an image in the chat and confirm the
<img>renders (no CSP console error). Optionally setNEXT_PUBLIC_IMAGE_GEN_R2_HOST/MOLECULE_IMAGE_GEN_R2_HOSTto the exact bucket origin to tighten from the wildcard.🤖 Generated with Claude Code
Reviewed: both CSP emitters (canvas middleware.ts buildCsp + workspace-server securityheaders.go) add the R2 host to img-src — correct, since browsers enforce the intersection of both headers. Only img-src widened; tests assert R2 present in img-src and ABSENT from connect-src. Pin via NEXT_PUBLIC_IMAGE_GEN_R2_HOST/MOLECULE_IMAGE_GEN_R2_HOST. Fixes the CSP-blocked generated-image display. LGTM.
Security review: widening is img-src ONLY (display), connect-src UNCHANGED (no fetch/XHR exfil to R2 — test enforces this invariant). Presigned R2 GETs are time-boxed + SigV4-signed single-object reads. Wildcard *.r2.cloudflarestorage.com is acceptable (display-only, low exfil risk) and is tightenable to the exact bucket origin via the env pin — RECOMMEND setting NEXT_PUBLIC_IMAGE_GEN_R2_HOST + MOLECULE_IMAGE_GEN_R2_HOST to the prod bucket host to drop the wildcard. Approving.