15e5dc1a01
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>
130 lines
6.5 KiB
Bash
Executable File
130 lines
6.5 KiB
Bash
Executable File
#!/bin/sh
|
|
set -e
|
|
|
|
# ─── Validate required environment variables ───
|
|
missing=""
|
|
for var in NEXT_PUBLIC_SUPABASE_URL NEXT_PUBLIC_SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY NEXT_PUBLIC_APP_URL CRON_SECRET; do
|
|
eval val=\$$var
|
|
if [ -z "$val" ]; then
|
|
missing="$missing - $var\n"
|
|
fi
|
|
done
|
|
|
|
if [ -n "$missing" ]; then
|
|
printf "ERROR: Missing required environment variables:\n%b\nSee .env.docker.example for reference.\n" "$missing" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Warn if placeholder values are still set
|
|
placeholders_found=""
|
|
case "$NEXT_PUBLIC_SUPABASE_ANON_KEY" in *your-anon-key*) placeholders_found="$placeholders_found - NEXT_PUBLIC_SUPABASE_ANON_KEY\n" ;; esac
|
|
case "$SUPABASE_SERVICE_ROLE_KEY" in *your-service-role-key*) placeholders_found="$placeholders_found - SUPABASE_SERVICE_ROLE_KEY\n" ;; esac
|
|
case "$NEXT_PUBLIC_SUPABASE_URL" in *your-project*) placeholders_found="$placeholders_found - NEXT_PUBLIC_SUPABASE_URL\n" ;; esac
|
|
case "$NEXT_PUBLIC_APP_URL" in *your-domain*) placeholders_found="$placeholders_found - NEXT_PUBLIC_APP_URL\n" ;; esac
|
|
case "$CRON_SECRET" in *generate-a-random-secret*) placeholders_found="$placeholders_found - CRON_SECRET\n" ;; esac
|
|
|
|
if [ -n "$placeholders_found" ]; then
|
|
printf "WARNING: These variables appear to contain placeholder values:\n%bPlease set them to real values before running in production.\n" "$placeholders_found" >&2
|
|
fi
|
|
|
|
# ─── Populate the writable tmpfs mounts from the baked-in templates ───
|
|
# Under docker-compose's `read_only: true`, /app/.next and /app/public are
|
|
# tmpfs mounts owned by nextjs (uid=1001); this cp fills them in RAM at every
|
|
# startup. /app/server.js, /app/node_modules and /app/package.json stay on the
|
|
# read-only image layer. Running as the unprivileged nextjs user means no
|
|
# CAP_CHOWN / CAP_SETUID is needed, so the container works under `cap_drop: ALL`.
|
|
# Without read_only:true the mount points were created empty in the Dockerfile,
|
|
# so the same cp still works.
|
|
#
|
|
# On a non-tmpfs restart the target dirs persist with their write bits removed
|
|
# (see the immutability step below), so restore owner-write first: otherwise the
|
|
# unprivileged cp -R below fails under `set -e`. Under tmpfs the dirs are empty
|
|
# each start, so this is a no-op.
|
|
chmod -R u+w /app/.next /app/public 2>/dev/null || true
|
|
if [ -d /opt/gnubok-template/.next ]; then
|
|
cp -R /opt/gnubok-template/.next/. /app/.next/
|
|
fi
|
|
if [ -d /opt/gnubok-template/public ]; then
|
|
cp -R /opt/gnubok-template/public/. /app/public/
|
|
fi
|
|
mkdir -p /app/.next/cache
|
|
|
|
# ─── Replace build-time placeholder sentinels with runtime env vars ───
|
|
# Substitution covers /app/.next (client static + server bundles + manifests;
|
|
# the manifests at .next/ root hold the CSP/headers from next.config.ts) and
|
|
# /app/public (sw.js: the service worker is served raw, so Next's build-time
|
|
# inlining doesn't reach it). server.js needs no substitution and lives on the
|
|
# read-only image layer, so it is deliberately excluded.
|
|
#
|
|
# `sed -i` rewrites every file it touches, so we prefilter with `grep -l` and
|
|
# only sed files that actually contain a placeholder. busybox grep has no -Z,
|
|
# so we rely on Next.js build outputs not having newlines in filenames.
|
|
SUBST_PATHS=""
|
|
[ -d /app/.next ] && SUBST_PATHS="$SUBST_PATHS /app/.next"
|
|
[ -d /app/public ] && SUBST_PATHS="$SUBST_PATHS /app/public"
|
|
|
|
if [ -n "$SUBST_PATHS" ]; then
|
|
# Escape sed replacement metacharacters (backslash, & whole-match, and the |
|
|
# delimiter) so a value like "Acme & Co." (legal in NEXT_PUBLIC_BRANDING_APP_NAME)
|
|
# or one containing | can't corrupt the output or break the sed command.
|
|
# busybox-ash-compatible parameter expansion (verified on busybox 1.37).
|
|
sed_esc() {
|
|
v=$1
|
|
v=${v//\\/\\\\}
|
|
v=${v//&/\\&}
|
|
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:-}")
|
|
E_SELF_HOSTED=$(sed_esc "${NEXT_PUBLIC_SELF_HOSTED:-true}")
|
|
E_REQUIRE_MFA=$(sed_esc "${NEXT_PUBLIC_REQUIRE_MFA:-false}")
|
|
E_BRANDING_APP_NAME=$(sed_esc "${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}")
|
|
|
|
# File-type coverage:
|
|
# *.js: client + server bundles
|
|
# *.json: routes-manifest.json (CSP/headers), build-manifest.json, etc.
|
|
# *.html: prerendered pages (e.g. /login title contains BRANDING_APP_NAME)
|
|
# *.rsc: RSC payloads with the same inlined values
|
|
# *.body: metadata-route bodies, e.g. manifest.webmanifest.body (PWA name)
|
|
# shellcheck disable=SC2086
|
|
find $SUBST_PATHS -type f \
|
|
\( -name '*.js' -o -name '*.json' -o -name '*.html' -o -name '*.rsc' -o -name '*.body' \) \
|
|
-exec grep -l "__NEXT_PUBLIC_" {} + 2>/dev/null \
|
|
| 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" \
|
|
-e "s|__NEXT_PUBLIC_SELF_HOSTED__|${E_SELF_HOSTED}|g" \
|
|
-e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${E_REQUIRE_MFA}|g" \
|
|
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${E_BRANDING_APP_NAME}|g"
|
|
fi
|
|
|
|
# ─── Make the served bundle immutable (defense in depth) ───
|
|
# nextjs owns these tmpfs files, so a compromised Node process could chmod them
|
|
# back; dropping the write bits still raises the bar against casual tampering.
|
|
# (Root-owned immutability isn't possible without running the entrypoint as
|
|
# root, which would reintroduce the CAP_CHOWN/CAP_SETUID requirement.)
|
|
chmod -R a-w /app/.next/static 2>/dev/null || true
|
|
[ -d /app/.next/server ] && chmod -R a-w /app/.next/server 2>/dev/null || true
|
|
find /app/.next -maxdepth 1 -type f -exec chmod a-w {} + 2>/dev/null || true
|
|
[ -f /app/public/sw.js ] && chmod a-w /app/public/sw.js 2>/dev/null || true
|
|
|
|
exec "$@"
|