fix(docker): run runtime as nextjs so self-host boots under cap_drop: ALL (#737)
Self-hosted Docker image now runs fully unprivileged (USER nextjs): the entrypoint populates the .next/public tmpfs mounts and substitutes NEXT_PUBLIC_* placeholders as nextjs, so the container boots under the hardened compose (cap_drop: ALL + read_only: true) with no capabilities. Also: substitute *.json (fixes CSP connect-src in routes-manifest.json), escape sed metacharacters in brand name, healthcheck via 127.0.0.1, and a fully-self-hosted Supabase docs section.
This commit is contained in:
+76
-36
@@ -27,51 +27,91 @@ 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 writable mount points from the baked-in templates. Under
|
||||
# docker-compose's read_only:true, /app/.next and /app/public are tmpfs
|
||||
# mounts; cp populates them in RAM. Without read_only:true the directories
|
||||
# were created empty in the Dockerfile, so cp still works.
|
||||
# ─── 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
|
||||
|
||||
# Ensure Next.js's runtime cache directory is writable by the unprivileged user.
|
||||
mkdir -p /app/.next/cache
|
||||
chown -R nextjs:nodejs /app/.next/cache
|
||||
chmod 755 /app/.next/cache
|
||||
|
||||
# Substitute build-time placeholder sentinels with runtime env values.
|
||||
find /app/.next -type f \( -name '*.js' -o -name '*.html' -o -name '*.rsc' -o -name '*.meta' -o -name '*.body' \) -exec sed -i \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_URL__|${NEXT_PUBLIC_SUPABASE_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${NEXT_PUBLIC_SUPABASE_ANON_KEY}|g" \
|
||||
-e "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SELF_HOSTED__|${NEXT_PUBLIC_SELF_HOSTED:-true}|g" \
|
||||
-e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${NEXT_PUBLIC_REQUIRE_MFA:-false}|g" \
|
||||
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
|
||||
{} +
|
||||
# ─── 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"
|
||||
|
||||
# Stamp the service worker fallback notification title with the brand name.
|
||||
# public/sw.js is served as a static file (not bundled by Next), so NEXT_PUBLIC_*
|
||||
# inlining doesn't reach it.
|
||||
if [ -f /app/public/sw.js ]; then
|
||||
sed -i \
|
||||
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
|
||||
/app/public/sw.js
|
||||
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"
|
||||
}
|
||||
E_SUPABASE_URL=$(sed_esc "$NEXT_PUBLIC_SUPABASE_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_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 JS bundle immutable. A runtime RCE in the Node process
|
||||
# cannot rewrite what other users will receive. Cache stays writable so
|
||||
# Next.js can populate its image-optimization / ISR caches.
|
||||
chmod -R a-w /app/.next/static
|
||||
[ -d /app/.next/server ] && chmod -R a-w /app/.next/server
|
||||
find /app/.next -maxdepth 1 -type f -exec chmod a-w {} +
|
||||
[ -f /app/public/sw.js ] && chmod a-w /app/public/sw.js
|
||||
# ─── 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
|
||||
|
||||
# Drop privileges. The Node server runs as the unprivileged nextjs:nodejs user;
|
||||
# the now-immutable static dir is root-owned, so even a process compromise
|
||||
# inside Node cannot rewrite the served JS bundle.
|
||||
exec su-exec nextjs:nodejs "$@"
|
||||
exec "$@"
|
||||
|
||||
Reference in New Issue
Block a user