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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user