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
+10
View File
@@ -1,2 +1,12 @@
# Database — Postgres su Coolify # Database — Postgres su Coolify
DATABASE_URL=postgresql://user:password@host:5432/database DATABASE_URL=postgresql://user:password@host:5432/database
# NextAuth
NEXTAUTH_URL=https://hub.iamcavalli.net
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=use-a-strong-password-min-20-chars
# Internal API secret — shared between proxy.ts and /api/internal/* routes
# Generate with: openssl rand -base64 32
INTERNAL_SECRET=generate-with-openssl-rand-base64-32
+1 -1
View File
@@ -15,7 +15,7 @@ RUN npm run build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXTAUTH_URL=https://hub.iamcavalli.net # NEXTAUTH_URL must be set via Coolify environment variables at runtime
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
+11
View File
@@ -1,7 +1,18 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const securityHeaders = [
{ 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=()" },
{ key: "X-DNS-Prefetch-Control", value: "on" },
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
}; };
export default nextConfig; export default nextConfig;
+21
View File
@@ -2,7 +2,14 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/db"; import { db } from "@/db";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
import { import {
phases, phases,
tasks, tasks,
@@ -56,6 +63,7 @@ const clientSchema = z.object({
}); });
export async function updateClient(clientId: string, formData: FormData) { export async function updateClient(clientId: string, formData: FormData) {
await requireAdmin();
const parsed = clientSchema.safeParse({ const parsed = clientSchema.safeParse({
name: formData.get("name"), name: formData.get("name"),
brand_name: formData.get("brand_name"), brand_name: formData.get("brand_name"),
@@ -78,12 +86,14 @@ export async function updateClient(clientId: string, formData: FormData) {
} }
export async function deleteClient(clientId: string) { export async function deleteClient(clientId: string) {
await requireAdmin();
await db.delete(clients).where(eq(clients.id, clientId)); await db.delete(clients).where(eq(clients.id, clientId));
revalidatePath("/admin"); revalidatePath("/admin");
redirect("/admin"); redirect("/admin");
} }
export async function setClientArchived(clientId: string, archived: boolean) { export async function setClientArchived(clientId: string, archived: boolean) {
await requireAdmin();
await db.update(clients).set({ archived }).where(eq(clients.id, clientId)); await db.update(clients).set({ archived }).where(eq(clients.id, clientId));
revalidatePath(`/admin/clients/${clientId}`); revalidatePath(`/admin/clients/${clientId}`);
revalidatePath("/admin"); revalidatePath("/admin");
@@ -92,6 +102,7 @@ export async function setClientArchived(clientId: string, archived: boolean) {
// ── PHASES ──────────────────────────────────────────────────────────────────── // ── PHASES ────────────────────────────────────────────────────────────────────
export async function addPhase(id: string, formData: FormData) { export async function addPhase(id: string, formData: FormData) {
await requireAdmin();
const title = (formData.get("title") as string)?.trim(); const title = (formData.get("title") as string)?.trim();
if (!title) throw new Error("Titolo fase richiesto"); if (!title) throw new Error("Titolo fase richiesto");
@@ -114,6 +125,7 @@ export async function addPhase(id: string, formData: FormData) {
} }
export async function updatePhaseStatus(phaseId: string, id: string, status: string) { export async function updatePhaseStatus(phaseId: string, id: string, status: string) {
await requireAdmin();
const allowed = ["upcoming", "active", "done"]; const allowed = ["upcoming", "active", "done"];
if (!allowed.includes(status)) throw new Error("Stato non valido"); if (!allowed.includes(status)) throw new Error("Stato non valido");
await db.update(phases).set({ status }).where(eq(phases.id, phaseId)); await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
@@ -124,6 +136,7 @@ export async function updatePhaseStatus(phaseId: string, id: string, status: str
// ── TASKS ───────────────────────────────────────────────────────────────────── // ── TASKS ─────────────────────────────────────────────────────────────────────
export async function addTask(phaseId: string, id: string, formData: FormData) { export async function addTask(phaseId: string, id: string, formData: FormData) {
await requireAdmin();
const title = (formData.get("title") as string)?.trim(); const title = (formData.get("title") as string)?.trim();
if (!title) throw new Error("Titolo task richiesto"); if (!title) throw new Error("Titolo task richiesto");
@@ -145,6 +158,7 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
} }
export async function updateTaskStatus(taskId: string, id: string, status: string) { export async function updateTaskStatus(taskId: string, id: string, status: string) {
await requireAdmin();
const allowed = ["todo", "in_progress", "done"]; const allowed = ["todo", "in_progress", "done"];
if (!allowed.includes(status)) throw new Error("Stato non valido"); if (!allowed.includes(status)) throw new Error("Stato non valido");
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
@@ -155,6 +169,7 @@ export async function updateTaskStatus(taskId: string, id: string, status: strin
// ── DELIVERABLES ────────────────────────────────────────────────────────────── // ── DELIVERABLES ──────────────────────────────────────────────────────────────
export async function addDeliverable(taskId: string, id: string, formData: FormData) { export async function addDeliverable(taskId: string, id: string, formData: FormData) {
await requireAdmin();
const title = (formData.get("title") as string)?.trim(); const title = (formData.get("title") as string)?.trim();
const url = (formData.get("url") as string)?.trim() || null; const url = (formData.get("url") as string)?.trim() || null;
if (!title) throw new Error("Titolo deliverable richiesto"); if (!title) throw new Error("Titolo deliverable richiesto");
@@ -171,6 +186,7 @@ const docSchema = z.object({
}); });
export async function addDocument(id: string, formData: FormData) { export async function addDocument(id: string, formData: FormData) {
await requireAdmin();
const parsed = docSchema.safeParse({ const parsed = docSchema.safeParse({
label: formData.get("label"), label: formData.get("label"),
url: formData.get("url"), url: formData.get("url"),
@@ -185,6 +201,7 @@ export async function addDocument(id: string, formData: FormData) {
} }
export async function updateDocument(documentId: string, id: string, formData: FormData) { export async function updateDocument(documentId: string, id: string, formData: FormData) {
await requireAdmin();
const parsed = docSchema.safeParse({ const parsed = docSchema.safeParse({
label: formData.get("label"), label: formData.get("label"),
url: formData.get("url"), url: formData.get("url"),
@@ -196,6 +213,7 @@ export async function updateDocument(documentId: string, id: string, formData: F
} }
export async function deleteDocument(documentId: string, id: string) { export async function deleteDocument(documentId: string, id: string) {
await requireAdmin();
await db.delete(documents).where(eq(documents.id, documentId)); await db.delete(documents).where(eq(documents.id, documentId));
const { path } = await resolveEntity(id); const { path } = await resolveEntity(id);
revalidatePath(path); revalidatePath(path);
@@ -204,6 +222,7 @@ export async function deleteDocument(documentId: string, id: string) {
// ── PAYMENTS ────────────────────────────────────────────────────────────────── // ── PAYMENTS ──────────────────────────────────────────────────────────────────
export async function updatePaymentStatus(paymentId: string, id: string, status: string) { export async function updatePaymentStatus(paymentId: string, id: string, status: string) {
await requireAdmin();
const allowed = ["da_saldare", "inviata", "saldato"]; const allowed = ["da_saldare", "inviata", "saldato"];
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido"); if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
const paid_at = status === "saldato" ? new Date() : null; const paid_at = status === "saldato" ? new Date() : null;
@@ -213,6 +232,7 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
} }
export async function updateAcceptedTotal(id: string, formData: FormData) { export async function updateAcceptedTotal(id: string, formData: FormData) {
await requireAdmin();
const raw = (formData.get("accepted_total") as string)?.trim(); const raw = (formData.get("accepted_total") as string)?.trim();
const val = parseFloat(raw); const val = parseFloat(raw);
if (isNaN(val) || val < 0) throw new Error("Importo non valido"); if (isNaN(val) || val < 0) throw new Error("Importo non valido");
@@ -254,6 +274,7 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
// ── COMMENTS (admin reply) ──────────────────────────────────────────────────── // ── COMMENTS (admin reply) ────────────────────────────────────────────────────
export async function postAdminComment(id: string, formData: FormData) { export async function postAdminComment(id: string, formData: FormData) {
await requireAdmin();
const entity = formData.get("entity") as string; const entity = formData.get("entity") as string;
const body = (formData.get("body") as string)?.trim(); const body = (formData.get("body") as string)?.trim();
if (!body || !entity) throw new Error("Dati mancanti"); if (!body || !entity) throw new Error("Dati mancanti");
+16 -2
View File
@@ -4,17 +4,30 @@ import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/db"; import { db } from "@/db";
import { clients, projects, payments } from "@/db/schema"; import { clients, projects, payments } from "@/db/schema";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
function randomAlpha(len: number): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
}
function toSlug(name: string): string { function toSlug(name: string): string {
return name const base = name
.toLowerCase() .toLowerCase()
.normalize("NFD") .normalize("NFD")
.replace(/[̀-ͯ]/g, "") .replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.slice(0, 50); .slice(0, 44);
return `${base}-${randomAlpha(4)}`;
} }
async function uniqueSlug(base: string): Promise<string | null> { async function uniqueSlug(base: string): Promise<string | null> {
@@ -34,6 +47,7 @@ const createClientSchema = z.object({
}); });
export async function createClient(formData: FormData) { export async function createClient(formData: FormData) {
await requireAdmin();
const raw = { const raw = {
name: formData.get("name") as string, name: formData.get("name") as string,
brand_name: formData.get("brand_name") as string, brand_name: formData.get("brand_name") as string,
+10
View File
@@ -1,12 +1,20 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/db"; import { db } from "@/db";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
import { time_entries, projects } from "@/db/schema"; import { time_entries, projects } from "@/db/schema";
import { eq, isNull, asc } from "drizzle-orm"; import { eq, isNull, asc } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
export async function startTimer(projectId: string): Promise<{ entryId: string }> { export async function startTimer(projectId: string): Promise<{ entryId: string }> {
await requireAdmin();
// Stop any currently running session before starting a new one // Stop any currently running session before starting a new one
const running = await db const running = await db
.select({ id: time_entries.id }) .select({ id: time_entries.id })
@@ -38,6 +46,7 @@ export async function startTimer(projectId: string): Promise<{ entryId: string }
} }
export async function startTimerForClient(clientId: string): Promise<{ entryId: string }> { export async function startTimerForClient(clientId: string): Promise<{ entryId: string }> {
await requireAdmin();
const projectRows = await db const projectRows = await db
.select({ id: projects.id }) .select({ id: projects.id })
.from(projects) .from(projects)
@@ -50,6 +59,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
} }
export async function stopTimer(entryId: string): Promise<void> { export async function stopTimer(entryId: string): Promise<void> {
await requireAdmin();
const rows = await db const rows = await db
.select({ started_at: time_entries.started_at }) .select({ started_at: time_entries.started_at })
.from(time_entries) .from(time_entries)
+8 -2
View File
@@ -2,8 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { clients, deliverables, tasks, phases, projects } from "@/db/schema"; import { clients, deliverables, tasks, phases, projects } from "@/db/schema";
import { rateLimit } from "@/lib/rate-limit";
export async function POST(request: NextRequest) { 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 { try {
const body = await request.json(); const body = await request.json();
const { token, deliverableId } = body as { token?: string; deliverableId?: string }; const { token, deliverableId } = body as { token?: string; deliverableId?: string };
@@ -20,7 +26,7 @@ export async function POST(request: NextRequest) {
.limit(1); .limit(1);
if (clientRows.length === 0) { 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; const clientId = clientRows[0].id;
@@ -37,7 +43,7 @@ export async function POST(request: NextRequest) {
.limit(1); .limit(1);
if (ownershipCheck.length === 0) { 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 // 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 { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema"; import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema";
import { rateLimit } from "@/lib/rate-limit";
const commentSchema = z.object({ const commentSchema = z.object({
token: z.string().min(1), token: z.string().min(1),
@@ -12,6 +13,11 @@ const commentSchema = z.object({
}); });
export async function POST(request: NextRequest) { 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 { try {
const body = await request.json(); const body = await request.json();
const parsed = commentSchema.safeParse(body); const parsed = commentSchema.safeParse(body);
@@ -33,7 +39,7 @@ export async function POST(request: NextRequest) {
.limit(1); .limit(1);
if (clientRows.length === 0) { 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; const clientId = clientRows[0].id;
@@ -41,7 +47,7 @@ export async function POST(request: NextRequest) {
if (entity_type === "general") { if (entity_type === "general") {
// General messages: entity_id must be the client's own id // General messages: entity_id must be the client's own id
if (entity_id !== clientId) { if (entity_id !== clientId) {
return NextResponse.json({ error: "Entity non valida" }, { status: 403 }); return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
} }
} else { } else {
// Scope phases through projects → client // Scope phases through projects → client
@@ -52,7 +58,7 @@ export async function POST(request: NextRequest) {
const projectIds = clientProjects.map((p) => p.id); const projectIds = clientProjects.map((p) => p.id);
if (projectIds.length === 0) { 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 const phasesForClient = await db
@@ -62,7 +68,7 @@ export async function POST(request: NextRequest) {
const phaseIds = phasesForClient.map((p) => p.id); const phaseIds = phasesForClient.map((p) => p.id);
if (phaseIds.length === 0) { 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 const taskRows = await db
@@ -72,20 +78,20 @@ export async function POST(request: NextRequest) {
if (entity_type === "task") { if (entity_type === "task") {
if (!taskRows.find((r) => r.id === entity_id)) { 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 { } else {
// deliverable // deliverable
const taskIds = taskRows.map((r) => r.id); const taskIds = taskRows.map((r) => r.id);
if (taskIds.length === 0) { 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 const delivRows = await db
.select({ id: deliverables.id }) .select({ id: deliverables.id })
.from(deliverables) .from(deliverables)
.where(inArray(deliverables.task_id, taskIds)); .where(inArray(deliverables.task_id, taskIds));
if (!delivRows.find((r) => r.id === entity_id)) { 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"; import { clients } from "@/db/schema";
export async function GET(request: NextRequest) { 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"); const slug = request.nextUrl.searchParams.get("slug");
if (!slug) { if (!slug) {
@@ -4,6 +4,11 @@ import { db } from '@/db';
import { clients } from '@/db/schema'; import { clients } from '@/db/schema';
export async function GET(request: NextRequest) { 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'); const token = request.nextUrl.searchParams.get('token');
if (!token) { if (!token) {
+2 -1
View File
@@ -33,7 +33,8 @@ export const authOptions: NextAuthOptions = {
], ],
session: { session: {
strategy: "jwt", // stateless JWT — no DB session table (per D-03) 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: { pages: {
signIn: "/admin/login", // custom login page (per D-07) 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;
}
+8 -2
View File
@@ -43,15 +43,21 @@ export async function proxy(request: NextRequest) {
const port = process.env.PORT ?? "3000"; const port = process.env.PORT ?? "3000";
const base = `http://localhost:${port}`; 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 // Try slug first (D-06) — user-friendly slugs before token fallback
let res = await fetch( let res = await fetch(
`${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}` `${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 slug not found, fall back to token validation (existing links continue to work)
if (!res.ok) { if (!res.ok) {
res = await fetch( res = await fetch(
`${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}` `${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
{ headers: internalHeaders }
); );
} }