Production hardening: CSP, rate limit, env validation, health gating, Render blueprint, DEPLOYMENT.md
This commit is contained in:
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";
|
||||
}
|
||||
Reference in New Issue
Block a user