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://<host>/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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-09 21:10:13 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 982fe77f72
commit 15e5dc1a01
4 changed files with 37 additions and 1 deletions
+5
View File
@@ -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
+4
View File
@@ -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__
+12
View File
@@ -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" \
+16 -1
View File
@@ -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://<supabase-host>/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:",