Production hardening: CSP, rate limit, env validation, health gating, Render blueprint, DEPLOYMENT.md

This commit is contained in:
Joel Brock
2026-05-09 21:43:43 -07:00
parent da3e48a874
commit dcdf315244
8 changed files with 343 additions and 2 deletions

73
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,73 @@
# Deployment
This app is built to deploy to **[Render](https://render.com)** as a Web
Service via the included `render.yaml` blueprint.
## Pre-flight checklist
Before deploying, confirm:
- [ ] CiviCRM has a working API key for a service user with sufficient privileges to read/write `Contact`, `Activity`, `Relationship`, and `OptionValue`.
- [ ] The `Primary Contact` (or whatever value `FORM_CONTACT_RELATIONSHIP` resolves to in `config/form.ts`) relationship type exists, with Individual on side A and Organization on side B.
- [ ] The `Check-in (organizing)` activity type exists with the six custom field groups attached (`Check_in_data__organizing_`, `Stage_1`, `Stage_2`, `Stage_3`, `Stage_4`, `Stage_5`).
- [ ] The Framework Stage option group on the Organization contact contains all six in-scope values: `Inquiry`, `Organizing`, `Feasibility`, `Business feasibility`, `Store Implementation`, `Stabilize newly opened co-op`.
Verify all of the above against your live CiviCRM by hitting `/api/health` (in development; production requires a token — see below).
## First-time Render setup
1. **Create a new Web Service** from this repo on Render. Render will detect `render.yaml` and configure the service automatically.
2. **Set the secrets** in the Render dashboard → Environment. The `render.yaml` declares them but marks them `sync: false` so they're never echoed in the committed file:
| Variable | Required? | Purpose |
|---|---|---|
| `CIVI_BASE_URL` | yes | e.g. `https://crm.fci.coop` |
| `CIVI_API_KEY` | yes | Per-user API key. Generate via CiviCRM Contact → API Key field. |
| `CIVI_SITE_KEY` | yes | From `civicrm.settings.php` (`CIVICRM_SITE_KEY`). |
| `CIVI_HTTP_AUTH_USER` | only if Civi sits behind webserver-level Basic Auth | HTTP Basic Auth username. |
| `CIVI_HTTP_AUTH_PASS` | only if CIVI_HTTP_AUTH_USER is set | HTTP Basic Auth password. |
| `HEALTH_TOKEN` | recommended | Long random string (e.g. `openssl rand -hex 32`). Required to access `/api/health` in production. If unset, that route returns 404. |
| `PUBLIC_ORIGIN` | optional | e.g. `https://check-in.fci.coop` — used in absolute self-links if needed later. |
3. **Trigger the first deploy.** Render will run `npm ci && npm run build` then `npm run start`. The platform health check hits `/healthz` (lightweight, no Civi dependency).
4. **Verify**:
- `https://<your-render-url>/healthz``{"ok":true,"service":"coop-checkin"}`
- `https://<your-render-url>/api/health?token=<HEALTH_TOKEN>` → all checks should be green
- Visit `https://<your-render-url>/?cid=<test-individual>&cs=<their-checksum>` → form loads with prefill
5. **Custom domain (optional)** — Render dashboard → Custom Domain → add `check-in.fci.coop`. Update DNS to the provided CNAME. Render auto-issues a Let's Encrypt cert.
## Security defaults
The app ships with these defaults; review and adjust per your threat model.
| Concern | Default |
|---|---|
| HTTPS | Required. Render terminates TLS for you. HSTS header is set with a 2-year max-age + preload. |
| CSP | Tight: same-origin scripts/connections; Google Fonts allowed for next/font; `frame-ancestors 'none'`; no third-party scripts. |
| Frame embedding | Denied (X-Frame-Options + CSP frame-ancestors). |
| Referrer | `same-origin` so the cid+cs query string never leaks via Referer to other origins. |
| Health endpoint | Production: gated behind `HEALTH_TOKEN`. Dev: unauthenticated. |
| Submit rate limit | 10 submissions per IP per minute (in-memory; resets on restart). |
| Error messages | Production responses say "Could not save" without details; full error logged server-side with secrets redacted. |
| Stub mode | Refuses to start if `NODE_ENV=production` and any of `CIVI_BASE_URL`/`CIVI_API_KEY`/`CIVI_SITE_KEY` is missing. |
## What's intentionally NOT done
- **CSRF tokens.** The submit endpoint is gated by the per-contact CiviCRM checksum, which is not browser-cookie-based, so traditional CSRF doesn't apply. Add CSRF tokens if you ever introduce a session-cookie auth path.
- **Multi-instance rate limiting.** The in-memory token bucket is per-process. Acceptable for a single Render instance; switch to Redis or Render's edge rate-limit if you scale horizontally.
- **WAF.** None configured. Render's platform provides basic DDoS protection; add a WAF (Cloudflare in front of Render, or similar) if your threat model warrants it.
- **i18n.** English-only.
## Rolling out updates
1. Push to your default branch.
2. Render auto-builds and deploys (per `autoDeploy: true` in `render.yaml`).
3. Watch deploy logs for any health-check failures.
4. After deploy: hit `/api/health?token=…` to confirm all checks still pass against the live CRM.
## Rollback
Render keeps the previous deploy available. From the Render dashboard → **Deploys** → previous deploy → **Rollback to this deploy**.

View File

@@ -21,6 +21,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm"; import { civi, verifyChecksum } from "@/lib/civicrm";
import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import { appEnv } from "@/lib/env";
interface CheckResult { interface CheckResult {
check: string; check: string;
@@ -353,6 +354,20 @@ async function probeContactRelationships(cid: string): Promise<CheckResult> {
} }
export async function GET(req: Request) { export async function GET(req: Request) {
// In production: require ?token=<HEALTH_TOKEN>. If HEALTH_TOKEN is unset
// in production, the endpoint is disabled entirely (404). In development
// / non-prod, anyone can read it.
const e = appEnv();
const url = new URL(req.url);
if (e.isProduction) {
if (!e.healthToken) {
return new NextResponse("Not Found", { status: 404 });
}
if (url.searchParams.get("token") !== e.healthToken) {
return new NextResponse("Not Found", { status: 404 });
}
}
const env = envSummary(); const env = envSummary();
const summary = { mode: env.ok ? "live" : "stub" }; const summary = { mode: env.ok ? "live" : "stub" };
@@ -361,7 +376,6 @@ export async function GET(req: Request) {
return NextResponse.json({ ...summary, checks: [env] }); return NextResponse.json({ ...summary, checks: [env] });
} }
const url = new URL(req.url);
const cid = url.searchParams.get("cid"); const cid = url.searchParams.get("cid");
const cs = url.searchParams.get("cs"); const cs = url.searchParams.get("cs");

View File

@@ -16,6 +16,8 @@ import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm"; import { civi, verifyChecksum } from "@/lib/civicrm";
import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import type { SubmitPayload } from "@/types/form"; import type { SubmitPayload } from "@/types/form";
import { appEnv, redact } from "@/lib/env";
import { rateLimit, clientIp } from "@/lib/rate-limit";
function isStubMode(): boolean { function isStubMode(): boolean {
return !( return !(
@@ -28,6 +30,21 @@ function isStubMode(): boolean {
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f])); const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
export async function POST(req: Request) { export async function POST(req: Request) {
// Rate limit per client IP. Generous default — 10 submissions per minute.
const ip = clientIp(req);
const rl = rateLimit(`submit:${ip}`, { capacity: 10, windowMs: 60_000 });
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many submissions. Please wait a moment and try again." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
},
},
);
}
let body: SubmitPayload; let body: SubmitPayload;
try { try {
body = (await req.json()) as SubmitPayload; body = (await req.json()) as SubmitPayload;
@@ -44,6 +61,23 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, stub: true }); return NextResponse.json({ ok: true, stub: true });
} }
try {
return await runSubmit(cid, cs, values);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[submit] failure:", redact(msg));
return NextResponse.json(
{
error: appEnv().isProduction
? "Could not save your check-in. Please try again, or contact your engagement coordinator."
: `Save failed: ${redact(msg)}`,
},
{ status: 500 },
);
}
}
async function runSubmit(cid: string, cs: string, values: Record<string, unknown>): Promise<NextResponse> {
const ok = await verifyChecksum(cid, cs); const ok = await verifyChecksum(cid, cs);
if (!ok) { if (!ok) {
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 }); return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });

13
app/healthz/route.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* GET /healthz
*
* Lightweight health check for Render's load-balancer. Returns 200 OK
* without making any external calls. Use /api/health for the deeper
* Civi-side diagnostic.
*/
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ ok: true, service: "coop-checkin" }, { status: 200 });
}

71
lib/env.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Environment-variable validation. Called at module load time by routes
* that need real Civi credentials. In stub mode, no env is required.
*
* Centralizing here means we can:
* - Fail fast at boot in production if a required var is missing
* - Avoid leaking secrets to error responses (we redact in error paths)
* - Reuse the "is stub mode?" check in one place
*/
export interface AppEnv {
isStub: boolean;
isProduction: boolean;
civiBaseUrl?: string;
civiApiKey?: string;
civiSiteKey?: string;
basicAuthUser?: string;
basicAuthPass?: string;
/**
* Optional admin token to gate /api/health in production. If unset in prod,
* /api/health is disabled entirely (returns 404). Set this to a long random
* string and pass via `?token=<>` to the health endpoint to read diagnostics.
*/
healthToken?: string;
/**
* Same-origin host this app runs at. Used to build `frame-ancestors 'self'`
* and any absolute self-links. Defaults to inferring from the request.
*/
publicOrigin?: string;
}
let cached: AppEnv | undefined;
export function appEnv(): AppEnv {
if (cached) return cached;
const e: AppEnv = {
isStub: !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
),
isProduction: process.env.NODE_ENV === "production",
civiBaseUrl: process.env.CIVI_BASE_URL,
civiApiKey: process.env.CIVI_API_KEY,
civiSiteKey: process.env.CIVI_SITE_KEY,
basicAuthUser: process.env.CIVI_HTTP_AUTH_USER,
basicAuthPass: process.env.CIVI_HTTP_AUTH_PASS,
healthToken: process.env.HEALTH_TOKEN,
publicOrigin: process.env.PUBLIC_ORIGIN,
};
// In production, refuse to start in stub mode — that's almost certainly a
// misconfiguration. Better to crash loudly than silently serve stub data.
if (e.isProduction && e.isStub) {
throw new Error(
"Refusing to run in production without CIVI_BASE_URL, CIVI_API_KEY, and CIVI_SITE_KEY set. " +
"Either provide all three, or set NODE_ENV != 'production'.",
);
}
cached = e;
return e;
}
/** Strip secrets from any string before logging or echoing in responses. */
export function redact(s: string): string {
let out = s;
const e = appEnv();
for (const v of [e.civiApiKey, e.civiSiteKey, e.basicAuthPass, e.healthToken]) {
if (v && v.length >= 6) out = out.split(v).join("«redacted»");
}
return out;
}

55
lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Tiny in-memory rate limiter. Token-bucket per key (typically client IP).
*
* Suitable for a single-instance deployment behind Render's load balancer.
* State resets on process restart, which is fine for this use case (the
* worst that happens is a malicious client gets to retry sooner).
*
* For multi-instance deployments use Redis or Render's edge rate-limit.
*/
interface Bucket {
tokens: number;
refilledAt: number;
}
const BUCKETS = new Map<string, Bucket>();
export interface RateLimitOptions {
/** Max requests per window. */
capacity: number;
/** Window length in milliseconds (tokens fully refill each window). */
windowMs: number;
}
export function rateLimit(key: string, opts: RateLimitOptions): { allowed: boolean; remaining: number; resetMs: number } {
const now = Date.now();
const bucket = BUCKETS.get(key);
if (!bucket || now - bucket.refilledAt >= opts.windowMs) {
BUCKETS.set(key, { tokens: opts.capacity - 1, refilledAt: now });
return { allowed: true, remaining: opts.capacity - 1, resetMs: opts.windowMs };
}
if (bucket.tokens > 0) {
bucket.tokens--;
return {
allowed: true,
remaining: bucket.tokens,
resetMs: opts.windowMs - (now - bucket.refilledAt),
};
}
return {
allowed: false,
remaining: 0,
resetMs: opts.windowMs - (now - bucket.refilledAt),
};
}
/**
* Best-effort client-IP extraction from a Next.js Request. Trusts the
* left-most x-forwarded-for entry (set by Render's load balancer).
*/
export function clientIp(req: Request): string {
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
return req.headers.get("x-real-ip") ?? "unknown";
}

View File

@@ -1,7 +1,51 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
/**
* Security headers applied to every response.
*
* Notes on each:
* - CSP: tight default; allows Google Fonts (next/font) and the same-origin
* /api routes. No third-party scripts. `frame-ancestors 'none'` prevents
* this app being embedded in another site's iframe.
* - HSTS: only meaningful behind HTTPS (Render terminates TLS, so this is
* correct in production).
* - Permissions-Policy: drop everything we don't use.
* - Referrer-Policy: same-origin — never leak the cid+cs query string to
* other origins via the Referer header.
* - X-Content-Type-Options: prevents MIME sniffing.
*/
const securityHeaders = [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].join("; "),
},
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "same-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
{ key: "X-Frame-Options", value: "DENY" },
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ poweredByHeader: false,
reactStrictMode: true,
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
}; };
export default nextConfig; export default nextConfig;

37
render.yaml Normal file
View File

@@ -0,0 +1,37 @@
# Render Blueprint — https://render.com/docs/blueprint-spec
#
# Deploy this app as a Web Service on Render. Push to your default branch
# triggers a deploy. Set the four CIVI_* env vars in the Render dashboard
# (mark them as "Sync: false" so they're not overwritten by this file).
services:
- type: web
name: coop-checkin
runtime: node
plan: starter
region: oregon
buildCommand: npm ci && npm run build
startCommand: npm run start
healthCheckPath: /healthz
envVars:
- key: NODE_ENV
value: production
- key: NODE_VERSION
value: "20"
# Set these manually in the Render dashboard (UI → Environment).
# Marked sync:false so they're not echoed in this committed file.
- key: CIVI_BASE_URL
sync: false
- key: CIVI_API_KEY
sync: false
- key: CIVI_SITE_KEY
sync: false
- key: CIVI_HTTP_AUTH_USER
sync: false
- key: CIVI_HTTP_AUTH_PASS
sync: false
- key: HEALTH_TOKEN
sync: false
- key: PUBLIC_ORIGIN
sync: false
autoDeploy: true