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
+8 -2
View File
@@ -2,8 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { eq, and } from "drizzle-orm";
import { db } from "@/db";
import { clients, deliverables, tasks, phases, projects } from "@/db/schema";
import { rateLimit } from "@/lib/rate-limit";
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!rateLimit(`approve:${ip}`, 20, 60_000)) {
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
}
try {
const body = await request.json();
const { token, deliverableId } = body as { token?: string; deliverableId?: string };
@@ -20,7 +26,7 @@ export async function POST(request: NextRequest) {
.limit(1);
if (clientRows.length === 0) {
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const clientId = clientRows[0].id;
@@ -37,7 +43,7 @@ export async function POST(request: NextRequest) {
.limit(1);
if (ownershipCheck.length === 0) {
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
// IMMUTABILITY RULE (CLAUDE.md): if approved_at is already set, this is a no-op
+13 -7
View File
@@ -3,6 +3,7 @@ import { eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema";
import { rateLimit } from "@/lib/rate-limit";
const commentSchema = z.object({
token: z.string().min(1),
@@ -12,6 +13,11 @@ const commentSchema = z.object({
});
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!rateLimit(`comment:${ip}`, 10, 60_000)) {
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
}
try {
const body = await request.json();
const parsed = commentSchema.safeParse(body);
@@ -33,7 +39,7 @@ export async function POST(request: NextRequest) {
.limit(1);
if (clientRows.length === 0) {
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const clientId = clientRows[0].id;
@@ -41,7 +47,7 @@ export async function POST(request: NextRequest) {
if (entity_type === "general") {
// General messages: entity_id must be the client's own id
if (entity_id !== clientId) {
return NextResponse.json({ error: "Entity non valida" }, { status: 403 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
} else {
// Scope phases through projects → client
@@ -52,7 +58,7 @@ export async function POST(request: NextRequest) {
const projectIds = clientProjects.map((p) => p.id);
if (projectIds.length === 0) {
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const phasesForClient = await db
@@ -62,7 +68,7 @@ export async function POST(request: NextRequest) {
const phaseIds = phasesForClient.map((p) => p.id);
if (phaseIds.length === 0) {
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const taskRows = await db
@@ -72,20 +78,20 @@ export async function POST(request: NextRequest) {
if (entity_type === "task") {
if (!taskRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
} else {
// deliverable
const taskIds = taskRows.map((r) => r.id);
if (taskIds.length === 0) {
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const delivRows = await db
.select({ id: deliverables.id })
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
if (!delivRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
}
}
@@ -4,6 +4,11 @@ import { db } from "@/db";
import { clients } from "@/db/schema";
export async function GET(request: NextRequest) {
const secret = process.env.INTERNAL_SECRET;
if (!secret || request.headers.get("x-internal-secret") !== secret) {
return NextResponse.json({ valid: false }, { status: 403 });
}
const slug = request.nextUrl.searchParams.get("slug");
if (!slug) {
@@ -4,6 +4,11 @@ import { db } from '@/db';
import { clients } from '@/db/schema';
export async function GET(request: NextRequest) {
const secret = process.env.INTERNAL_SECRET;
if (!secret || request.headers.get("x-internal-secret") !== secret) {
return NextResponse.json({ valid: false }, { status: 403 });
}
const token = request.nextUrl.searchParams.get('token');
if (!token) {