Files
clienthub/src/proxy.ts
T
simone a478462aa4 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>
2026-05-22 14:36:16 +02:00

80 lines
2.6 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// ── ADMIN GUARD ──────────────────────────────────────────────────────────
if (pathname.startsWith("/admin")) {
// Allow the login page and NextAuth API routes through without session check
if (
pathname === "/admin/login" ||
pathname.startsWith("/api/auth")
) {
return NextResponse.next();
}
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
if (!token) {
const loginUrl = new URL("/admin/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
// ── CLIENT TOKEN/SLUG GUARD ──────────────────────────────────────────────
if (pathname.startsWith("/client/")) {
const slugOrTokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
if (!slugOrTokenMatch) {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
const slugOrToken = slugOrTokenMatch[1];
try {
// Use localhost to avoid hairpin NAT issues in Docker.
// request.url is the external hostname (via Traefik); inside the container the app is on localhost.
const port = process.env.PORT ?? "3000";
const base = `http://localhost:${port}`;
const internalHeaders = {
"x-internal-secret": process.env.INTERNAL_SECRET ?? "",
};
// Try slug first (D-06) — user-friendly slugs before token fallback
let res = await fetch(
`${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
{ headers: internalHeaders }
);
// If slug not found, fall back to token validation (existing links continue to work)
if (!res.ok) {
res = await fetch(
`${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
{ headers: internalHeaders }
);
}
if (!res.ok) {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
return NextResponse.next();
} catch {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/client/:path*"],
};