From 6e8f062189a2edad0ff44b71b323d942be5f4c80 Mon Sep 17 00:00:00 2001 From: core-devops Date: Sun, 21 Jun 2026 08:37:55 -0700 Subject: [PATCH] fix(csp): bake exact generated-image R2 host into tenant img-src pin (#3128 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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) --- .gitea/workflows/publish-canvas-image.yml | 8 ++ .../publish-workspace-server-image.yml | 13 ++ canvas/Dockerfile | 8 ++ workspace-server/Dockerfile.tenant | 12 ++ .../csp_imgsrc_build_wiring_test.go | 114 ++++++++++++++++++ 5 files changed, 155 insertions(+) create mode 100644 workspace-server/internal/middleware/csp_imgsrc_build_wiring_test.go diff --git a/.gitea/workflows/publish-canvas-image.yml b/.gitea/workflows/publish-canvas-image.yml index 5bb7f2bca..08cc2d556 100644 --- a/.gitea/workflows/publish-canvas-image.yml +++ b/.gitea/workflows/publish-canvas-image.yml @@ -192,12 +192,19 @@ jobs: SECRET_PLATFORM_URL: ${{ secrets.CANVAS_PLATFORM_URL }} INPUT_WS_URL: ${{ github.event.inputs.ws_url }} SECRET_WS_URL: ${{ secrets.CANVAS_WS_URL }} + # Exact generated-image R2 origin baked into the CSP img-src at build + # time (config, not a secret — it is a public CSP directive value). + # Falls back to the production-derived host. prod + staging share the + # R2 bucket + account so a single value is correct for both. + VAR_IMAGE_GEN_R2_HOST: ${{ vars.IMAGE_GEN_R2_HOST }} run: | PLATFORM_URL="${INPUT_PLATFORM_URL:-${SECRET_PLATFORM_URL:-http://localhost:8080}}" WS_URL="${INPUT_WS_URL:-${SECRET_WS_URL:-ws://localhost:8080/ws}}" + IMAGE_GEN_R2_HOST="${VAR_IMAGE_GEN_R2_HOST:-https://molecule-workspace-data.bfa4e604e168a938e565600b27e2828c.r2.cloudflarestorage.com}" echo "platform_url=${PLATFORM_URL}" >> "$GITHUB_OUTPUT" echo "ws_url=${WS_URL}" >> "$GITHUB_OUTPUT" + echo "image_gen_r2_host=${IMAGE_GEN_R2_HOST}" >> "$GITHUB_OUTPUT" - name: Build & push canvas image to ECR uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 @@ -209,6 +216,7 @@ jobs: build-args: | NEXT_PUBLIC_PLATFORM_URL=${{ steps.build_args.outputs.platform_url }} NEXT_PUBLIC_WS_URL=${{ steps.build_args.outputs.ws_url }} + NEXT_PUBLIC_IMAGE_GEN_R2_HOST=${{ steps.build_args.outputs.image_gen_r2_host }} # Bake the merge SHA into the image so /api/buildinfo reports the # served canvas SHA (core#2235). Mirrors how the platform image # surfaces GIT_SHA at /buildinfo. Full 40-char SHA (not the diff --git a/.gitea/workflows/publish-workspace-server-image.yml b/.gitea/workflows/publish-workspace-server-image.yml index 6fa3c748b..2090cfa43 100644 --- a/.gitea/workflows/publish-workspace-server-image.yml +++ b/.gitea/workflows/publish-workspace-server-image.yml @@ -271,6 +271,18 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-2 + # Exact generated-image R2 origin baked into the canvas CSP img-src + # at BUILD time (NEXT_PUBLIC_* is inlined by Next.js, not read at + # runtime). This single image serves BOTH prod and staging tenants + # (pushed to both ECR accounts below), and both environments share + # the same R2 bucket + Cloudflare account, so one baked value is + # correct for both. Sourced from the IMAGE_GEN_R2_HOST repo variable + # (config, not a secret — it is a public CSP directive value); + # defaults to the production-derived host + # `.` + # if the variable is unset. Empty/unset → canvas buildImgSrc() falls + # back to the wildcard (functional, just looser). + IMAGE_GEN_R2_HOST: ${{ vars.IMAGE_GEN_R2_HOST || 'https://molecule-workspace-data.bfa4e604e168a938e565600b27e2828c.r2.cloudflarestorage.com' }} run: | set -euo pipefail ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}" @@ -321,6 +333,7 @@ jobs: --provenance=false \ --sbom=false \ --build-arg NEXT_PUBLIC_PLATFORM_URL= \ + --build-arg NEXT_PUBLIC_IMAGE_GEN_R2_HOST="${IMAGE_GEN_R2_HOST}" \ --build-arg GIT_SHA="${GIT_SHA}" \ --label "org.opencontainers.image.source=https://git.moleculesai.app/molecule-ai/${REPO}" \ --label "org.opencontainers.image.revision=${GIT_SHA}" \ diff --git a/canvas/Dockerfile b/canvas/Dockerfile index 975cb2bf8..4daa3c172 100644 --- a/canvas/Dockerfile +++ b/canvas/Dockerfile @@ -10,9 +10,17 @@ COPY . . ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080 ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws ARG NEXT_PUBLIC_ADMIN_TOKEN= +# NEXT_PUBLIC_IMAGE_GEN_R2_HOST pins the exact generated-image R2 origin into +# the CSP img-src (buildImgSrc() in src/middleware.ts). Inlined by Next.js at +# build time. Unset → buildImgSrc() falls back to the wildcard. The combined +# tenant image (workspace-server/Dockerfile.tenant) is the primary serving +# path; this standalone canvas image (docker-compose / self-host) gets it too +# for parity. +ARG NEXT_PUBLIC_IMAGE_GEN_R2_HOST= ENV NEXT_PUBLIC_PLATFORM_URL=$NEXT_PUBLIC_PLATFORM_URL ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN +ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST=$NEXT_PUBLIC_IMAGE_GEN_R2_HOST RUN npm run build FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f diff --git a/workspace-server/Dockerfile.tenant b/workspace-server/Dockerfile.tenant index 69a054492..97b25fcd2 100644 --- a/workspace-server/Dockerfile.tenant +++ b/workspace-server/Dockerfile.tenant @@ -90,8 +90,20 @@ RUN npm install COPY canvas/ . ARG NEXT_PUBLIC_PLATFORM_URL="" ARG NEXT_PUBLIC_WS_URL="" +# NEXT_PUBLIC_IMAGE_GEN_R2_HOST pins the EXACT generated-image R2 origin into +# the canvas CSP img-src (buildImgSrc() in canvas/src/middleware.ts). Next.js +# inlines NEXT_PUBLIC_* at BUILD time, so it MUST be present here, not at +# runtime. Unset → buildImgSrc() falls back to the wildcard +# https://*.r2.cloudflarestorage.com (still functional, just looser). CI passes +# the exact `..r2.cloudflarestorage.com` host, derived +# from the same Infisical SSOT the CP uses (MOLECULE_IMAGE_GEN_BUCKET / +# MOLECULE_IMAGE_GEN_ENDPOINT, with the MOLECULE_WORKSPACE_DATA_* fallbacks); +# see publish-workspace-server-image.yml. prod + staging share the bucket + +# R2 account, so a single baked value is correct for both. +ARG NEXT_PUBLIC_IMAGE_GEN_R2_HOST="" ENV NEXT_PUBLIC_PLATFORM_URL=$NEXT_PUBLIC_PLATFORM_URL ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST=$NEXT_PUBLIC_IMAGE_GEN_R2_HOST RUN npm run build # ── Stage 3: Runtime ────────────────────────────────────────────────── diff --git a/workspace-server/internal/middleware/csp_imgsrc_build_wiring_test.go b/workspace-server/internal/middleware/csp_imgsrc_build_wiring_test.go new file mode 100644 index 000000000..ac89d7bee --- /dev/null +++ b/workspace-server/internal/middleware/csp_imgsrc_build_wiring_test.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// csp_imgsrc_build_wiring_test.go — guards that the tenant IMAGE BUILD actually +// wires the exact generated-image R2 host into the canvas CSP img-src pin +// (NEXT_PUBLIC_IMAGE_GEN_R2_HOST), not just that the emitters SUPPORT a pin. +// +// Why this lives here: securityheaders_test.go proves the Go emitter renders the +// pinned host when MOLECULE_IMAGE_GEN_R2_HOST is set at runtime, and the canvas +// vitest suite proves buildImgSrc() renders the pinned host when +// NEXT_PUBLIC_IMAGE_GEN_R2_HOST is set. But NEXT_PUBLIC_* is inlined by Next.js +// at BUILD time — if the Dockerfile / CI never set it, the deployed tenant UI +// silently emits the wildcard regardless of the (correct) emitter logic. These +// tests are the missing link: they assert the build plumbing exists. +// +// Follow-up to #3128 (which added the pin support + the wildcard default). + +// exactPinnedR2Host is the derived prod/staging generated-image origin: +// +// . +// +// prod and staging share the R2 bucket + Cloudflare account, so this single +// value is correct for both (no cross-env mismatch). It is the CI default when +// the IMAGE_GEN_R2_HOST repo variable is unset. +const exactPinnedR2Host = "https://molecule-workspace-data.bfa4e604e168a938e565600b27e2828c.r2.cloudflarestorage.com" + +// repoRoot walks up from the test's working directory (the package dir) to the +// molecule-core repo root by locating the canvas/ dir + .gitea/ dir. Returns "" +// (and the test skips) if not found — keeps the test robust to vendored / split +// checkouts where the repo tree isn't fully present. +func repoRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for i := 0; i < 8; i++ { + if fi, err := os.Stat(filepath.Join(dir, "workspace-server", "Dockerfile.tenant")); err == nil && !fi.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +// TestTenantDockerfile_PinsImageGenR2Host asserts Dockerfile.tenant's canvas +// build stage declares BOTH the ARG and the ENV for NEXT_PUBLIC_IMAGE_GEN_R2_HOST +// before `npm run build`, so the value (passed as a build-arg by CI) is inlined +// into the canvas bundle's CSP at build time. Without the ENV line the ARG is +// inert and the bundle ships the wildcard. +func TestTenantDockerfile_PinsImageGenR2Host(t *testing.T) { + root := repoRoot() + if root == "" { + t.Skip("repo root not found from test cwd; skipping build-wiring guard") + } + path := filepath.Join(root, "workspace-server", "Dockerfile.tenant") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + df := string(b) + + if !strings.Contains(df, "ARG NEXT_PUBLIC_IMAGE_GEN_R2_HOST") { + t.Errorf("Dockerfile.tenant missing `ARG NEXT_PUBLIC_IMAGE_GEN_R2_HOST` in the canvas build stage") + } + if !strings.Contains(df, "ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST=$NEXT_PUBLIC_IMAGE_GEN_R2_HOST") { + t.Errorf("Dockerfile.tenant missing `ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST=$NEXT_PUBLIC_IMAGE_GEN_R2_HOST` — the ARG is inert without it (Next.js inlines the ENV at build time)") + } + + // The ENV must be set BEFORE `npm run build` or Next.js won't inline it. + envIdx := strings.Index(df, "ENV NEXT_PUBLIC_IMAGE_GEN_R2_HOST=") + buildIdx := strings.LastIndex(df, "RUN npm run build") + if envIdx < 0 || buildIdx < 0 || envIdx > buildIdx { + t.Errorf("Dockerfile.tenant must set NEXT_PUBLIC_IMAGE_GEN_R2_HOST before `RUN npm run build` (env=%d build=%d)", envIdx, buildIdx) + } +} + +// TestPublishWorkflow_PassesExactImageGenR2Host asserts the tenant-image publish +// workflow passes the exact (non-wildcard) host as the canvas build-arg, with +// the production-derived host as the default. This is what makes the deployed +// prod + staging tenant UIs emit the EXACT host instead of the wildcard. +func TestPublishWorkflow_PassesExactImageGenR2Host(t *testing.T) { + root := repoRoot() + if root == "" { + t.Skip("repo root not found from test cwd; skipping CI-wiring guard") + } + path := filepath.Join(root, ".gitea", "workflows", "publish-workspace-server-image.yml") + b, err := os.ReadFile(path) + if err != nil { + t.Skipf("publish workflow not present at %s (split checkout?): %v", path, err) + } + wf := string(b) + + if !strings.Contains(wf, "--build-arg NEXT_PUBLIC_IMAGE_GEN_R2_HOST=") { + t.Errorf("publish-workspace-server-image.yml does not pass --build-arg NEXT_PUBLIC_IMAGE_GEN_R2_HOST to the tenant build") + } + if !strings.Contains(wf, exactPinnedR2Host) { + t.Errorf("publish-workspace-server-image.yml missing the exact default host %q (must be the non-wildcard prod/staging origin)", exactPinnedR2Host) + } + // Defense: the default baked into CI must NOT be the wildcard. + if strings.Contains(wf, "IMAGE_GEN_R2_HOST: ${{ vars.IMAGE_GEN_R2_HOST || 'https://*.r2.cloudflarestorage.com'") { + t.Errorf("publish workflow defaults the build-arg to the wildcard — defeats the pin") + } +} -- 2.52.0