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..d49b85c 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 ─────────────────────────────────────────────────────────────────── @@ -90,6 +97,15 @@ PGADMIN_PASSWORD= # If not set, Sentry is disabled (SDK is installed but sends nothing). # NEXT_PUBLIC_SENTRY_DSN= +# ─── Dispo import ──────────────────────────────────────────────────────────── + +# Absolute directory that dispo .xlsx workbook imports must live under. The +# tRPC surface only accepts relative paths and the runtime reader re-validates +# that any resolved path remains inside this directory; this prevents an +# admin (or compromised admin token) from pointing the parser at arbitrary +# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset. +# DISPO_IMPORT_DIR=/var/lib/capakraken/imports + # ─── Testing (never enable in production) ──────────────────────────────────── # Disables rate limiting and session tracking during end-to-end tests. 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/package.json b/apps/web/package.json index c263aa0..b98fa23 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,7 +31,7 @@ "@trpc/server": "^11.0.0", "@types/qrcode": "^1.5.6", "clsx": "^2.1.1", - "dompurify": "^3.3.3", + "dompurify": "^3.4.0", "exceljs": "^4.4.0", "framer-motion": "^12.38.0", "next": "^15.5.15", diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 1c9c1b0..5b8461b 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -2,9 +2,21 @@ import { createTRPCContext, loadRoleDefaults } from "@capakraken/api"; import { appRouter } from "@capakraken/api/router"; import { prisma } from "@capakraken/db"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { getToken } from "next-auth/jwt"; import type { NextRequest } from "next/server"; import { auth } from "~/server/auth.js"; +function extractClientIp(req: NextRequest): string | null { + const forwarded = req.headers.get("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + const realIp = req.headers.get("x-real-ip"); + if (realIp) return realIp.trim(); + return null; +} + // Throttle lastActiveAt updates: max once per 60s per user const lastActiveCache = new Map(); const ACTIVITY_THROTTLE_MS = 60_000; @@ -14,10 +26,14 @@ function trackActivity(userId: string) { const last = lastActiveCache.get(userId) ?? 0; if (now - last < ACTIVITY_THROTTLE_MS) return; lastActiveCache.set(userId, now); - prisma.user.update({ - where: { id: userId }, - data: { lastActiveAt: new Date(now) }, - }).catch(() => {/* ignore */}); + prisma.user + .update({ + where: { id: userId }, + data: { lastActiveAt: new Date(now) }, + }) + .catch(() => { + /* ignore */ + }); } const handler = async (req: NextRequest) => { @@ -27,9 +43,19 @@ const handler = async (req: NextRequest) => { // Sessions kicked by concurrent-session limits or manual logout are rejected immediately. // Fail-open: if the table doesn't exist yet (pending migration) the check is skipped. // In E2E test mode the jwt callback skips registration, so skip validation too. + // + // We decode the JWT directly (not session.user.jti) because the session + // token is client-visible and therefore must not carry internal + // session-revocation identifiers — see security ticket #41. const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true"; if (session?.user && !isE2eTestMode) { - const jti = (session.user as typeof session.user & { jti?: string }).jti; + const secret = process.env["AUTH_SECRET"] ?? process.env["NEXTAUTH_SECRET"] ?? ""; + const cookieName = + (process.env["AUTH_URL"] ?? "").startsWith("https://") || process.env["VERCEL"] === "1" + ? "__Host-authjs.session-token" + : "authjs.session-token"; + const jwt = secret ? await getToken({ req, secret, salt: cookieName }) : null; + const jti = (jwt?.["sid"] as string | undefined) ?? undefined; if (jti) { try { const activeSession = await prisma.activeSession.findUnique({ where: { jti } }); @@ -63,7 +89,8 @@ const handler = async (req: NextRequest) => { endpoint: "/api/trpc", req, router: appRouter, - createContext: () => createTRPCContext({ session, dbUser, roleDefaults }), + createContext: () => + createTRPCContext({ session, dbUser, roleDefaults, clientIp: extractClientIp(req) }), }; if (process.env["NODE_ENV"] === "development") { diff --git a/apps/web/src/app/auth/reset-password/[token]/page.tsx b/apps/web/src/app/auth/reset-password/[token]/page.tsx index c7589e2..e7716dc 100644 --- a/apps/web/src/app/auth/reset-password/[token]/page.tsx +++ b/apps/web/src/app/auth/reset-password/[token]/page.tsx @@ -2,6 +2,7 @@ import { use, useState } from "react"; import { useRouter } from "next/navigation"; +import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) { @@ -21,8 +22,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token: function handleSubmit(e: React.FormEvent) { e.preventDefault(); setFormError(null); - if (password.length < 8) { - setFormError("Password must be at least 8 characters."); + if (password.length < PASSWORD_MIN_LENGTH) { + setFormError(PASSWORD_POLICY_MESSAGE); return; } if (password !== confirm) { @@ -40,9 +41,7 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:

Password updated

-

- Your password has been changed successfully. -

+

Your password has been changed successfully.