security: full hardening pass — auth guards, rate limiting, headers, internal secret

- Add requireAdmin() to all unprotected admin server actions
  (clients/new, clients/[id], timer-actions — 17 functions total)
- Protect /api/internal/* endpoints with X-Internal-Secret header
  (proxy.ts sends it; routes reject requests without it)
- Randomize auto-generated client slugs with 4-char suffix
  to prevent enumeration via predictable name-based slugs
- Add in-memory rate limiting to /api/client/approve (20/min)
  and /api/client/comment (10/min) per IP
- Add security headers: X-Frame-Options, X-Content-Type-Options,
  Referrer-Policy, Permissions-Policy, X-DNS-Prefetch-Control
- Reduce JWT session from 30 days to 7 days with daily rotation
- Remove hardcoded NEXTAUTH_URL from Dockerfile (pass via Coolify env)
- Genericize client API error messages to not leak data structure
- Update .env.example with all required variables including INTERNAL_SECRET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:36:16 +02:00
parent eab88c9f63
commit a478462aa4
13 changed files with 128 additions and 15 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ export const authOptions: NextAuthOptions = {
],
session: {
strategy: "jwt", // stateless JWT — no DB session table (per D-03)
maxAge: 30 * 24 * 60 * 60, // 30 days
maxAge: 7 * 24 * 60 * 60, // 7 days (reduced from 30 for security)
updateAge: 24 * 60 * 60, // rotate token daily on active use
},
pages: {
signIn: "/admin/login", // custom login page (per D-07)
+18
View File
@@ -0,0 +1,18 @@
// In-memory rate limiter — suitable for single-container deployments.
// Each bucket tracks hit count within a rolling window.
const buckets = new Map<string, { hits: number; resetAt: number }>();
export function rateLimit(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now >= bucket.resetAt) {
buckets.set(key, { hits: 1, resetAt: now + windowMs });
return true;
}
if (bucket.hits >= limit) return false;
bucket.hits++;
return true;
}