# Security incident — rcc-ump.com (2026-05-27) **Status:** Remediation in progress (code fixes tracked below) **Scope:** Production exposure at `https://www.rcc-ump.com` / `https://rcc-ump.com` **Related prior audit:** [assets/docs/2026-05-21-security-review.md](../assets/docs/2026-05-21-security-review.md) --- ## Executive summary Public screenshots and `curl` tests show the production site was serving the **Vite development server**, not a built SPA. That exposes full TypeScript source, `import.meta.env` values (including internal Docker hostnames), stack traces, and HMR internals. Combined with backend misconfiguration (default JWT secret, unauthenticated API routes), this created a path from **reconnaissance → data theft → admin takeover → arbitrary file write**. Treat the VPS as **compromised until forensics prove otherwise**. Rotate credentials and redeploy with the fixes in this document before bringing the site back. --- ## Evidence (what attackers saw) | Observation | Confirms | |---|---| | DevTools Sources tree shows `@vite/client`, `@react-refresh`, `node_modules`, `/src/**` | Vite **dev** server on the public internet | | `curl …/src/shared/api/client.ts` returns source with `DEV: true`, `VITE_DEV_PROXY_TARGET: http://be0:4402` | Env + internal service names leaked | | `curl …/vite.config.ts` returns HTML error with full config + stack trace | Verbose dev error handling | | `lovable-tagger` in plugin list | Dev-only tooling active | **Root cause in repo:** `docker-compose.prod.yml` and `fe0/Dockerfile` run `npm run dev -- --host 0.0.0.0`. --- ## Findings → fixes (checklist) Track implementation in git; check off after deploy to production. ### Step 0 — Incident response (ops, not code) - [ ] Restrict public access (maintenance page / firewall) during remediation - [ ] Rotate **Postgres**, **MinIO**, **SMTP**, and generate new **`JWT_SECRET`** (`openssl rand -base64 48`) - [ ] Bump every user's `credential_version` in Postgres (invalidates old JWTs) - [ ] Review `audit_events`, unknown admin users, MinIO objects, modified files under `./be0` / `./fe0` - [ ] Bind MinIO console to localhost; do not expose `:9001` to the internet - [ ] Purge `.env` from git history if the repo was ever shared (`git filter-repo`) ### Step 1 — JWT and production mode ✅ (code) | ID | Finding | Fix | Status | |---|---|---|---| | C-1 | `JWT_SECRET` unset → dev fallback signs tokens | `JWT_SECRET` + `ENVIRONMENT=production` in `docker-compose.prod.yml`; `verify-prod-env.sh` | **Done** | | — | `ENVIRONMENT` never set in prod | Pass `ENVIRONMENT=production` to `be0` | **Done** | ### Step 2 — Production frontend (stop source leak) ✅ (code) | ID | Finding | Fix | Status | |---|---|---|---| | H-3 | Vite dev in production | `fe0/Dockerfile.prod` + `fe0/nginx/default.conf`; updated `docker-compose.prod.yml` | **Done** | | — | Prod API URL pointed at localhost | Same-origin `/api` via nginx when `VITE_API_URL` unset | **Done** | ### Step 3 — Remove broken / dangerous endpoints ✅ (code) | ID | Finding | Fix | Status | |---|---|---|---| | C-3 | `POST /upload_document` | **Removed** | **Done** | | M-9 | `POST /get_page` | **Removed** | **Done** | ### Step 4 — Authenticate sensitive API routes ✅ (code) | ID | Finding | Fix | Status | |---|---|---|---| | C-4 | `/api/v1/review-documents` CRUD | Login + owner/staff | **Done** | | C-5 | `GET /api/applications` | Staff-only | **Done** | | C-5 | `GET /api/applications/{id}` | Owner or staff | **Done** | | H-1 | LLM / chat / ideas endpoints | Auth on all listed routes | **Done** | | H-4 | `DELETE …/admin-result` auth order | Auth first | **Done** | ### Step 5 — Hardening ✅ (code) | ID | Finding | Fix | Status | |---|---|---|---| | H-5 | MinIO CORS `*` default | Required `MINIO_API_CORS_ALLOW_ORIGIN`; console on localhost | **Done** | | H-7 | No login rate limit | `allow_login()` | **Done** | | M-2 | No security headers | Middleware + nginx | **Done** | | M-3 | CORS `*` risk | Fail startup if `*` in origins | **Done** | **Deploy:** Rebuild `fe0` with `Dockerfile.prod`. Confirm DevTools no longer shows `@vite/client` or `/src/`. Replace placeholder `fe0/public/logo.svg` with your institution logo if needed. **Deploy (Steps 1–5):** Update `.env` (see below), run `./scripts/verify-prod-env.sh`, then: ```bash docker compose --env-file .env -f docker-compose.prod.yml up -d --build ``` Recreate `be0` after setting `JWT_SECRET` and bump all users' `credential_version` in Postgres. - Non-root Docker users; remove prod bind-mounts of `./be0` / `./fe0` source - HttpOnly refresh tokens; shorten JWT TTL - Upgrade `xlsx`; pin `pip install` at image build time - Auth audit test: every mutating route must have auth dependency - Add `SECURITY.md` disclosure policy --- ## Production architecture (target) ``` Browser (HTTPS, external nginx/Caddy on VPS) │ ├─► fe0 container (nginx :8080) ── static files from dist/ │ proxy /api/* ──► be0:4402 (Docker network) │ proxy /submitted-initiatives/ ──► be0:4402 │ ├─► be0 bound 127.0.0.1:4402 on host (not public) ├─► postgres bound 127.0.0.1:15432 └─► MinIO API (TLS via reverse proxy); console localhost-only ``` External TLS termination (Certbot/Caddy on the VPS) sits in front of `${FE_PORT}`. See [deploy-production-docker.md](./deploy-production-docker.md) and [minio-behind-https.md](./minio-behind-https.md). --- ## Verification after deploy ### Automated tests (run before deploy) ```bash # Backend — includes 17 security regression tests cd be0 && python -m pytest tests/ -q # Frontend unit tests + env config cd fe0 && npm test && npm run build # Production .env validation script ./scripts/test-verify-prod-env.sh ``` ### Production smoke checks ```bash # 1. Env validation ./scripts/verify-prod-env.sh # 2. Stack healthy docker compose --env-file .env -f docker-compose.prod.yml ps # 3. No Vite dev artifacts (expect 404, not TS source) curl -sS -o /dev/null -w "%{http_code}\n" https://www.rcc-ump.com/src/main.tsx # 4. Unauthenticated PII blocked (expect 401) curl -sS -o /dev/null -w "%{http_code}\n" https://www.rcc-ump.com/api/applications # 5. JWT not forgeable — login with real user; admin routes reject unsigned tokens curl -sS -o /dev/null -w "%{http_code}\n" \ -H "Authorization: Bearer invalid" \ https://www.rcc-ump.com/api/v1/admin/audit-events ``` --- ## `.env` additions required for production ```bash # Generate once: JWT_SECRET=$(openssl rand -base64 48) # Restrict MinIO browser CORS to your SPA origin (scheme + host, no trailing slash): MINIO_API_CORS_ALLOW_ORIGIN=https://www.rcc-ump.com # Public app URL (emails, CORS extras): AUTH_PUBLIC_WEB_ORIGIN=https://www.rcc-ump.com CORS_ORIGINS_EXTRA=https://www.rcc-ump.com ``` After first deploy with `JWT_SECRET`, run SQL (or admin script) to increment `credential_version` for all users. --- *Last updated: 2026-05-27 — update checkboxes as fixes land on `main` and production.*