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
5 changed files with 155 additions and 0 deletions
@@ -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
@@ -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
# `<MOLECULE_IMAGE_GEN_BUCKET>.<MOLECULE_IMAGE_GEN_ENDPOINT 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}" \
+8
View File
@@ -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
+12
View File
@@ -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 `<bucket>.<cf-account-hash>.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 ──────────────────────────────────────────────────
@@ -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:
//
// <MOLECULE_IMAGE_GEN_BUCKET=molecule-workspace-data>.<MOLECULE_IMAGE_GEN_ENDPOINT
// host = bfa4e604e168a938e565600b27e2828c.r2.cloudflarestorage.com>
//
// 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")
}
}