From 969d5d4c8abdf61c874defe5ffaecb45984da48e Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:56:34 -0400 Subject: [PATCH] Security hardening from 2026-07-01 audit - Exclude supabase/ from Docker build context (leaked service_role key file) - /api/files: exact per-user namespace match + reject path traversal; storage resolveKey rejects ".."/"." segments (fixes cross-user file read) - Add ownsProperty/Unit/Tenant checks to tenants, maintenance (landlord path), and documents (JSON branch, now field-whitelisted) create handlers - Escape user data in follow-up + payment-link emails (reuse escapeHtml) - Neutralize CSV formula injection in toCsv + export routes - Tighter sign-in rate limit (10/min); env-gated email verification + sender - Per-request nonce CSP; drop script-src 'unsafe-inline' (styles unchanged) - Add input length bounds; validate follow-ups POST body Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 1 + app/api/documents/route.ts | 25 ++++++++- app/api/expenses/export/route.ts | 11 ++-- app/api/export/rent/route.ts | 26 +++++---- app/api/export/tenants/route.ts | 28 +++++----- app/api/files/[...key]/route.ts | 10 +++- app/api/follow-ups/route.ts | 10 ++-- app/api/follow-ups/run/route.ts | 4 +- app/api/maintenance/route.ts | 12 +++++ app/api/rent/send-payment-link/route.ts | 8 +-- app/api/tenants/route.ts | 8 +++ lib/auth.ts | 43 +++++++++++++-- lib/db/admin-queries.ts | 6 ++- lib/email/send.ts | 2 +- lib/storage.ts | 3 ++ lib/validations/index.ts | 71 ++++++++++++++----------- next.config.ts | 22 ++------ proxy.ts | 37 ++++++++++++- 18 files changed, 224 insertions(+), 103 deletions(-) diff --git a/.dockerignore b/.dockerignore index 8da72d1..e35593a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ coverage # Secrets — never bake env files into the image .env .env.* +supabase # Local file storage (uploads live on a mounted volume, not in the image) storage diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index 19c8bd0..0cddab8 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -4,6 +4,7 @@ import { db } from "@/lib/db" import { documents, properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { saveFile } from "@/lib/storage" +import { ownsProperty, ownsTenant } from "@/lib/db/ownership" export async function GET(request: Request) { const user = await getSessionUser() @@ -76,9 +77,31 @@ export async function POST(request: Request) { // JSON fallback (metadata only) const body = (await request.json()) as Record + const propertyId = body.property_id as string | undefined + const tenantId = body.tenant_id as string | undefined + + // Verify the property/tenant belong to the user before attaching a document. + if (!(await ownsProperty(user.id, propertyId))) { + return NextResponse.json({ error: "Property not found" }, { status: 404 }) + } + if (!(await ownsTenant(user.id, tenantId))) { + return NextResponse.json({ error: "Tenant not found" }, { status: 404 }) + } + + // Whitelist insertable columns — never trust client-supplied user_id/id/created_at. const [data] = await db .insert(documents) - .values({ ...(body as typeof documents.$inferInsert), user_id: user.id }) + .values({ + user_id: user.id, + property_id: propertyId as string, + tenant_id: tenantId, + name: body.name as string, + category: (body.category as typeof documents.$inferInsert.category) ?? "other", + file_url: body.file_url as string, + storage_path: body.storage_path as string | undefined, + file_type: body.file_type as string | undefined, + file_size: body.file_size as number | undefined, + }) .returning() return NextResponse.json(data, { status: 201 }) diff --git a/app/api/expenses/export/route.ts b/app/api/expenses/export/route.ts index ba68ac6..54ca5b2 100644 --- a/app/api/expenses/export/route.ts +++ b/app/api/expenses/export/route.ts @@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { expenses } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { toCsv } from "@/lib/db/admin-queries" export async function GET(request: Request) { const user = await getSessionUser() @@ -22,9 +23,9 @@ export async function GET(request: Request) { orderBy: desc(expenses.expense_date), }) - const rows = [ + const csv = toCsv( ["Date", "Description", "Category", "Amount", "Property", "Vendor", "Recurring", "Recurrence", "Notes"], - ...data.map((e) => [ + data.map((e) => [ e.expense_date, e.description, e.category, @@ -34,10 +35,8 @@ export async function GET(request: Request) { e.is_recurring ? "Yes" : "No", e.recurrence ?? "", e.notes ?? "", - ]), - ] - - const csv = rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")).join("\n") + ]) + ) return new Response(csv, { headers: { diff --git a/app/api/export/rent/route.ts b/app/api/export/rent/route.ts index 1d60045..831e9ad 100644 --- a/app/api/export/rent/route.ts +++ b/app/api/export/rent/route.ts @@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { rent_payments } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { toCsv } from "@/lib/db/admin-queries" export async function GET() { const user = await getSessionUser() @@ -18,24 +19,21 @@ export async function GET() { orderBy: desc(rent_payments.due_date), }) - const headers = ["Tenant", "Email", "Property", "Unit", "Amount", "Due Date", "Paid Date", "Status", "Method", "Notes"] - const lines = [ - headers.join(","), - ...rows.map((p) => [ - `"${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}"`, - `"${p.tenant?.email ?? ""}"`, - `"${p.property?.name ?? ""}"`, - `"${p.unit?.unit_number ?? ""}"`, + const csv = toCsv( + ["Tenant", "Email", "Property", "Unit", "Amount", "Due Date", "Paid Date", "Status", "Method", "Notes"], + rows.map((p) => [ + `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`, + p.tenant?.email ?? "", + p.property?.name ?? "", + p.unit?.unit_number ?? "", p.amount ?? 0, p.due_date ?? "", p.paid_date ?? "", p.status ?? "", - `"${p.payment_method ?? ""}"`, - `"${(p.notes ?? "").replace(/"/g, "'")}"`, - ].join(",")) - ] - - const csv = lines.join("\n") + p.payment_method ?? "", + p.notes ?? "", + ]) + ) return new NextResponse(csv, { headers: { diff --git a/app/api/export/tenants/route.ts b/app/api/export/tenants/route.ts index 3d7f109..0b319eb 100644 --- a/app/api/export/tenants/route.ts +++ b/app/api/export/tenants/route.ts @@ -3,6 +3,7 @@ import { asc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { tenants } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { toCsv } from "@/lib/db/admin-queries" export async function GET() { const user = await getSessionUser() @@ -17,23 +18,20 @@ export async function GET() { orderBy: asc(tenants.last_name), }) - const headers = ["First Name", "Last Name", "Email", "Phone", "Property", "Unit", "Status", "Move In Date", "Notes"] - const lines = [ - headers.join(","), - ...rows.map((t) => [ - `"${t.first_name ?? ""}"`, - `"${t.last_name ?? ""}"`, - `"${t.email ?? ""}"`, - `"${t.phone ?? ""}"`, - `"${t.property?.name ?? ""}"`, - `"${t.unit?.unit_number ?? ""}"`, + const csv = toCsv( + ["First Name", "Last Name", "Email", "Phone", "Property", "Unit", "Status", "Move In Date", "Notes"], + rows.map((t) => [ + t.first_name ?? "", + t.last_name ?? "", + t.email ?? "", + t.phone ?? "", + t.property?.name ?? "", + t.unit?.unit_number ?? "", t.status ?? "", t.move_in_date ?? "", - `"${(t.notes ?? "").replace(/"/g, "'")}"`, - ].join(",")) - ] - - const csv = lines.join("\n") + t.notes ?? "", + ]) + ) return new NextResponse(csv, { headers: { diff --git a/app/api/files/[...key]/route.ts b/app/api/files/[...key]/route.ts index 0f42079..ebac692 100644 --- a/app/api/files/[...key]/route.ts +++ b/app/api/files/[...key]/route.ts @@ -14,12 +14,18 @@ export async function GET(_: Request, { params }: { params: Promise<{ key: strin if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) const { key: segments } = await params - const key = segments.map((s) => decodeURIComponent(s)).join("/") - if (!key.startsWith(`${user.id}/`)) { + // Reject traversal / malformed segments (no double-decode — params are already decoded). + const badSegment = segments.some( + (s) => s === "" || s === "." || s === ".." || s.includes("/") || s.includes("\\") + ) + // Ownership: the first path segment must be EXACTLY the caller's user id. + if (badSegment || segments[0] !== user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } + const key = segments.join("/") + try { const buffer = await readFile(key) diff --git a/app/api/follow-ups/route.ts b/app/api/follow-ups/route.ts index 6cbc6c5..062d8ae 100644 --- a/app/api/follow-ups/route.ts +++ b/app/api/follow-ups/route.ts @@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { follow_up_rules, follow_up_log } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { followUpRuleSchema } from "@/lib/validations" export async function GET() { const user = await getSessionUser() @@ -30,13 +31,16 @@ export async function POST(request: Request) { if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) const body = await request.json() - const { type, name, trigger_days, message_template } = body + const parsed = followUpRuleSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 }) + } - if (!type || !name) return NextResponse.json({ error: "type and name required" }, { status: 400 }) + const { type, name, trigger_days, message_template } = parsed.data const [data] = await db .insert(follow_up_rules) - .values({ user_id: user.id, type, name, trigger_days: trigger_days ?? 3, message_template }) + .values({ user_id: user.id, type, name, trigger_days, message_template }) .returning() return NextResponse.json(data, { status: 201 }) diff --git a/app/api/follow-ups/run/route.ts b/app/api/follow-ups/run/route.ts index bfe3313..2869f60 100644 --- a/app/api/follow-ups/run/route.ts +++ b/app/api/follow-ups/run/route.ts @@ -11,7 +11,7 @@ import { } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { logActivity } from "@/lib/activity" -import { sendEmail } from "@/lib/email/send" +import { sendEmail, escapeHtml } from "@/lib/email/send" export async function POST() { const user = await getSessionUser() @@ -170,7 +170,7 @@ export async function POST() {
-

${log.message.replace(/\n/g, "
")}

+

${escapeHtml(log.message).replace(/\n/g, "
")}

Property Management Network — Automated Follow-up System

diff --git a/app/api/maintenance/route.ts b/app/api/maintenance/route.ts index c264828..a173f34 100644 --- a/app/api/maintenance/route.ts +++ b/app/api/maintenance/route.ts @@ -4,6 +4,7 @@ import { db } from "@/lib/db" import { maintenance_requests, tenants } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { maintenanceSchema } from "@/lib/validations" +import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership" import { logActivity } from "@/lib/activity" const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"] @@ -85,6 +86,17 @@ export async function POST(request: Request) { const parsed = maintenanceSchema.safeParse(body) if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + // Authenticated-landlord path: verify the caller owns the submitted references. + // (The portal-token path already validates these against the tenant above.) + if ( + user && + (!(await ownsProperty(user.id, parsed.data.property_id)) || + !(await ownsUnit(user.id, parsed.data.unit_id)) || + !(await ownsTenant(user.id, parsed.data.tenant_id))) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + const [data] = await db .insert(maintenance_requests) .values({ ...parsed.data, user_id: userId, status: "open" }) diff --git a/app/api/rent/send-payment-link/route.ts b/app/api/rent/send-payment-link/route.ts index f95d231..ffdb512 100644 --- a/app/api/rent/send-payment-link/route.ts +++ b/app/api/rent/send-payment-link/route.ts @@ -3,7 +3,7 @@ import { and, eq, sql } from "drizzle-orm" import { db } from "@/lib/db" import { rent_payments, profiles } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" -import { sendEmail } from "@/lib/email/send" +import { sendEmail, escapeHtml } from "@/lib/email/send" import { paymentLinkSchema } from "@/lib/validations" export async function POST(request: Request) { @@ -48,16 +48,16 @@ export async function POST(request: Request) {

Property Management Network

-

Hi ${tenantName},

+

Hi ${escapeHtml(tenantName)},

Your rent payment of ${amount} is due on ${dueDate} - for ${payment.property?.name}${payment.unit ? ` Unit ${payment.unit.unit_number}` : ""}. + for ${escapeHtml(payment.property?.name)}${payment.unit ? ` Unit ${escapeHtml(payment.unit.unit_number)}` : ""}.

Please arrange payment at your earliest convenience. Contact your landlord if you have any questions.

- Sent by ${profile?.full_name ?? "Your Landlord"} via Property Management Network + Sent by ${escapeHtml(profile?.full_name ?? "Your Landlord")} via Property Management Network

diff --git a/app/api/tenants/route.ts b/app/api/tenants/route.ts index 0e7cd82..63a7073 100644 --- a/app/api/tenants/route.ts +++ b/app/api/tenants/route.ts @@ -4,6 +4,7 @@ import { db } from "@/lib/db" import { tenants, units } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { tenantSchema } from "@/lib/validations" +import { ownsProperty, ownsUnit } from "@/lib/db/ownership" import { logActivity } from "@/lib/activity" export async function GET(request: Request) { @@ -53,6 +54,13 @@ export async function POST(request: Request) { const parsed = tenantSchema.safeParse(body) if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + if ( + !(await ownsProperty(user.id, parsed.data.property_id)) || + !(await ownsUnit(user.id, parsed.data.unit_id)) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + const [tenant] = await db .insert(tenants) .values({ ...parsed.data, user_id: user.id }) diff --git a/lib/auth.ts b/lib/auth.ts index 54eea3f..dc3550c 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -21,9 +21,10 @@ export const auth = betterAuth({ }), emailAndPassword: { enabled: true, - // Login works immediately; flip to true once verification email is desired. - // Recommended for production: set requireEmailVerification to true. - requireEmailVerification: false, + // Env-gated so production can require a verified email without breaking + // local dev (where RESEND is typically unconfigured). Set + // REQUIRE_EMAIL_VERIFICATION=true in production to enforce. + requireEmailVerification: process.env.REQUIRE_EMAIL_VERIFICATION === "true", minPasswordLength: 8, sendResetPassword: async ({ user: u, url }) => { await sendEmail({ @@ -33,6 +34,18 @@ export const auth = betterAuth({ }) }, }, + // Send a verification email on sign-up. Enforcement of verified-email login + // is gated by REQUIRE_EMAIL_VERIFICATION (see emailAndPassword above). + emailVerification: { + sendOnSignUp: true, + sendVerificationEmail: async ({ user: u, url }) => { + await sendEmail({ + to: u.email, + subject: "Verify your email — Property Management Network", + html: verifyEmailHtml(url), + }) + }, + }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID ?? "", @@ -44,6 +57,11 @@ export const auth = betterAuth({ enabled: true, window: 60, // seconds max: 20, // requests per window per IP for auth endpoints + customRules: { + // Tighter limit on the password sign-in endpoint to slow credential + // stuffing / brute-force attempts. + "/sign-in/email": { window: 60, max: 10 }, + }, }, // Auto-create the app `profiles` row whenever Better Auth creates a user // (replaces the old `handle_new_user` Postgres trigger). @@ -88,3 +106,22 @@ function resetPasswordHtml(url: string) { ` } + +function verifyEmailHtml(url: string) { + return ` + + + +
+

Verify your email

+

+ Confirm your email address to finish setting up your account. If you didn't create an account, you can ignore this email. +

+ + Verify Email + +

Property Management Network

+
+ +` +} diff --git a/lib/db/admin-queries.ts b/lib/db/admin-queries.ts index faa8b14..802622e 100644 --- a/lib/db/admin-queries.ts +++ b/lib/db/admin-queries.ts @@ -402,7 +402,11 @@ export async function getUserDetail(id: string) { // ── CSV helpers (shared by admin export routes) ───────────────────────────────── export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) { const esc = (v: string | number | null | undefined) => { - const s = v === null || v === undefined ? "" : String(v) + let s = v === null || v === undefined ? "" : String(v) + // Neutralize spreadsheet formula injection: values that begin with a + // formula trigger (= + - @) or a control char (tab/CR) are prefixed with a + // single quote so Excel/Sheets treat them as text, not executable formulas. + if (/^[=+\-@\t\r]/.test(s)) s = `'${s}` return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s } return [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n") diff --git a/lib/email/send.ts b/lib/email/send.ts index ff63f09..da2cd1b 100644 --- a/lib/email/send.ts +++ b/lib/email/send.ts @@ -1,6 +1,6 @@ import { resend, FROM_EMAIL, APP_NAME } from "./client" -function escapeHtml(value: unknown): string { +export function escapeHtml(value: unknown): string { return String(value ?? "") .replace(/&/g, "&") .replace(/ seg === ".." || seg === ".")) { + throw new Error("Invalid storage path") + } const abs = path.resolve(STORAGE_DIR, clean) if (abs !== STORAGE_DIR && !abs.startsWith(STORAGE_DIR + path.sep)) { throw new Error("Invalid storage path") diff --git a/lib/validations/index.ts b/lib/validations/index.ts index 366bb94..d0cea36 100644 --- a/lib/validations/index.ts +++ b/lib/validations/index.ts @@ -1,41 +1,41 @@ import { z } from "zod" export const propertySchema = z.object({ - name: z.string().min(1, "Property name is required"), - address_line1: z.string().min(1, "Address is required"), - address_line2: z.string().optional(), - city: z.string().min(1, "City is required"), - state: z.string().optional(), - postal_code: z.string().optional(), - country: z.string().default("US"), + name: z.string().min(1, "Property name is required").max(200), + address_line1: z.string().min(1, "Address is required").max(300), + address_line2: z.string().max(300).optional(), + city: z.string().min(1, "City is required").max(120), + state: z.string().max(120).optional(), + postal_code: z.string().max(20).optional(), + country: z.string().max(120).default("US"), property_type: z.enum(["residential", "commercial", "mixed"]).default("residential"), total_units: z.number().int().min(1).default(1), - notes: z.string().optional(), + notes: z.string().max(5000).optional(), image_url: z.string().url().nullable().optional(), }) export const unitSchema = z.object({ property_id: z.string().uuid(), - unit_number: z.string().min(1, "Unit number is required"), + unit_number: z.string().min(1, "Unit number is required").max(50), bedrooms: z.number().int().min(0).default(1), bathrooms: z.number().min(0).default(1), sq_ft: z.number().int().positive().optional(), rent_amount: z.number().positive("Rent amount must be positive"), status: z.enum(["vacant", "occupied", "maintenance", "unavailable"]).default("vacant"), - notes: z.string().optional(), + notes: z.string().max(5000).optional(), }) export const tenantSchema = z.object({ property_id: z.string().uuid(), unit_id: z.string().uuid().optional(), - first_name: z.string().min(1, "First name is required"), - last_name: z.string().min(1, "Last name is required"), - email: z.string().email("Invalid email").optional().or(z.literal("")), - phone: z.string().optional(), - emergency_contact_name: z.string().optional(), - emergency_contact_phone: z.string().optional(), + first_name: z.string().min(1, "First name is required").max(100), + last_name: z.string().min(1, "Last name is required").max(100), + email: z.string().email("Invalid email").max(254).optional().or(z.literal("")), + phone: z.string().max(40).optional(), + emergency_contact_name: z.string().max(200).optional(), + emergency_contact_phone: z.string().max(40).optional(), move_in_date: z.string().optional(), - notes: z.string().optional(), + notes: z.string().max(5000).optional(), }) export const rentPaymentSchema = z.object({ @@ -46,19 +46,19 @@ export const rentPaymentSchema = z.object({ due_date: z.string().min(1, "Due date is required"), paid_date: z.string().optional(), status: z.enum(["pending", "paid", "overdue", "partial", "waived"]).default("pending"), - payment_method: z.string().optional(), - notes: z.string().optional(), + payment_method: z.string().max(100).optional(), + notes: z.string().max(5000).optional(), }) export const maintenanceSchema = z.object({ property_id: z.string().uuid(), unit_id: z.string().uuid().optional(), tenant_id: z.string().uuid().optional(), - title: z.string().min(1, "Title is required"), - description: z.string().min(1, "Description is required"), + title: z.string().min(1, "Title is required").max(200), + description: z.string().min(1, "Description is required").max(5000), category: z.enum(["plumbing", "electrical", "hvac", "appliance", "structural", "pest", "general"]).default("general"), priority: z.enum(["low", "medium", "high", "emergency"]).default("medium"), - assigned_to: z.string().optional(), + assigned_to: z.string().max(200).optional(), estimated_cost: z.number().positive().optional(), }) @@ -72,20 +72,20 @@ export const leaseSchema = z.object({ security_deposit: z.number().positive().optional(), lease_type: z.enum(["fixed", "month_to_month"]).default("fixed"), auto_renew: z.boolean().default(false), - notes: z.string().optional(), + notes: z.string().max(5000).optional(), }) export const expenseSchema = z.object({ property_id: z.string().uuid(), unit_id: z.string().uuid().optional(), category: z.enum(["repairs", "utilities", "insurance", "mortgage", "taxes", "management", "supplies", "other"]), - description: z.string().min(1, "Description is required"), + description: z.string().min(1, "Description is required").max(500), amount: z.number().positive("Amount must be positive"), expense_date: z.string().min(1, "Date is required"), - vendor: z.string().optional(), + vendor: z.string().max(200).optional(), is_recurring: z.boolean().default(false), recurrence: z.enum(["monthly", "quarterly", "yearly"]).optional(), - notes: z.string().optional(), + notes: z.string().max(5000).optional(), }) export type PropertyFormValues = z.infer @@ -95,11 +95,11 @@ export type RentPaymentFormValues = z.infer export type MaintenanceFormValues = z.infer export type LeaseFormValues = z.infer export const vendorSchema = z.object({ - name: z.string().min(1, "Vendor name is required"), - trade: z.string().optional(), - phone: z.string().optional(), - email: z.string().email("Invalid email").optional().or(z.literal("")), - notes: z.string().optional(), + name: z.string().min(1, "Vendor name is required").max(200), + trade: z.string().max(120).optional(), + phone: z.string().max(40).optional(), + email: z.string().email("Invalid email").max(254).optional().or(z.literal("")), + notes: z.string().max(5000).optional(), property_id: z.string().uuid().optional().or(z.literal("")), }) @@ -108,13 +108,20 @@ export const inspectionSchema = z.object({ unit_id: z.string().uuid().optional().or(z.literal("")), type: z.enum(["move_in", "move_out", "routine"]), date: z.string().min(1, "Date is required"), - notes: z.string().optional(), + notes: z.string().max(5000).optional(), }) export const paymentLinkSchema = z.object({ payment_id: z.string().uuid("Valid payment ID is required"), }) +export const followUpRuleSchema = z.object({ + type: z.enum(["overdue_rent", "maintenance_stale", "lease_renewal", "vacant_unit"]), + name: z.string().min(1, "Name is required").max(200), + trigger_days: z.number().int().min(0).max(3650).default(3), + message_template: z.string().max(5000).optional(), +}) + export type ExpenseFormValues = z.infer export type VendorFormValues = z.infer export type InspectionFormValues = z.infer diff --git a/next.config.ts b/next.config.ts index 6cbebb4..f000741 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,22 +1,9 @@ import type { NextConfig } from "next"; -// Pragmatic baseline Content-Security-Policy for a Next.js app. -// NOTE: Next 16 commonly needs 'unsafe-inline' for styles/scripts when no -// nonce pipeline is configured. Tightening this to per-request nonces -// (removing 'unsafe-inline') is a recommended follow-up. -const contentSecurityPolicy = [ - "default-src 'self'", - "img-src 'self' data: blob: https:", - "style-src 'self' 'unsafe-inline'", - "script-src 'self' 'unsafe-inline'", - "font-src 'self' data:", - "connect-src 'self' https://api.stripe.com https://api.openai.com https://api.resend.com", - "frame-src https://js.stripe.com https://hooks.stripe.com", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", -].join("; "); - +// NOTE: The Content-Security-Policy is set per-request in `proxy.ts` (Next +// middleware) so `script-src` can carry a per-request nonce instead of +// 'unsafe-inline'. A static header here cannot carry a per-request nonce and +// would conflict with the middleware, so it is intentionally omitted below. const nextConfig: NextConfig = { // Emit a self-contained server bundle at .next/standalone so the Docker // image (used by Coolify) ships only the files needed to run `node server.js`. @@ -38,7 +25,6 @@ const nextConfig: NextConfig = { value: "max-age=63072000; includeSubDomains; preload", }, { key: "X-DNS-Prefetch-Control", value: "off" }, - { key: "Content-Security-Policy", value: contentSecurityPolicy }, ], }, ]; diff --git a/proxy.ts b/proxy.ts index f89abde..a1d187e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -27,6 +27,27 @@ const PROTECTED_PATHS = [ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"] +// Build the per-request Content-Security-Policy. `script-src` carries a +// per-request nonce instead of 'unsafe-inline'. `style-src` keeps +// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline +// styles and removing it would break the UI. Next.js reads the nonce from the +// `content-security-policy` request header and applies it to its own inline +// scripts automatically. +function buildCsp(nonce: string): string { + return [ + "default-src 'self'", + "img-src 'self' data: blob: https:", + "style-src 'self' 'unsafe-inline'", + `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`, + "font-src 'self' data:", + "connect-src 'self' https://api.stripe.com https://api.openai.com https://api.resend.com https://challenges.cloudflare.com", + "frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; ") +} + export async function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname @@ -48,7 +69,21 @@ export async function proxy(request: NextRequest) { return NextResponse.redirect(url) } - return NextResponse.next() + // Per-request CSP nonce. UUID contains only hex + dashes, so it never + // includes HTML-escape characters (which Next rejects in nonces). + const nonce = crypto.randomUUID() + const csp = buildCsp(nonce) + + // Forward the nonce + CSP on the request headers so Next.js can pick up the + // nonce and apply it to its own inline scripts during render. + const requestHeaders = new Headers(request.headers) + requestHeaders.set("x-nonce", nonce) + requestHeaders.set("Content-Security-Policy", csp) + + const response = NextResponse.next({ request: { headers: requestHeaders } }) + // Also set the CSP on the outgoing response so the browser enforces it. + response.headers.set("Content-Security-Policy", csp) + return response } export const config = {