a478462aa4
- 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>
80 lines
2.6 KiB
TypeScript
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*"],
|
|
};
|