● Zion Boggan zionboggan.com ↗

Security hardening: CSP + 9 security headers, per-IP API rate limiter, table-name whitelist, poweredByHeader off; run.py photos/meetings steps

e3f7a3a   Zion Boggan committed on Jun 26, 2026 (1 month ago)
lib/db.ts +4 -1
@@ -64,8 +64,11 @@ export function safeGet<T>(
}
}
-/** Count rows in a table; 0 if the table is missing. */
+/** Count rows in a table; 0 if the table is missing.
+ * Hardening: table name is whitelisted to [a-z_] so it can never carry
+ * user input into SQL (identifiers can't be bound parameters). */
export function safeCount(table: string): number {
+ if (!/^[a-z_]+$/.test(table)) return 0;
try {
const row = db()
.prepare(`SELECT COUNT(*) AS c FROM "${table}"`)
middleware.ts +60 -0
@@ -0,0 +1,60 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+/**
+ * Lightweight per-IP rate limiter for the public read-API.
+ * Fixed-window counter in process memory - Docket runs as a single Node
+ * server (`next start`), so the map persists across requests. This is a
+ * basic abuse/DoS guard, not a billing meter; a reverse proxy (Caddy/CF)
+ * should sit in front in production for L3/L4 protection.
+ */
+const WINDOW_MS = 60_000;
+const MAX_REQ = 120; // per IP per window, across /api/*
+
+type Bucket = { count: number; resetAt: number };
+const buckets = new Map<string, Bucket>();
+
+function clientIp(req: NextRequest): string {
+ const xff = req.headers.get("x-forwarded-for");
+ if (xff) return xff.split(",")[0].trim();
+ return req.headers.get("x-real-ip") ?? "unknown";
+}
+
+export function middleware(req: NextRequest) {
+ // opportunistic cleanup so the map can't grow unbounded
+ if (buckets.size > 10_000) {
+ const now = Date.now();
+ for (const [k, b] of buckets) if (b.resetAt < now) buckets.delete(k);
+ }
+
+ const ip = clientIp(req);
+ const now = Date.now();
+ const b = buckets.get(ip);
+
+ if (!b || b.resetAt < now) {
+ buckets.set(ip, { count: 1, resetAt: now + WINDOW_MS });
+ return NextResponse.next();
+ }
+
+ b.count += 1;
+ if (b.count > MAX_REQ) {
+ const retry = Math.ceil((b.resetAt - now) / 1000);
+ return new NextResponse(
+ JSON.stringify({ error: "Rate limit exceeded. Slow down." }),
+ {
+ status: 429,
+ headers: {
+ "Content-Type": "application/json",
+ "Retry-After": String(retry),
+ "X-RateLimit-Limit": String(MAX_REQ),
+ "X-RateLimit-Remaining": "0",
+ },
+ }
+ );
+ }
+ return NextResponse.next();
+}
+
+// Only guard the API surface; pages/static assets are unaffected.
+export const config = {
+ matcher: ["/api/:path*"],
+};
next.config.js +37 -2
@@ -1,11 +1,46 @@
/** @type {import('next').NextConfig} */
+
+// Content-Security-Policy. Docket serves only its own JS/CSS + self-hosted
+// fonts; the one external asset is official member headshots from the
+// public-domain unitedstates/images project on GitHub Pages.
+const csp = [
+ "default-src 'self'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "frame-ancestors 'none'",
+ "object-src 'none'",
+ // Next.js injects a small inline hydration bootstrap; 'unsafe-inline' kept
+ // for scripts/styles (no nonce pipeline). No third-party script origins.
+ "script-src 'self' 'unsafe-inline'",
+ "style-src 'self' 'unsafe-inline'",
+ "font-src 'self' data:",
+ "img-src 'self' data: https://unitedstates.github.io",
+ "connect-src 'self'",
+ "upgrade-insecure-requests",
+].join("; ");
+
+const securityHeaders = [
+ { key: "Content-Security-Policy", value: csp },
+ { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
+ { key: "X-Frame-Options", value: "DENY" },
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
+ { key: "X-DNS-Prefetch-Control", value: "off" },
+ { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
+ { key: "Cross-Origin-Resource-Policy", value: "same-origin" },
+];
+
const nextConfig = {
reactStrictMode: true,
- // better-sqlite3 is a native Node addon - never let webpack bundle it.
- // Route handlers and server components load it as an external require.
+ poweredByHeader: false, // don't advertise Next.js version
experimental: {
+ // better-sqlite3 is a native Node addon - never let webpack bundle it.
serverComponentsExternalPackages: ["better-sqlite3"],
},
+ async headers() {
+ return [{ source: "/:path*", headers: securityHeaders }];
+ },
};
module.exports = nextConfig;