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:
+11
-1
@@ -17,11 +17,21 @@ node_modules
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.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
|
||||||
|
**/.env.*
|
||||||
!.env.example
|
!.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
|
# Test artifacts
|
||||||
coverage
|
coverage
|
||||||
**/coverage
|
**/coverage
|
||||||
|
|||||||
+11
-4
@@ -21,10 +21,17 @@ NEXTAUTH_SECRET=
|
|||||||
|
|
||||||
# ─── Database ────────────────────────────────────────────────────────────────
|
# ─── Database ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# REQUIRED — PostgreSQL connection string.
|
# REQUIRED when starting Docker Compose — postgres container initializes with
|
||||||
# When running with Docker Compose the app container uses the Docker-internal
|
# this password and the app container derives DATABASE_URL from it. No default
|
||||||
# host (postgres:5432); the host-level connection (for pnpm dev on the host)
|
# is shipped; set any non-empty value for local dev, use a generated secret in
|
||||||
# uses localhost:5433 (the published port).
|
# 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
|
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
|
||||||
|
|
||||||
# ─── Redis ───────────────────────────────────────────────────────────────────
|
# ─── Redis ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -323,6 +323,11 @@ jobs:
|
|||||||
# ${PGADMIN_PASSWORD:?} check fires and aborts the compose call.
|
# ${PGADMIN_PASSWORD:?} check fires and aborts the compose call.
|
||||||
# Provide a dummy value so parsing succeeds — pgadmin is never started.
|
# Provide a dummy value so parsing succeeds — pgadmin is never started.
|
||||||
PGADMIN_PASSWORD: ci-unused
|
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
|
# Tell test-server.mjs not to spin up its own postgres-test container
|
||||||
# — the e2epg job service is already running and reachable. Without
|
# — the e2epg job service is already running and reachable. Without
|
||||||
# this, test-server tries to publish 5432 on the QNAP host, which
|
# this, test-server tries to publish 5432 on the QNAP host, which
|
||||||
@@ -462,6 +467,9 @@ jobs:
|
|||||||
NEXTAUTH_URL=http://localhost:3100
|
NEXTAUTH_URL=http://localhost:3100
|
||||||
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
|
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
|
||||||
PGADMIN_PASSWORD=ci-pgadmin
|
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
|
EOF
|
||||||
|
|
||||||
- name: Tear down any stale stack & volumes
|
- name: Tear down any stale stack & volumes
|
||||||
|
|||||||
+5
-2
@@ -1,7 +1,7 @@
|
|||||||
FROM node:20-bookworm-slim AS base
|
FROM node:20-bookworm-slim AS base
|
||||||
|
|
||||||
# Prisma needs OpenSSL available during install/generate/runtime.
|
# Prisma needs OpenSSL; curl is used by HEALTHCHECK below.
|
||||||
RUN apt-get update -y && apt-get install -y openssl postgresql-client && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update -y && apt-get install -y openssl postgresql-client curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install pnpm
|
# Install pnpm
|
||||||
RUN npm install -g pnpm@9.14.2
|
RUN npm install -g pnpm@9.14.2
|
||||||
@@ -30,4 +30,7 @@ RUN pnpm --filter @capakraken/db db:generate
|
|||||||
|
|
||||||
EXPOSE 3100
|
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"]
|
CMD ["sh", "./tooling/docker/app-dev-start.sh"]
|
||||||
|
|||||||
+11
-7
@@ -47,19 +47,23 @@ ENV NODE_ENV=production
|
|||||||
# next build collects page data for /api/auth/[...nextauth] which crashes
|
# next build collects page data for /api/auth/[...nextauth] which crashes
|
||||||
# without these envs even though they are placeholders at image-build time
|
# without these envs even though they are placeholders at image-build time
|
||||||
# (real values are injected at container start). Mirrors the CI build job.
|
# (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 NEXTAUTH_URL=http://localhost:3100
|
||||||
ARG AUTH_URL=http://localhost:3100
|
ARG AUTH_URL=http://localhost:3100
|
||||||
ARG NEXTAUTH_SECRET=ci-build-placeholder-secret-minimum-32-chars
|
ARG NEXTAUTH_SECRET=ci-build-placeholder-secret-minimum-32-chars
|
||||||
ARG AUTH_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 DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder
|
||||||
ARG REDIS_URL=redis://placeholder:6379
|
ARG REDIS_URL=redis://placeholder:6379
|
||||||
ENV NEXTAUTH_URL=$NEXTAUTH_URL
|
RUN NEXTAUTH_URL="$NEXTAUTH_URL" \
|
||||||
ENV AUTH_URL=$AUTH_URL
|
AUTH_URL="$AUTH_URL" \
|
||||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
NEXTAUTH_SECRET="$NEXTAUTH_SECRET" \
|
||||||
ENV AUTH_SECRET=$AUTH_SECRET
|
AUTH_SECRET="$AUTH_SECRET" \
|
||||||
ENV DATABASE_URL=$DATABASE_URL
|
DATABASE_URL="$DATABASE_URL" \
|
||||||
ENV REDIS_URL=$REDIS_URL
|
REDIS_URL="$REDIS_URL" \
|
||||||
RUN pnpm --filter @capakraken/web build
|
pnpm --filter @capakraken/web build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 3: Migration runner
|
# Stage 3: Migration runner
|
||||||
|
|||||||
@@ -32,7 +32,21 @@ describe("runtime env validation", () => {
|
|||||||
NEXTAUTH_SECRET: "dev-secret-change-in-production",
|
NEXTAUTH_SECRET: "dev-secret-change-in-production",
|
||||||
NEXTAUTH_URL: "https://capakraken.example.com",
|
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", () => {
|
it("rejects non-https auth urls in production", () => {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const DISALLOWED_PRODUCTION_SECRETS = new Set([
|
|||||||
"change-me",
|
"change-me",
|
||||||
"default",
|
"default",
|
||||||
"secret",
|
"secret",
|
||||||
|
"ci-build-placeholder-secret-minimum-32-chars",
|
||||||
|
"ci-test-secret-minimum-32-chars-xx",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type RuntimeEnv = Partial<Record<string, string | undefined>>;
|
type RuntimeEnv = Partial<Record<string, string | undefined>>;
|
||||||
|
|||||||
+2
-2
@@ -8,7 +8,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: capakraken
|
POSTGRES_DB: capakraken
|
||||||
POSTGRES_USER: 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: >
|
command: >
|
||||||
postgres
|
postgres
|
||||||
-c log_connections=on
|
-c log_connections=on
|
||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
# Always use the Docker-internal service name. The host-level DATABASE_URL
|
# Always use the Docker-internal service name. The host-level DATABASE_URL
|
||||||
# (localhost:5433) must not bleed into the container where "localhost" is
|
# (localhost:5433) must not bleed into the container where "localhost" is
|
||||||
# the container itself, not the host.
|
# 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
|
REDIS_URL: redis://redis:6379
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)}
|
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET}
|
||||||
|
|||||||
Reference in New Issue
Block a user