fix(csp): bake exact generated-image R2 host into tenant img-src pin (#3128 follow-up) #3131

Open
core-devops wants to merge 1 commits from fix/csp-img-src-pin-exact-r2-host into main
Member

What

Follow-up to #3128. Drop the wildcard: make the tenant-UI CSP img-src emit the exact generated-image R2 host instead of https://*.r2.cloudflarestorage.com.

This PR wires the canvas BUILD-time pin (NEXT_PUBLIC_IMAGE_GEN_R2_HOST). The Go RUNTIME pin (MOLECULE_IMAGE_GEN_R2_HOST in the tenant container env) is wired in molecule-controlplane PR fix/csp-img-src-pin-exact-r2-host-runtime. Browsers enforce the intersection of both emitters, so both must carry the exact host.

Exact host

https://molecule-workspace-data.bfa4e604e168a938e565600b27e2828c.r2.cloudflarestorage.com

Derived from the CP's own R2 config (bucket molecule-workspace-data + endpoint account-hash bfa4e604e168a938e565600b27e2828c). Verified against Infisical /shared/controlplane for both prod and staging — they share the bucket + Cloudflare account, so a single baked value is correct (no cross-env mismatch). Image gen is enabled on prod; staging shares the same bucket/endpoint via the workspace-data fallback.

Changes (img-src only — connect-src untouched)

  • workspace-server/Dockerfile.tenant: ARG+ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST in the canvas-builder stage, before npm run build (Next.js inlines NEXT_PUBLIC_* at build time).
  • .gitea/workflows/publish-workspace-server-image.yml: pass --build-arg NEXT_PUBLIC_IMAGE_GEN_R2_HOST, sourced from the IMAGE_GEN_R2_HOST repo variable (config, not a secret) defaulting to the production-derived host.
  • canvas/Dockerfile + publish-canvas-image.yml: same wiring for the standalone canvas image (docker-compose / self-host parity).
  • workspace-server/internal/middleware/csp_imgsrc_build_wiring_test.go: guards that the build actually sets the pin (ARG+ENV present, set before build, CI passes the exact non-wildcard host).

Deploy path

Merge → core CI publish-workspace-server-image.yml builds the combined platform-tenant image with the exact host baked into the canvas bundle → :latest promoted on prod-green → a tenant redeploy pulls the new image → tenant UI emits the exact host.

Verify (after deploy + tenant redeploy)

curl -sI https://<slug>.moleculesai.app/ | grep -i content-security-policy

img-src shows the exact host and no *.r2.cloudflarestorage.com.

🤖 Generated with Claude Code

## What Follow-up to #3128. Drop the wildcard: make the tenant-UI CSP `img-src` emit the **exact** generated-image R2 host instead of `https://*.r2.cloudflarestorage.com`. This PR wires the **canvas BUILD-time** pin (`NEXT_PUBLIC_IMAGE_GEN_R2_HOST`). The **Go RUNTIME** pin (`MOLECULE_IMAGE_GEN_R2_HOST` in the tenant container env) is wired in molecule-controlplane PR `fix/csp-img-src-pin-exact-r2-host-runtime`. Browsers enforce the intersection of both emitters, so both must carry the exact host. ## Exact host ``` https://molecule-workspace-data.bfa4e604e168a938e565600b27e2828c.r2.cloudflarestorage.com ``` Derived from the CP's own R2 config (bucket `molecule-workspace-data` + endpoint account-hash `bfa4e604e168a938e565600b27e2828c`). Verified against Infisical `/shared/controlplane` for **both** prod and staging — they share the bucket + Cloudflare account, so a single baked value is correct (no cross-env mismatch). Image gen is enabled on prod; staging shares the same bucket/endpoint via the workspace-data fallback. ## Changes (img-src only — connect-src untouched) - `workspace-server/Dockerfile.tenant`: `ARG`+`ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST` in the canvas-builder stage, before `npm run build` (Next.js inlines `NEXT_PUBLIC_*` at build time). - `.gitea/workflows/publish-workspace-server-image.yml`: pass `--build-arg NEXT_PUBLIC_IMAGE_GEN_R2_HOST`, sourced from the `IMAGE_GEN_R2_HOST` repo variable (config, not a secret) defaulting to the production-derived host. - `canvas/Dockerfile` + `publish-canvas-image.yml`: same wiring for the standalone canvas image (docker-compose / self-host parity). - `workspace-server/internal/middleware/csp_imgsrc_build_wiring_test.go`: guards that the build **actually sets** the pin (ARG+ENV present, set before build, CI passes the exact non-wildcard host). ## Deploy path Merge → core CI `publish-workspace-server-image.yml` builds the combined platform-tenant image with the exact host baked into the canvas bundle → `:latest` promoted on prod-green → a tenant **redeploy** pulls the new image → tenant UI emits the exact host. ## Verify (after deploy + tenant redeploy) ``` curl -sI https://<slug>.moleculesai.app/ | grep -i content-security-policy ``` `img-src` shows the exact host and **no** `*.r2.cloudflarestorage.com`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
core-devops added 1 commit 2026-06-21 15:42:03 +00:00
fix(csp): bake exact generated-image R2 host into tenant img-src pin (#3128 follow-up)
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (pull_request) Has been skipped
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging Workspace Requests (core#2606) (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (pull_request) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / detect-changes (pull_request) Successful in 7s
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 9s
Harness Replays / detect-changes (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (pull_request) Successful in 13s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 16s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 8s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 19s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Failing after 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 25s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 24s
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (pull_request) Successful in 16s
E2E Chat / E2E Chat (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request_target) Has been skipped
lint-setup-go-cache / lint-setup-go-cache (pull_request) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 21s
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
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request_target) Failing after 12s
reserved-path-review / reserved-path-review (pull_request_target) Failing after 13s
lint-no-coe-on-required / lint-no-coe-on-required (pull_request) Successful in 31s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 30s
sop-checklist / all-items-acked (pull_request_target) Successful in 11s
gate-check-v3 / gate-check (pull_request_target) Successful in 19s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 41s
PR Diff Guard / PR diff guard (pull_request) Successful in 26s
qa-review / approved (pull_request_target) Failing after 20s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 37s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 38s
template-delivery-e2e / detect-changes (pull_request) Successful in 38s
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 1m44s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m22s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Successful in 2m11s
CI / Platform (Go) (pull_request) Successful in 3m50s
CI / Canvas (Next.js) (pull_request) Successful in 4m10s
CI / Canvas Deploy Status (pull_request) Successful in 1s
CI / all-required (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Successful in 6m30s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (pull_request) Successful in 9m42s
6e8f062189
#3128 added NEXT_PUBLIC_IMAGE_GEN_R2_HOST (canvas, build-time) and
MOLECULE_IMAGE_GEN_R2_HOST (Go workspace-server, runtime) as OPTIONAL pins
for the generated-image R2 host in CSP img-src, defaulting to the wildcard
https://*.r2.cloudflarestorage.com. The build never set the canvas pin, so
deployed tenants shipped the wildcard.

This wires the canvas BUILD-time pin so the tenant UI emits the EXACT host:
  https://molecule-workspace-data.bfa4e604e168a938e565600b27e2828c.r2.cloudflarestorage.com
derived from the CP's own R2 config (bucket molecule-workspace-data +
endpoint account-hash bfa4e604e168a938e565600b27e2828c). prod and staging
share the bucket + Cloudflare account (verified against Infisical
/shared/controlplane for both envs), so a single baked value is correct for
both — no cross-env mismatch.

Changes (canvas build-time only; img-src is the only directive touched):
- workspace-server/Dockerfile.tenant: ARG+ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST
  in the canvas-builder stage, before `npm run build` (Next.js inlines
  NEXT_PUBLIC_* at build).
- .gitea/workflows/publish-workspace-server-image.yml: pass the exact host as
  --build-arg NEXT_PUBLIC_IMAGE_GEN_R2_HOST, sourced from the IMAGE_GEN_R2_HOST
  repo variable (config, not a secret) with the production-derived host as the
  default.
- canvas/Dockerfile + publish-canvas-image.yml: same wiring for the standalone
  canvas image (docker-compose / self-host parity).
- workspace-server/internal/middleware/csp_imgsrc_build_wiring_test.go: guards
  that the build ACTUALLY sets the pin (ARG+ENV present, set before build, CI
  passes the exact non-wildcard host) — closes the gap between "emitter supports
  a pin" and "the deployed bundle ships it".

The Go runtime pin (MOLECULE_IMAGE_GEN_R2_HOST in the tenant container env) is
wired separately in molecule-controlplane (provisioner tenant docker-run env).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some optional checks failed
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (pull_request) Has been skipped
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging Workspace Requests (core#2606) (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (pull_request) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / detect-changes (pull_request) Successful in 7s
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 9s
Harness Replays / detect-changes (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (pull_request) Successful in 13s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 16s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 8s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 19s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Required
Details
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Failing after 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 25s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 24s
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (pull_request) Successful in 16s
E2E Chat / E2E Chat (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request_target) Has been skipped
lint-setup-go-cache / lint-setup-go-cache (pull_request) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
Required
Details
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 21s
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
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request_target) Failing after 12s
reserved-path-review / reserved-path-review (pull_request_target) Failing after 13s
lint-no-coe-on-required / lint-no-coe-on-required (pull_request) Successful in 31s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 30s
sop-checklist / all-items-acked (pull_request_target) Successful in 11s
gate-check-v3 / gate-check (pull_request_target) Successful in 19s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 41s
PR Diff Guard / PR diff guard (pull_request) Successful in 26s
qa-review / approved (pull_request_target) Failing after 20s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 37s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 38s
template-delivery-e2e / detect-changes (pull_request) Successful in 38s
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 1m44s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m22s
Required
Details
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Successful in 2m11s
CI / Platform (Go) (pull_request) Successful in 3m50s
CI / Canvas (Next.js) (pull_request) Successful in 4m10s
CI / Canvas Deploy Status (pull_request) Successful in 1s
CI / all-required (pull_request) Successful in 4s
Required
Details
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Successful in 6m30s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (pull_request) Successful in 9m42s
This pull request doesn't have enough required approvals yet. 0 of 2 official approvals granted.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/csp-img-src-pin-exact-r2-host:fix/csp-img-src-pin-exact-r2-host
git checkout fix/csp-img-src-pin-exact-r2-host
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: molecule-ai/molecule-core#3131