From 15e5dc1a0177ab4ce2c1c0f5fc6a781894ba23bd Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:10:13 +0200 Subject: [PATCH] fix(csp): allow self-hosted Supabase Realtime WebSocket in connect-src (#954) connect-src listed the https Supabase origin plus wss://*.supabase.co, but never the wss variant of a self-hosted Supabase URL. Supabase Realtime opens wss:///realtime/v1/websocket, which CSP blocked; WebKit throws synchronously on a CSP-blocked new WebSocket(), so Safari unmounted the dashboard into the error boundary (Chromium only logs). - next.config.ts: add supabaseWsUrl (NEXT_PUBLIC_SUPABASE_WS_URL, or the Supabase URL with https to wss / http to ws) to connect-src - Dockerfile: bake a __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel, since the CSP is fixed at build time and only sed-substituted at runtime - docker-entrypoint.sh: derive the wss origin from NEXT_PUBLIC_SUPABASE_URL unless overridden, substitute the sentinel - .env.docker.example: document the optional override Hosted is unaffected: the wss form of *.supabase.co was already allowlisted, so the added token is redundant there. Fixes #893 Co-authored-by: Claude Fable 5 --- .env.docker.example | 5 +++++ Dockerfile | 4 ++++ docker-entrypoint.sh | 12 ++++++++++++ next.config.ts | 17 ++++++++++++++++- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.env.docker.example b/.env.docker.example index 1f6944f0..fc92b31b 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -6,3 +6,8 @@ CRON_SECRET=generate-a-random-secret # Self-hosted (Docker) flag: disables application-side MFA enforcement. NEXT_PUBLIC_SELF_HOSTED=true + +# Optional: WebSocket origin allowed for Supabase Realtime in the CSP. +# Defaults to NEXT_PUBLIC_SUPABASE_URL with https:// replaced by wss:// +# (http:// by ws://). Set only if Realtime is served from another origin. +# NEXT_PUBLIC_SUPABASE_WS_URL=wss://your-project.supabase.co diff --git a/Dockerfile b/Dockerfile index 37e5cb6a..c46ffa25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,10 @@ COPY docker/extensions.${EXTENSIONS_PRESET}.json ./extensions.config.json # These get replaced at runtime by docker-entrypoint.sh so the image # is generic and reusable across different Supabase projects. ENV NEXT_PUBLIC_SUPABASE_URL=__NEXT_PUBLIC_SUPABASE_URL__ +# Realtime WebSocket origin for the CSP. Must be its own sentinel: the CSP is +# baked into the build output, so the entrypoint cannot derive wss:// from the +# already-substituted https URL after the fact (issue #893). +ENV NEXT_PUBLIC_SUPABASE_WS_URL=__NEXT_PUBLIC_SUPABASE_WS_URL__ ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=__NEXT_PUBLIC_SUPABASE_ANON_KEY__ ENV NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__ ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=__NEXT_PUBLIC_VAPID_PUBLIC_KEY__ diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 700ea0fc..6bcd0171 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -75,7 +75,18 @@ if [ -n "$SUBST_PATHS" ]; then v=${v//|/\\|} printf %s "$v" } + # Realtime WebSocket origin for the CSP (issue #893): derive from the + # Supabase URL unless explicitly overridden. https:// becomes wss://; + # http:// becomes ws:// for plain-HTTP local installs. Without this token + # in connect-src, Supabase Realtime's WebSocket is CSP-blocked on + # self-hosted installs and WebKit crashes the dashboard. + if [ -z "${NEXT_PUBLIC_SUPABASE_WS_URL:-}" ]; then + NEXT_PUBLIC_SUPABASE_WS_URL=$(printf %s "$NEXT_PUBLIC_SUPABASE_URL" \ + | sed -e 's|^https://|wss://|' -e 's|^http://|ws://|') + fi + E_SUPABASE_URL=$(sed_esc "$NEXT_PUBLIC_SUPABASE_URL") + E_SUPABASE_WS_URL=$(sed_esc "$NEXT_PUBLIC_SUPABASE_WS_URL") E_SUPABASE_ANON_KEY=$(sed_esc "$NEXT_PUBLIC_SUPABASE_ANON_KEY") E_APP_URL=$(sed_esc "$NEXT_PUBLIC_APP_URL") E_VAPID_PUBLIC_KEY=$(sed_esc "${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}") @@ -96,6 +107,7 @@ if [ -n "$SUBST_PATHS" ]; then | tr '\n' '\0' \ | xargs -0 -r sed -i \ -e "s|__NEXT_PUBLIC_SUPABASE_URL__|${E_SUPABASE_URL}|g" \ + -e "s|__NEXT_PUBLIC_SUPABASE_WS_URL__|${E_SUPABASE_WS_URL}|g" \ -e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${E_SUPABASE_ANON_KEY}|g" \ -e "s|__NEXT_PUBLIC_APP_URL__|${E_APP_URL}|g" \ -e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${E_VAPID_PUBLIC_KEY}|g" \ diff --git a/next.config.ts b/next.config.ts index e4cbcec0..033a1be1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,6 +11,21 @@ const isDev = process.env.NODE_ENV === "development"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; +// WebSocket origin for Supabase Realtime. Hosted projects are covered by the +// wss://*.supabase.co wildcard below, but a SELF-HOSTED Supabase URL is not: +// Realtime opens wss:///realtime/v1/websocket, and WebKit +// throws synchronously on a CSP-blocked `new WebSocket()`, unmounting the +// dashboard into the error boundary (issue #893). The Docker image bakes the +// __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel at build time and +// docker-entrypoint.sh substitutes the real value at runtime (a build-time +// https-to-wss replace would only rewrite the sentinel); the fallback derives +// wss:/ws: from the https/http URL for non-Docker builds where the real URL +// is present at build time. Empty supabaseUrl stays empty, mirroring how +// ${supabaseUrl} is interpolated below (extra whitespace is valid in CSP). +const supabaseWsUrl = + process.env.NEXT_PUBLIC_SUPABASE_WS_URL ?? + supabaseUrl.replace(/^http(s?):/, "ws$1:"); + const cspDirectives = [ "default-src 'self'", // Recapt: scoped to the two specific hosts the SDK actually contacts: @@ -18,7 +33,7 @@ const cspDirectives = [ // ingestion. The previous wildcard (`https://*.recapt.app`) allowed // exfiltration to any subdomain of recapt.app and is intentionally // narrowed. - `connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`, + `connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`, `style-src 'self' 'unsafe-inline' https://*.enablebanking.com`, `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`, "img-src 'self' data: blob: https:",