diff --git a/.env.example b/.env.example index b204567..8037bf4 100644 --- a/.env.example +++ b/.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 diff --git a/backend/src/config.rs b/backend/src/config.rs index 88363fc..2d4f9fc 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -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 { diff --git a/docker-compose.override.yml b/docker-compose.override.yml index b15b9e4..8988dd7 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 6919e38..e269cdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5223746..595642c 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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"]