fix(csp): allow generated-image R2 host in img-src so image-gen results render #3128

Merged
core-devops merged 1 commits from fix/csp-img-src-generated-images into main 2026-06-21 13:08:40 +00:00
Member

The bug

The tenant chat UI at https://<slug>.moleculesai.app/ served a CSP with img-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 in img-src — broken thumbnail despite a valid image/png (HTTP 200, ~1.1MB). Pure CSP failure, not a content failure.

Fix

Add the generated-image R2 host to img-src in 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:

  • A deploy MAY pin the exact origin via NEXT_PUBLIC_IMAGE_GEN_R2_HOST (canvas) / MOLECULE_IMAGE_GEN_R2_HOST (Go) — tightest policy, preferred when known.
  • Otherwise we fall back to the documented https://*.r2.cloudflarestorage.com.

Security rationale

Only img-src (image display) is widened. connect-src is unchanged, so fetch()/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 strict default-src 'self'.

Both suites green locally; go vet + tsc --noEmit clean.

Deploy / verify

Tenant UI CSP reaches prod via the standard core build → tenant image deploy. After deploy, verify:

curl -sI https://<slug>.moleculesai.app/ | grep -i content-security-policy
# img-src must now include https://*.r2.cloudflarestorage.com (or the pinned host)

Then generate an image in the chat and confirm the <img> renders (no CSP console error). Optionally set NEXT_PUBLIC_IMAGE_GEN_R2_HOST / MOLECULE_IMAGE_GEN_R2_HOST to the exact bucket origin to tighten from the wildcard.

🤖 Generated with Claude Code

## The bug The tenant chat UI at `https://<slug>.moleculesai.app/` served a CSP with `img-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 in `img-src` — broken thumbnail despite a valid `image/png` (HTTP 200, ~1.1MB). Pure CSP failure, not a content failure. ## Fix Add the generated-image R2 host to `img-src` in **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: - A deploy MAY pin the exact origin via `NEXT_PUBLIC_IMAGE_GEN_R2_HOST` (canvas) / `MOLECULE_IMAGE_GEN_R2_HOST` (Go) — tightest policy, preferred when known. - Otherwise we fall back to the documented `https://*.r2.cloudflarestorage.com`. ### Security rationale Only `img-src` (image **display**) is widened. `connect-src` is **unchanged**, so `fetch()`/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 strict `default-src 'self'`**. Both suites green locally; `go vet` + `tsc --noEmit` clean. ## Deploy / verify Tenant UI CSP reaches prod via the standard core build → tenant image deploy. After deploy, verify: ``` curl -sI https://<slug>.moleculesai.app/ | grep -i content-security-policy # img-src must now include https://*.r2.cloudflarestorage.com (or the pinned host) ``` Then generate an image in the chat and confirm the `<img>` renders (no CSP console error). Optionally set `NEXT_PUBLIC_IMAGE_GEN_R2_HOST` / `MOLECULE_IMAGE_GEN_R2_HOST` to the exact bucket origin to tighten from the wildcard. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
core-devops added 1 commit 2026-06-21 13:04:26 +00:00
fix(csp): allow generated-image R2 host in img-src so image-gen results render
CI / Python Lint & Test (pull_request) Successful in 6s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 8s
sop-checklist / review-refire (pull_request_target) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 19s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 19s
sop-checklist / na-declarations (pull_request) N/A: (none)
reserved-path-review / reserved-path-review (pull_request_target) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 16s
sop-checklist / all-items-acked (pull_request_target) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 22s
template-delivery-e2e / detect-changes (pull_request) Successful in 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (pull_request) Has been cancelled
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (pull_request) Has been cancelled
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (pull_request) Has been cancelled
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Has been cancelled
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been cancelled
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Has been cancelled
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (pull_request) Has been cancelled
E2E Staging SaaS (full lifecycle) / E2E Staging Workspace Requests (core#2606) (pull_request) Has been cancelled
E2E API Smoke Test / detect-changes (pull_request) Successful in 26s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request_target) Failing after 22s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 35s
PR Diff Guard / PR diff guard (pull_request) Successful in 1m5s
Harness Replays / Harness Replays (pull_request) Successful in 1m31s
reserved-path-review / reserved-path-review (pull_request_review) Successful in 9s
qa-review / approved (pull_request_target) Review check failed via pull_request_review trigger
security-review / approved (pull_request_target) Approved via pull_request_review trigger
security-review / approved (pull_request_review) Successful in 11s
qa-review / approved (pull_request_review) Failing after 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m30s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Successful in 2m22s
CI / Platform (Go) (pull_request) Successful in 3m36s
CI / Canvas (Next.js) (pull_request) Successful in 3m40s
CI / Canvas Deploy Status (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 4s
audit-force-merge / audit (pull_request_target) Successful in 11s
b0d5460b50
The tenant chat UI CSP set img-src to `'self' blob: data:` only. The image-gen
capability socket (RFC #3105) returns generated images as time-boxed,
SigV4-presigned R2 GET URLs on `<bucket>.<cf-account-hash>.r2.cloudflarestorage.com`.
The chat renders those as `<img src="https://…r2.cloudflarestorage.com/…">`,
which the browser blocked (broken thumbnail) even though the bytes are a valid
PNG — pure CSP failure, not a content failure.

Add the generated-image R2 host to img-src in BOTH CSP emitters that cover the
canvas HTML page:
  - canvas/src/middleware.ts (Next.js per-request CSP)
  - workspace-server .../securityheaders.go (Go front-door canvas-route CSP)
The combined tenant image returns both headers and browsers enforce the
intersection, so the host must be in both.

Host selection: the bucket + CF account hash are control-plane deploy config,
not known to the canvas build, so we cannot hardcode the exact origin. A deploy
MAY pin it (NEXT_PUBLIC_IMAGE_GEN_R2_HOST / MOLECULE_IMAGE_GEN_R2_HOST), tightest
policy; otherwise we fall back to the documented `https://*.r2.cloudflarestorage.com`.

Security: only img-src (image *display*) is widened. connect-src is unchanged,
so fetch()/XHR to R2 stays blocked — no exfiltration channel. The URLs are
short-lived signed GETs of a single object key the agent already holds.

Tests: extend csp-nonce.test.ts + securityheaders_test.go to assert the R2 host
is in img-src (default wildcard + pinned override) and is NOT in connect-src,
and that API routes keep the strict default-src 'self' policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
molecule-code-reviewer approved these changes 2026-06-21 13:06:30 +00:00
molecule-code-reviewer left a comment
Member

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.

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.
core-security approved these changes 2026-06-21 13:06:32 +00:00
core-security left a comment
Member

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.

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.
core-devops scheduled this pull request to auto merge when all checks succeed 2026-06-21 13:07:17 +00:00
core-devops merged commit 3cce3bd3c7 into main 2026-06-21 13:08:40 +00:00
Sign in to join this conversation.
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: molecule-ai/molecule-core#3128