Production hardening: CSP, rate limit, env validation, health gating, Render blueprint, DEPLOYMENT.md
This commit is contained in:
73
DEPLOYMENT.md
Normal file
73
DEPLOYMENT.md
Normal 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**.
|
||||
@@ -21,6 +21,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||
import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
||||
import { appEnv } from "@/lib/env";
|
||||
|
||||
interface CheckResult {
|
||||
check: string;
|
||||
@@ -353,6 +354,20 @@ async function probeContactRelationships(cid: string): Promise<CheckResult> {
|
||||
}
|
||||
|
||||
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 summary = { mode: env.ok ? "live" : "stub" };
|
||||
|
||||
@@ -361,7 +376,6 @@ export async function GET(req: Request) {
|
||||
return NextResponse.json({ ...summary, checks: [env] });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const cid = url.searchParams.get("cid");
|
||||
const cs = url.searchParams.get("cs");
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { NextResponse } from "next/server";
|
||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||
import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
||||
import type { SubmitPayload } from "@/types/form";
|
||||
import { appEnv, redact } from "@/lib/env";
|
||||
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
||||
|
||||
function isStubMode(): boolean {
|
||||
return !(
|
||||
@@ -28,6 +30,21 @@ function isStubMode(): boolean {
|
||||
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
|
||||
|
||||
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;
|
||||
try {
|
||||
body = (await req.json()) as SubmitPayload;
|
||||
@@ -44,6 +61,23 @@ export async function POST(req: Request) {
|
||||
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);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });
|
||||
|
||||
13
app/healthz/route.ts
Normal file
13
app/healthz/route.ts
Normal 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
71
lib/env.ts
Normal 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
55
lib/rate-limit.ts
Normal 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";
|
||||
}
|
||||
@@ -1,7 +1,51 @@
|
||||
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 = {
|
||||
/* config options here */
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
async headers() {
|
||||
return [{ source: "/:path*", headers: securityHeaders }];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
37
render.yaml
Normal file
37
render.yaml
Normal 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
|
||||
Reference in New Issue
Block a user