security(docker): remove hardcoded dev password, stop placeholder secrets leaking into migrator image (#50)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 14:50:05 +02:00
parent e2dddd30df
commit 805bb0464f
8 changed files with 65 additions and 17 deletions
+11 -1
View File
@@ -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
+11 -4
View File
@@ -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 ───────────────────────────────────────────────────────────────────
+8
View File
@@ -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
+5 -2
View File
@@ -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"]
+11 -7
View File
@@ -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
+15 -1
View File
@@ -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", () => {
+2
View File
@@ -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<Record<string, string | undefined>>;
+2 -2
View File
@@ -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}