| @@ -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}"`) |
| @@ -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*"], | |
| + | }; |
| @@ -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; |