From 805bb0464fb204ab938dba95bf3f3e9cff85dc5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 17 Apr 2026 14:50:05 +0200 Subject: [PATCH] security(docker): remove hardcoded dev password, stop placeholder secrets leaking into migrator image (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: require ${POSTGRES_PASSWORD} for the postgres service and the app container's DATABASE_URL. No default — compose refuses to start without it, mirroring the existing PGADMIN_PASSWORD pattern. - Dockerfile.prod: move auth/db ENV assignments from persistent ENV lines into an inline env prefix on the `pnpm build` RUN step. Placeholders are still available to `next build` but no longer persist in the builder layer or in the published migrator image (which is FROM builder). - Dockerfile.dev: add HEALTHCHECK against /api/health and install curl for it. - .dockerignore: cover nested **/.env*, **/*.pem, **/*.key, **/secrets/**. - runtime-env.ts: add the CI build placeholder strings to the disallowed-secret set so a misconfigured prod deploy using the baked-in ARG defaults fails startup instead of silently running with a known-bad secret. - .env.example: document the new POSTGRES_PASSWORD requirement. - CI: write POSTGRES_PASSWORD into the Fresh-Linux Docker Deploy job's .env (must match docker-compose.ci.yml's hardcoded DATABASE_URL), and provide a dummy value in the E2E job where compose validates all services' interp. Co-Authored-By: Claude Opus 4.7 --- .dockerignore | 12 +++++++++++- .env.example | 15 +++++++++++---- .github/workflows/ci.yml | 8 ++++++++ Dockerfile.dev | 7 +++++-- Dockerfile.prod | 18 +++++++++++------- apps/web/src/server/runtime-env.test.ts | 16 +++++++++++++++- apps/web/src/server/runtime-env.ts | 2 ++ docker-compose.yml | 4 ++-- 8 files changed, 65 insertions(+), 17 deletions(-) diff --git a/.dockerignore b/.dockerignore index 3d104ac..7381a3f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,11 +17,21 @@ node_modules *.swp *.swo -# Environment files (injected at runtime) +# Environment files (injected at runtime). Glob variants catch nested +# .env, .env.local, etc. inside any package directory. .env .env.* +**/.env +**/.env.* !.env.example +# Private keys, certificates, and any secrets-like directory. Defence in +# depth against accidentally bind-mounting or COPYing these in. +**/*.pem +**/*.key +**/secrets +**/secrets/** + # Test artifacts coverage **/coverage diff --git a/.env.example b/.env.example index f005187..0b7daa8 100644 --- a/.env.example +++ b/.env.example @@ -21,10 +21,17 @@ NEXTAUTH_SECRET= # ─── Database ──────────────────────────────────────────────────────────────── -# REQUIRED — PostgreSQL connection string. -# When running with Docker Compose the app container uses the Docker-internal -# host (postgres:5432); the host-level connection (for pnpm dev on the host) -# uses localhost:5433 (the published port). +# REQUIRED when starting Docker Compose — postgres container initializes with +# this password and the app container derives DATABASE_URL from it. No default +# is shipped; set any non-empty value for local dev, use a generated secret in +# any shared or production environment. +# Generate one with: openssl rand -hex 32 +POSTGRES_PASSWORD= + +# REQUIRED — PostgreSQL connection string used by `pnpm dev` running on the +# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app +# container this variable is overridden by docker-compose.yml (which routes +# to the postgres service name on the internal network). DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken # ─── Redis ─────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e24d84c..249df04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -323,6 +323,11 @@ jobs: # ${PGADMIN_PASSWORD:?} check fires and aborts the compose call. # Provide a dummy value so parsing succeeds — pgadmin is never started. PGADMIN_PASSWORD: ci-unused + # Same reason as PGADMIN_PASSWORD: docker compose validates env + # interpolation across all services, including postgres (which has + # ${POSTGRES_PASSWORD:?}). Dummy value — postgres service is not used + # here (the `e2epg` GH Actions service container is). + POSTGRES_PASSWORD: ci-unused # Tell test-server.mjs not to spin up its own postgres-test container # — the e2epg job service is already running and reachable. Without # this, test-server tries to publish 5432 on the QNAP host, which @@ -462,6 +467,9 @@ jobs: NEXTAUTH_URL=http://localhost:3100 NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx PGADMIN_PASSWORD=ci-pgadmin + # Must match the password baked into docker-compose.ci.yml's + # DATABASE_URL override (capakraken_dev). + POSTGRES_PASSWORD=capakraken_dev EOF - name: Tear down any stale stack & volumes diff --git a/Dockerfile.dev b/Dockerfile.dev index 36ec528..e9799dd 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,7 @@ FROM node:20-bookworm-slim AS base -# Prisma needs OpenSSL available during install/generate/runtime. -RUN apt-get update -y && apt-get install -y openssl postgresql-client && rm -rf /var/lib/apt/lists/* +# Prisma needs OpenSSL; curl is used by HEALTHCHECK below. +RUN apt-get update -y && apt-get install -y openssl postgresql-client curl && rm -rf /var/lib/apt/lists/* # Install pnpm RUN npm install -g pnpm@9.14.2 @@ -30,4 +30,7 @@ RUN pnpm --filter @capakraken/db db:generate EXPOSE 3100 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl -fsS http://localhost:3100/api/health || exit 1 + CMD ["sh", "./tooling/docker/app-dev-start.sh"] diff --git a/Dockerfile.prod b/Dockerfile.prod index e0d112b..9787e2e 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -47,19 +47,23 @@ ENV NODE_ENV=production # next build collects page data for /api/auth/[...nextauth] which crashes # without these envs even though they are placeholders at image-build time # (real values are injected at container start). Mirrors the CI build job. +# +# IMPORTANT: pass these only as inline env on the RUN step, not via `ENV`. +# `ENV` persists the placeholder into the image layer — scanned as a leaked +# secret and inherited by the `migrator` stage (which is published). ARG NEXTAUTH_URL=http://localhost:3100 ARG AUTH_URL=http://localhost:3100 ARG NEXTAUTH_SECRET=ci-build-placeholder-secret-minimum-32-chars ARG AUTH_SECRET=ci-build-placeholder-secret-minimum-32-chars ARG DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder ARG REDIS_URL=redis://placeholder:6379 -ENV NEXTAUTH_URL=$NEXTAUTH_URL -ENV AUTH_URL=$AUTH_URL -ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET -ENV AUTH_SECRET=$AUTH_SECRET -ENV DATABASE_URL=$DATABASE_URL -ENV REDIS_URL=$REDIS_URL -RUN pnpm --filter @capakraken/web build +RUN NEXTAUTH_URL="$NEXTAUTH_URL" \ + AUTH_URL="$AUTH_URL" \ + NEXTAUTH_SECRET="$NEXTAUTH_SECRET" \ + AUTH_SECRET="$AUTH_SECRET" \ + DATABASE_URL="$DATABASE_URL" \ + REDIS_URL="$REDIS_URL" \ + pnpm --filter @capakraken/web build # ============================================================ # Stage 3: Migration runner diff --git a/apps/web/src/server/runtime-env.test.ts b/apps/web/src/server/runtime-env.test.ts index 8c258ef..df134a1 100644 --- a/apps/web/src/server/runtime-env.test.ts +++ b/apps/web/src/server/runtime-env.test.ts @@ -32,7 +32,21 @@ describe("runtime env validation", () => { NEXTAUTH_SECRET: "dev-secret-change-in-production", NEXTAUTH_URL: "https://capakraken.example.com", }), - ).toContain("AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production."); + ).toContain( + "AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.", + ); + }); + + it("rejects the CI build-time placeholder that leaks from Dockerfile ARG default", () => { + expect( + getRuntimeEnvViolations({ + NODE_ENV: "production", + NEXTAUTH_SECRET: "ci-build-placeholder-secret-minimum-32-chars", + NEXTAUTH_URL: "https://capakraken.example.com", + }), + ).toContain( + "AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.", + ); }); it("rejects non-https auth urls in production", () => { diff --git a/apps/web/src/server/runtime-env.ts b/apps/web/src/server/runtime-env.ts index e997de4..8687b07 100644 --- a/apps/web/src/server/runtime-env.ts +++ b/apps/web/src/server/runtime-env.ts @@ -6,6 +6,8 @@ const DISALLOWED_PRODUCTION_SECRETS = new Set([ "change-me", "default", "secret", + "ci-build-placeholder-secret-minimum-32-chars", + "ci-test-secret-minimum-32-chars-xx", ]); type RuntimeEnv = Partial>; diff --git a/docker-compose.yml b/docker-compose.yml index ad613aa..d83cbdc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: environment: POSTGRES_DB: capakraken POSTGRES_USER: capakraken - POSTGRES_PASSWORD: capakraken_dev + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (any non-empty value for local dev)} command: > postgres -c log_connections=on @@ -61,7 +61,7 @@ services: # Always use the Docker-internal service name. The host-level DATABASE_URL # (localhost:5433) must not bleed into the container where "localhost" is # the container itself, not the host. - DATABASE_URL: postgresql://capakraken:capakraken_dev@postgres:5432/capakraken + DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken REDIS_URL: redis://redis:6379 NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET}