harden(deploy): close public DB exposure + make JWT prod guard effective
Two HIGH deployment fixes found in the go-live review, plus two cheap backstops. HIGH 1 — Postgres was published to the public internet. The committed, auto-merged docker-compose.override.yml mapped 5432:5432 on 0.0.0.0 (and a Docker publish bypasses ufw), so a `docker compose up` on the prod host exposed the DB with the .env password. Bind the dev port to 127.0.0.1 so it's reachable only from the host's loopback even when the override lands in prod. .env.example also stops shipping `secret` as the DB password (now CHANGE_ME_*). HIGH 2 — the JWT-secret production guard was inert. APP_ENV was never set to production (defaulted to development), so the guard never ran; and it only matched one stale dev sentinel, not the `.env.example` placeholder that an operator is most likely to leave in place. Now: docker-compose.yml forces APP_ENV=production on the app service, and config.rs refuses to boot in production on any placeholder (change/example/placeholder/replace) or any secret < 64 chars. Verified: placeholder and short secrets are rejected; a 128-char secret passes. This turns "looks protected" into actually protected. Backstops: - frontend container now runs as the non-root `node` user (the backend was already hardened; the frontend wasn't). - app gets mem_limit 3g so a miss in the in-app upload RAM caps OOM-kills the container instead of the 8 GB host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
.env.example
12
.env.example
@@ -4,15 +4,21 @@ DOMAIN=my-event.example.com
|
||||
|
||||
# ── App server ────────────────────────────────────────────────────────────────
|
||||
APP_PORT=3000
|
||||
# docker-compose.yml already forces APP_ENV=production for the `app` service.
|
||||
# Only set this for a non-compose (bare cargo) run; leave unset for local dev.
|
||||
# APP_ENV=production
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
|
||||
# Set a strong password and keep it identical in DATABASE_URL and POSTGRES_PASSWORD.
|
||||
DATABASE_URL=postgres://eventsnap:CHANGE_ME_strong_db_password@db:5432/eventsnap
|
||||
POSTGRES_USER=eventsnap
|
||||
POSTGRES_PASSWORD=secret
|
||||
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
|
||||
POSTGRES_DB=eventsnap
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Generate with: openssl rand -hex 64
|
||||
# REQUIRED in production: generate with `openssl rand -hex 64` (128 hex chars).
|
||||
# The backend refuses to start in production with this placeholder or any value
|
||||
# that is short or contains change/example/placeholder/replace.
|
||||
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
|
||||
SESSION_EXPIRY_DAYS=30
|
||||
|
||||
|
||||
@@ -25,18 +25,30 @@ impl AppConfig {
|
||||
let is_prod = app_env.eq_ignore_ascii_case("production");
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
if jwt_secret == DEV_JWT_SECRET_SENTINEL {
|
||||
if is_prod {
|
||||
// A weak/placeholder signing key lets anyone forge any token (including
|
||||
// admin). Detect the known dev sentinel, the `.env.example` placeholder,
|
||||
// and anything that smells like an unreplaced template value.
|
||||
let lower = jwt_secret.to_ascii_lowercase();
|
||||
let looks_placeholder = jwt_secret == DEV_JWT_SECRET_SENTINEL
|
||||
|| lower.contains("change")
|
||||
|| lower.contains("example")
|
||||
|| lower.contains("placeholder")
|
||||
|| lower.contains("replace");
|
||||
|
||||
if is_prod {
|
||||
// Production must use a real, strong secret — no placeholders, ≥64
|
||||
// chars (an `openssl rand -hex 64` is 128 hex chars).
|
||||
if looks_placeholder || jwt_secret.len() < 64 {
|
||||
return Err(anyhow!(
|
||||
"Refusing to start in production with the well-known dev JWT_SECRET — \
|
||||
rotate it (openssl rand -hex 64)."
|
||||
"Refusing to start in production: JWT_SECRET is a placeholder or too short. \
|
||||
Generate a real one (openssl rand -hex 64) and set it in the prod environment."
|
||||
));
|
||||
}
|
||||
} else if looks_placeholder || jwt_secret.len() < 32 {
|
||||
tracing::warn!(
|
||||
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
|
||||
"JWT_SECRET looks like a dev/placeholder value — fine for local development, \
|
||||
NEVER ship this to production."
|
||||
);
|
||||
} else if jwt_secret.len() < 32 {
|
||||
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Auto-merged by `docker compose up`. Exposes Postgres for LOCAL tooling only —
|
||||
# bound to 127.0.0.1 so that even when this committed file lands on the prod host
|
||||
# the DB is NOT reachable on the public IP (a `0.0.0.0` publish would also bypass
|
||||
# ufw). Never widen this to 0.0.0.0.
|
||||
services:
|
||||
db:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
|
||||
@@ -21,6 +21,13 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
# Force production mode here (not via .env, which is easy to forget) so the
|
||||
# backend's JWT-secret guard actually fires on this deployment.
|
||||
environment:
|
||||
APP_ENV: production
|
||||
# Backstop the in-app upload RAM caps: if anything slips the per-request
|
||||
# bounds, the container is OOM-killed instead of the 8 GB host.
|
||||
mem_limit: 3g
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -16,6 +16,10 @@ COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Run as the image's built-in unprivileged `node` user (defense-in-depth). The
|
||||
# adapter-node server only reads from /app, so no chown is needed.
|
||||
USER node
|
||||
|
||||
EXPOSE 3001
|
||||
ENV PORT=3001 HOST=0.0.0.0
|
||||
CMD ["node", "build"]
|
||||
|
||||
Reference in New Issue
Block a user