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:
@@ -2,7 +2,14 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
import {
|
||||
phases,
|
||||
tasks,
|
||||
@@ -56,6 +63,7 @@ const clientSchema = z.object({
|
||||
});
|
||||
|
||||
export async function updateClient(clientId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = clientSchema.safeParse({
|
||||
name: formData.get("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) {
|
||||
await requireAdmin();
|
||||
await db.delete(clients).where(eq(clients.id, clientId));
|
||||
revalidatePath("/admin");
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
export async function setClientArchived(clientId: string, archived: boolean) {
|
||||
await requireAdmin();
|
||||
await db.update(clients).set({ archived }).where(eq(clients.id, clientId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
revalidatePath("/admin");
|
||||
@@ -92,6 +102,7 @@ export async function setClientArchived(clientId: string, archived: boolean) {
|
||||
// ── PHASES ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addPhase(id: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
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) {
|
||||
await requireAdmin();
|
||||
const allowed = ["upcoming", "active", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addTask(phaseId: string, id: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
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) {
|
||||
await requireAdmin();
|
||||
const allowed = ["todo", "in_progress", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addDeliverable(taskId: string, id: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
const url = (formData.get("url") as string)?.trim() || null;
|
||||
if (!title) throw new Error("Titolo deliverable richiesto");
|
||||
@@ -171,6 +186,7 @@ const docSchema = z.object({
|
||||
});
|
||||
|
||||
export async function addDocument(id: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = docSchema.safeParse({
|
||||
label: formData.get("label"),
|
||||
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) {
|
||||
await requireAdmin();
|
||||
const parsed = docSchema.safeParse({
|
||||
label: formData.get("label"),
|
||||
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) {
|
||||
await requireAdmin();
|
||||
await db.delete(documents).where(eq(documents.id, documentId));
|
||||
const { path } = await resolveEntity(id);
|
||||
revalidatePath(path);
|
||||
@@ -204,6 +222,7 @@ export async function deleteDocument(documentId: string, id: string) {
|
||||
// ── PAYMENTS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function updatePaymentStatus(paymentId: string, id: string, status: string) {
|
||||
await requireAdmin();
|
||||
const allowed = ["da_saldare", "inviata", "saldato"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
|
||||
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) {
|
||||
await requireAdmin();
|
||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||
const val = parseFloat(raw);
|
||||
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) ────────────────────────────────────────────────────
|
||||
|
||||
export async function postAdminComment(id: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const entity = formData.get("entity") as string;
|
||||
const body = (formData.get("body") as string)?.trim();
|
||||
if (!body || !entity) throw new Error("Dati mancanti");
|
||||
|
||||
@@ -4,17 +4,30 @@ import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
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 {
|
||||
return name
|
||||
const base = name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 50);
|
||||
.slice(0, 44);
|
||||
return `${base}-${randomAlpha(4)}`;
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string): Promise<string | null> {
|
||||
@@ -34,6 +47,7 @@ const createClientSchema = z.object({
|
||||
});
|
||||
|
||||
export async function createClient(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const raw = {
|
||||
name: formData.get("name") as string,
|
||||
brand_name: formData.get("brand_name") as string,
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
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 { eq, isNull, asc } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function startTimer(projectId: string): Promise<{ entryId: string }> {
|
||||
await requireAdmin();
|
||||
// Stop any currently running session before starting a new one
|
||||
const running = await db
|
||||
.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 }> {
|
||||
await requireAdmin();
|
||||
const projectRows = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
@@ -50,6 +59,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
|
||||
}
|
||||
|
||||
export async function stopTimer(entryId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
.select({ started_at: time_entries.started_at })
|
||||
.from(time_entries)
|
||||
|
||||
Reference in New Issue
Block a user