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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
857b9a7811
commit
969d5d4c8a
@@ -8,6 +8,7 @@ coverage
|
|||||||
# Secrets — never bake env files into the image
|
# Secrets — never bake env files into the image
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
supabase
|
||||||
|
|
||||||
# Local file storage (uploads live on a mounted volume, not in the image)
|
# Local file storage (uploads live on a mounted volume, not in the image)
|
||||||
storage
|
storage
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from "@/lib/db"
|
|||||||
import { documents, properties } from "@/lib/db/schema"
|
import { documents, properties } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
import { saveFile } from "@/lib/storage"
|
import { saveFile } from "@/lib/storage"
|
||||||
|
import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -76,9 +77,31 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// JSON fallback (metadata only)
|
// JSON fallback (metadata only)
|
||||||
const body = (await request.json()) as Record<string, unknown>
|
const body = (await request.json()) as Record<string, unknown>
|
||||||
|
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
|
const [data] = await db
|
||||||
.insert(documents)
|
.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()
|
.returning()
|
||||||
|
|
||||||
return NextResponse.json(data, { status: 201 })
|
return NextResponse.json(data, { status: 201 })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm"
|
|||||||
import { db } from "@/lib/db"
|
import { db } from "@/lib/db"
|
||||||
import { expenses } from "@/lib/db/schema"
|
import { expenses } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { toCsv } from "@/lib/db/admin-queries"
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -22,9 +23,9 @@ export async function GET(request: Request) {
|
|||||||
orderBy: desc(expenses.expense_date),
|
orderBy: desc(expenses.expense_date),
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = [
|
const csv = toCsv(
|
||||||
["Date", "Description", "Category", "Amount", "Property", "Vendor", "Recurring", "Recurrence", "Notes"],
|
["Date", "Description", "Category", "Amount", "Property", "Vendor", "Recurring", "Recurrence", "Notes"],
|
||||||
...data.map((e) => [
|
data.map((e) => [
|
||||||
e.expense_date,
|
e.expense_date,
|
||||||
e.description,
|
e.description,
|
||||||
e.category,
|
e.category,
|
||||||
@@ -34,10 +35,8 @@ export async function GET(request: Request) {
|
|||||||
e.is_recurring ? "Yes" : "No",
|
e.is_recurring ? "Yes" : "No",
|
||||||
e.recurrence ?? "",
|
e.recurrence ?? "",
|
||||||
e.notes ?? "",
|
e.notes ?? "",
|
||||||
]),
|
])
|
||||||
]
|
)
|
||||||
|
|
||||||
const csv = rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")).join("\n")
|
|
||||||
|
|
||||||
return new Response(csv, {
|
return new Response(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
|
|||||||
import { db } from "@/lib/db"
|
import { db } from "@/lib/db"
|
||||||
import { rent_payments } from "@/lib/db/schema"
|
import { rent_payments } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { toCsv } from "@/lib/db/admin-queries"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -18,24 +19,21 @@ export async function GET() {
|
|||||||
orderBy: desc(rent_payments.due_date),
|
orderBy: desc(rent_payments.due_date),
|
||||||
})
|
})
|
||||||
|
|
||||||
const headers = ["Tenant", "Email", "Property", "Unit", "Amount", "Due Date", "Paid Date", "Status", "Method", "Notes"]
|
const csv = toCsv(
|
||||||
const lines = [
|
["Tenant", "Email", "Property", "Unit", "Amount", "Due Date", "Paid Date", "Status", "Method", "Notes"],
|
||||||
headers.join(","),
|
rows.map((p) => [
|
||||||
...rows.map((p) => [
|
`${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`,
|
||||||
`"${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}"`,
|
p.tenant?.email ?? "",
|
||||||
`"${p.tenant?.email ?? ""}"`,
|
p.property?.name ?? "",
|
||||||
`"${p.property?.name ?? ""}"`,
|
p.unit?.unit_number ?? "",
|
||||||
`"${p.unit?.unit_number ?? ""}"`,
|
|
||||||
p.amount ?? 0,
|
p.amount ?? 0,
|
||||||
p.due_date ?? "",
|
p.due_date ?? "",
|
||||||
p.paid_date ?? "",
|
p.paid_date ?? "",
|
||||||
p.status ?? "",
|
p.status ?? "",
|
||||||
`"${p.payment_method ?? ""}"`,
|
p.payment_method ?? "",
|
||||||
`"${(p.notes ?? "").replace(/"/g, "'")}"`,
|
p.notes ?? "",
|
||||||
].join(","))
|
])
|
||||||
]
|
)
|
||||||
|
|
||||||
const csv = lines.join("\n")
|
|
||||||
|
|
||||||
return new NextResponse(csv, {
|
return new NextResponse(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { asc, eq } from "drizzle-orm"
|
|||||||
import { db } from "@/lib/db"
|
import { db } from "@/lib/db"
|
||||||
import { tenants } from "@/lib/db/schema"
|
import { tenants } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { toCsv } from "@/lib/db/admin-queries"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -17,23 +18,20 @@ export async function GET() {
|
|||||||
orderBy: asc(tenants.last_name),
|
orderBy: asc(tenants.last_name),
|
||||||
})
|
})
|
||||||
|
|
||||||
const headers = ["First Name", "Last Name", "Email", "Phone", "Property", "Unit", "Status", "Move In Date", "Notes"]
|
const csv = toCsv(
|
||||||
const lines = [
|
["First Name", "Last Name", "Email", "Phone", "Property", "Unit", "Status", "Move In Date", "Notes"],
|
||||||
headers.join(","),
|
rows.map((t) => [
|
||||||
...rows.map((t) => [
|
t.first_name ?? "",
|
||||||
`"${t.first_name ?? ""}"`,
|
t.last_name ?? "",
|
||||||
`"${t.last_name ?? ""}"`,
|
t.email ?? "",
|
||||||
`"${t.email ?? ""}"`,
|
t.phone ?? "",
|
||||||
`"${t.phone ?? ""}"`,
|
t.property?.name ?? "",
|
||||||
`"${t.property?.name ?? ""}"`,
|
t.unit?.unit_number ?? "",
|
||||||
`"${t.unit?.unit_number ?? ""}"`,
|
|
||||||
t.status ?? "",
|
t.status ?? "",
|
||||||
t.move_in_date ?? "",
|
t.move_in_date ?? "",
|
||||||
`"${(t.notes ?? "").replace(/"/g, "'")}"`,
|
t.notes ?? "",
|
||||||
].join(","))
|
])
|
||||||
]
|
)
|
||||||
|
|
||||||
const csv = lines.join("\n")
|
|
||||||
|
|
||||||
return new NextResponse(csv, {
|
return new NextResponse(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ export async function GET(_: Request, { params }: { params: Promise<{ key: strin
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { key: segments } = await params
|
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 })
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = segments.join("/")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await readFile(key)
|
const buffer = await readFile(key)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
|
|||||||
import { db } from "@/lib/db"
|
import { db } from "@/lib/db"
|
||||||
import { follow_up_rules, follow_up_log } from "@/lib/db/schema"
|
import { follow_up_rules, follow_up_log } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { followUpRuleSchema } from "@/lib/validations"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -30,13 +31,16 @@ export async function POST(request: Request) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
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
|
const [data] = await db
|
||||||
.insert(follow_up_rules)
|
.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()
|
.returning()
|
||||||
|
|
||||||
return NextResponse.json(data, { status: 201 })
|
return NextResponse.json(data, { status: 201 })
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
import { logActivity } from "@/lib/activity"
|
import { logActivity } from "@/lib/activity"
|
||||||
import { sendEmail } from "@/lib/email/send"
|
import { sendEmail, escapeHtml } from "@/lib/email/send"
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -170,7 +170,7 @@ export async function POST() {
|
|||||||
<html>
|
<html>
|
||||||
<body style="font-family:sans-serif;background:#09090b;color:#fff;padding:40px 20px;max-width:560px;margin:0 auto;">
|
<body style="font-family:sans-serif;background:#09090b;color:#fff;padding:40px 20px;max-width:560px;margin:0 auto;">
|
||||||
<div style="background:#16161f;border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:32px;">
|
<div style="background:#16161f;border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:32px;">
|
||||||
<p style="font-size:15px;line-height:1.6;color:rgba(255,255,255,0.8);margin:0 0 24px;">${log.message.replace(/\n/g, "<br/>")}</p>
|
<p style="font-size:15px;line-height:1.6;color:rgba(255,255,255,0.8);margin:0 0 24px;">${escapeHtml(log.message).replace(/\n/g, "<br/>")}</p>
|
||||||
<p style="color:rgba(255,255,255,0.3);font-size:11px;margin:24px 0 0;border-top:1px solid rgba(255,255,255,0.06);padding-top:16px;">
|
<p style="color:rgba(255,255,255,0.3);font-size:11px;margin:24px 0 0;border-top:1px solid rgba(255,255,255,0.06);padding-top:16px;">
|
||||||
Property Management Network — Automated Follow-up System
|
Property Management Network — Automated Follow-up System
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from "@/lib/db"
|
|||||||
import { maintenance_requests, tenants } from "@/lib/db/schema"
|
import { maintenance_requests, tenants } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
import { maintenanceSchema } from "@/lib/validations"
|
import { maintenanceSchema } from "@/lib/validations"
|
||||||
|
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
|
||||||
import { logActivity } from "@/lib/activity"
|
import { logActivity } from "@/lib/activity"
|
||||||
|
|
||||||
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
|
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
|
||||||
@@ -85,6 +86,17 @@ export async function POST(request: Request) {
|
|||||||
const parsed = maintenanceSchema.safeParse(body)
|
const parsed = maintenanceSchema.safeParse(body)
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
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
|
const [data] = await db
|
||||||
.insert(maintenance_requests)
|
.insert(maintenance_requests)
|
||||||
.values({ ...parsed.data, user_id: userId, status: "open" })
|
.values({ ...parsed.data, user_id: userId, status: "open" })
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { and, eq, sql } from "drizzle-orm"
|
|||||||
import { db } from "@/lib/db"
|
import { db } from "@/lib/db"
|
||||||
import { rent_payments, profiles } from "@/lib/db/schema"
|
import { rent_payments, profiles } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
import { sendEmail } from "@/lib/email/send"
|
import { sendEmail, escapeHtml } from "@/lib/email/send"
|
||||||
import { paymentLinkSchema } from "@/lib/validations"
|
import { paymentLinkSchema } from "@/lib/validations"
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -48,16 +48,16 @@ export async function POST(request: Request) {
|
|||||||
<p style="margin:8px 0 0;opacity:.8;font-size:14px;">Property Management Network</p>
|
<p style="margin:8px 0 0;opacity:.8;font-size:14px;">Property Management Network</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:32px;">
|
<div style="padding:32px;">
|
||||||
<p style="font-size:16px;margin:0 0 8px;">Hi ${tenantName},</p>
|
<p style="font-size:16px;margin:0 0 8px;">Hi ${escapeHtml(tenantName)},</p>
|
||||||
<p style="color:rgba(255,255,255,.6);font-size:14px;margin:0 0 24px;">
|
<p style="color:rgba(255,255,255,.6);font-size:14px;margin:0 0 24px;">
|
||||||
Your rent payment of <strong style="color:#fff;">${amount}</strong> is due on <strong style="color:#fff;">${dueDate}</strong>
|
Your rent payment of <strong style="color:#fff;">${amount}</strong> is due on <strong style="color:#fff;">${dueDate}</strong>
|
||||||
for ${payment.property?.name}${payment.unit ? ` Unit ${payment.unit.unit_number}` : ""}.
|
for ${escapeHtml(payment.property?.name)}${payment.unit ? ` Unit ${escapeHtml(payment.unit.unit_number)}` : ""}.
|
||||||
</p>
|
</p>
|
||||||
<p style="color:rgba(255,255,255,.6);font-size:14px;margin:0 0 16px;">
|
<p style="color:rgba(255,255,255,.6);font-size:14px;margin:0 0 16px;">
|
||||||
Please arrange payment at your earliest convenience. Contact your landlord if you have any questions.
|
Please arrange payment at your earliest convenience. Contact your landlord if you have any questions.
|
||||||
</p>
|
</p>
|
||||||
<p style="color:rgba(255,255,255,.4);font-size:12px;margin:24px 0 0;border-top:1px solid rgba(255,255,255,.08);padding-top:16px;">
|
<p style="color:rgba(255,255,255,.4);font-size:12px;margin:24px 0 0;border-top:1px solid rgba(255,255,255,.08);padding-top:16px;">
|
||||||
Sent by ${profile?.full_name ?? "Your Landlord"} via Property Management Network
|
Sent by ${escapeHtml(profile?.full_name ?? "Your Landlord")} via Property Management Network
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from "@/lib/db"
|
|||||||
import { tenants, units } from "@/lib/db/schema"
|
import { tenants, units } from "@/lib/db/schema"
|
||||||
import { getSessionUser } from "@/lib/session"
|
import { getSessionUser } from "@/lib/session"
|
||||||
import { tenantSchema } from "@/lib/validations"
|
import { tenantSchema } from "@/lib/validations"
|
||||||
|
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
|
||||||
import { logActivity } from "@/lib/activity"
|
import { logActivity } from "@/lib/activity"
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
@@ -53,6 +54,13 @@ export async function POST(request: Request) {
|
|||||||
const parsed = tenantSchema.safeParse(body)
|
const parsed = tenantSchema.safeParse(body)
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
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
|
const [tenant] = await db
|
||||||
.insert(tenants)
|
.insert(tenants)
|
||||||
.values({ ...parsed.data, user_id: user.id })
|
.values({ ...parsed.data, user_id: user.id })
|
||||||
|
|||||||
+40
-3
@@ -21,9 +21,10 @@ export const auth = betterAuth({
|
|||||||
}),
|
}),
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// Login works immediately; flip to true once verification email is desired.
|
// Env-gated so production can require a verified email without breaking
|
||||||
// Recommended for production: set requireEmailVerification to true.
|
// local dev (where RESEND is typically unconfigured). Set
|
||||||
requireEmailVerification: false,
|
// REQUIRE_EMAIL_VERIFICATION=true in production to enforce.
|
||||||
|
requireEmailVerification: process.env.REQUIRE_EMAIL_VERIFICATION === "true",
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
sendResetPassword: async ({ user: u, url }) => {
|
sendResetPassword: async ({ user: u, url }) => {
|
||||||
await sendEmail({
|
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: {
|
socialProviders: {
|
||||||
google: {
|
google: {
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
|
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
|
||||||
@@ -44,6 +57,11 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
window: 60, // seconds
|
window: 60, // seconds
|
||||||
max: 20, // requests per window per IP for auth endpoints
|
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
|
// Auto-create the app `profiles` row whenever Better Auth creates a user
|
||||||
// (replaces the old `handle_new_user` Postgres trigger).
|
// (replaces the old `handle_new_user` Postgres trigger).
|
||||||
@@ -88,3 +106,22 @@ function resetPasswordHtml(url: string) {
|
|||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyEmailHtml(url: string) {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family: sans-serif; background: #09090b; color: #fff; padding: 40px 20px; max-width: 560px; margin: 0 auto;">
|
||||||
|
<div style="background: #16161f; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 32px;">
|
||||||
|
<h1 style="font-size: 20px; margin: 0 0 8px; color: #fff;">Verify your email</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
||||||
|
Confirm your email address to finish setting up your account. If you didn't create an account, you can ignore this email.
|
||||||
|
</p>
|
||||||
|
<a href="${url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">Property Management Network</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|||||||
@@ -402,7 +402,11 @@ export async function getUserDetail(id: string) {
|
|||||||
// ── CSV helpers (shared by admin export routes) ─────────────────────────────────
|
// ── CSV helpers (shared by admin export routes) ─────────────────────────────────
|
||||||
export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) {
|
export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) {
|
||||||
const esc = (v: 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 /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
||||||
}
|
}
|
||||||
return [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n")
|
return [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n")
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { resend, FROM_EMAIL, APP_NAME } from "./client"
|
import { resend, FROM_EMAIL, APP_NAME } from "./client"
|
||||||
|
|
||||||
function escapeHtml(value: unknown): string {
|
export function escapeHtml(value: unknown): string {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function contentTypeForKey(key: string): string {
|
|||||||
/** Resolve a storage key to an absolute path, refusing path traversal. */
|
/** Resolve a storage key to an absolute path, refusing path traversal. */
|
||||||
function resolveKey(key: string): string {
|
function resolveKey(key: string): string {
|
||||||
const clean = key.replace(/^\/+/, "")
|
const clean = key.replace(/^\/+/, "")
|
||||||
|
if (clean.split(/[\\/]+/).some((seg) => seg === ".." || seg === ".")) {
|
||||||
|
throw new Error("Invalid storage path")
|
||||||
|
}
|
||||||
const abs = path.resolve(STORAGE_DIR, clean)
|
const abs = path.resolve(STORAGE_DIR, clean)
|
||||||
if (abs !== STORAGE_DIR && !abs.startsWith(STORAGE_DIR + path.sep)) {
|
if (abs !== STORAGE_DIR && !abs.startsWith(STORAGE_DIR + path.sep)) {
|
||||||
throw new Error("Invalid storage path")
|
throw new Error("Invalid storage path")
|
||||||
|
|||||||
+39
-32
@@ -1,41 +1,41 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const propertySchema = z.object({
|
export const propertySchema = z.object({
|
||||||
name: z.string().min(1, "Property name is required"),
|
name: z.string().min(1, "Property name is required").max(200),
|
||||||
address_line1: z.string().min(1, "Address is required"),
|
address_line1: z.string().min(1, "Address is required").max(300),
|
||||||
address_line2: z.string().optional(),
|
address_line2: z.string().max(300).optional(),
|
||||||
city: z.string().min(1, "City is required"),
|
city: z.string().min(1, "City is required").max(120),
|
||||||
state: z.string().optional(),
|
state: z.string().max(120).optional(),
|
||||||
postal_code: z.string().optional(),
|
postal_code: z.string().max(20).optional(),
|
||||||
country: z.string().default("US"),
|
country: z.string().max(120).default("US"),
|
||||||
property_type: z.enum(["residential", "commercial", "mixed"]).default("residential"),
|
property_type: z.enum(["residential", "commercial", "mixed"]).default("residential"),
|
||||||
total_units: z.number().int().min(1).default(1),
|
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(),
|
image_url: z.string().url().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const unitSchema = z.object({
|
export const unitSchema = z.object({
|
||||||
property_id: z.string().uuid(),
|
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),
|
bedrooms: z.number().int().min(0).default(1),
|
||||||
bathrooms: z.number().min(0).default(1),
|
bathrooms: z.number().min(0).default(1),
|
||||||
sq_ft: z.number().int().positive().optional(),
|
sq_ft: z.number().int().positive().optional(),
|
||||||
rent_amount: z.number().positive("Rent amount must be positive"),
|
rent_amount: z.number().positive("Rent amount must be positive"),
|
||||||
status: z.enum(["vacant", "occupied", "maintenance", "unavailable"]).default("vacant"),
|
status: z.enum(["vacant", "occupied", "maintenance", "unavailable"]).default("vacant"),
|
||||||
notes: z.string().optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const tenantSchema = z.object({
|
export const tenantSchema = z.object({
|
||||||
property_id: z.string().uuid(),
|
property_id: z.string().uuid(),
|
||||||
unit_id: z.string().uuid().optional(),
|
unit_id: z.string().uuid().optional(),
|
||||||
first_name: z.string().min(1, "First name is required"),
|
first_name: z.string().min(1, "First name is required").max(100),
|
||||||
last_name: z.string().min(1, "Last name is required"),
|
last_name: z.string().min(1, "Last name is required").max(100),
|
||||||
email: z.string().email("Invalid email").optional().or(z.literal("")),
|
email: z.string().email("Invalid email").max(254).optional().or(z.literal("")),
|
||||||
phone: z.string().optional(),
|
phone: z.string().max(40).optional(),
|
||||||
emergency_contact_name: z.string().optional(),
|
emergency_contact_name: z.string().max(200).optional(),
|
||||||
emergency_contact_phone: z.string().optional(),
|
emergency_contact_phone: z.string().max(40).optional(),
|
||||||
move_in_date: z.string().optional(),
|
move_in_date: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const rentPaymentSchema = z.object({
|
export const rentPaymentSchema = z.object({
|
||||||
@@ -46,19 +46,19 @@ export const rentPaymentSchema = z.object({
|
|||||||
due_date: z.string().min(1, "Due date is required"),
|
due_date: z.string().min(1, "Due date is required"),
|
||||||
paid_date: z.string().optional(),
|
paid_date: z.string().optional(),
|
||||||
status: z.enum(["pending", "paid", "overdue", "partial", "waived"]).default("pending"),
|
status: z.enum(["pending", "paid", "overdue", "partial", "waived"]).default("pending"),
|
||||||
payment_method: z.string().optional(),
|
payment_method: z.string().max(100).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const maintenanceSchema = z.object({
|
export const maintenanceSchema = z.object({
|
||||||
property_id: z.string().uuid(),
|
property_id: z.string().uuid(),
|
||||||
unit_id: z.string().uuid().optional(),
|
unit_id: z.string().uuid().optional(),
|
||||||
tenant_id: z.string().uuid().optional(),
|
tenant_id: z.string().uuid().optional(),
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required").max(200),
|
||||||
description: z.string().min(1, "Description is required"),
|
description: z.string().min(1, "Description is required").max(5000),
|
||||||
category: z.enum(["plumbing", "electrical", "hvac", "appliance", "structural", "pest", "general"]).default("general"),
|
category: z.enum(["plumbing", "electrical", "hvac", "appliance", "structural", "pest", "general"]).default("general"),
|
||||||
priority: z.enum(["low", "medium", "high", "emergency"]).default("medium"),
|
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(),
|
estimated_cost: z.number().positive().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,20 +72,20 @@ export const leaseSchema = z.object({
|
|||||||
security_deposit: z.number().positive().optional(),
|
security_deposit: z.number().positive().optional(),
|
||||||
lease_type: z.enum(["fixed", "month_to_month"]).default("fixed"),
|
lease_type: z.enum(["fixed", "month_to_month"]).default("fixed"),
|
||||||
auto_renew: z.boolean().default(false),
|
auto_renew: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const expenseSchema = z.object({
|
export const expenseSchema = z.object({
|
||||||
property_id: z.string().uuid(),
|
property_id: z.string().uuid(),
|
||||||
unit_id: z.string().uuid().optional(),
|
unit_id: z.string().uuid().optional(),
|
||||||
category: z.enum(["repairs", "utilities", "insurance", "mortgage", "taxes", "management", "supplies", "other"]),
|
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"),
|
amount: z.number().positive("Amount must be positive"),
|
||||||
expense_date: z.string().min(1, "Date is required"),
|
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),
|
is_recurring: z.boolean().default(false),
|
||||||
recurrence: z.enum(["monthly", "quarterly", "yearly"]).optional(),
|
recurrence: z.enum(["monthly", "quarterly", "yearly"]).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type PropertyFormValues = z.infer<typeof propertySchema>
|
export type PropertyFormValues = z.infer<typeof propertySchema>
|
||||||
@@ -95,11 +95,11 @@ export type RentPaymentFormValues = z.infer<typeof rentPaymentSchema>
|
|||||||
export type MaintenanceFormValues = z.infer<typeof maintenanceSchema>
|
export type MaintenanceFormValues = z.infer<typeof maintenanceSchema>
|
||||||
export type LeaseFormValues = z.infer<typeof leaseSchema>
|
export type LeaseFormValues = z.infer<typeof leaseSchema>
|
||||||
export const vendorSchema = z.object({
|
export const vendorSchema = z.object({
|
||||||
name: z.string().min(1, "Vendor name is required"),
|
name: z.string().min(1, "Vendor name is required").max(200),
|
||||||
trade: z.string().optional(),
|
trade: z.string().max(120).optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().max(40).optional(),
|
||||||
email: z.string().email("Invalid email").optional().or(z.literal("")),
|
email: z.string().email("Invalid email").max(254).optional().or(z.literal("")),
|
||||||
notes: z.string().optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
property_id: z.string().uuid().optional().or(z.literal("")),
|
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("")),
|
unit_id: z.string().uuid().optional().or(z.literal("")),
|
||||||
type: z.enum(["move_in", "move_out", "routine"]),
|
type: z.enum(["move_in", "move_out", "routine"]),
|
||||||
date: z.string().min(1, "Date is required"),
|
date: z.string().min(1, "Date is required"),
|
||||||
notes: z.string().optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const paymentLinkSchema = z.object({
|
export const paymentLinkSchema = z.object({
|
||||||
payment_id: z.string().uuid("Valid payment ID is required"),
|
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<typeof expenseSchema>
|
export type ExpenseFormValues = z.infer<typeof expenseSchema>
|
||||||
export type VendorFormValues = z.infer<typeof vendorSchema>
|
export type VendorFormValues = z.infer<typeof vendorSchema>
|
||||||
export type InspectionFormValues = z.infer<typeof inspectionSchema>
|
export type InspectionFormValues = z.infer<typeof inspectionSchema>
|
||||||
|
|||||||
+4
-18
@@ -1,22 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
// Pragmatic baseline Content-Security-Policy for a Next.js app.
|
// NOTE: The Content-Security-Policy is set per-request in `proxy.ts` (Next
|
||||||
// NOTE: Next 16 commonly needs 'unsafe-inline' for styles/scripts when no
|
// middleware) so `script-src` can carry a per-request nonce instead of
|
||||||
// nonce pipeline is configured. Tightening this to per-request nonces
|
// 'unsafe-inline'. A static header here cannot carry a per-request nonce and
|
||||||
// (removing 'unsafe-inline') is a recommended follow-up.
|
// would conflict with the middleware, so it is intentionally omitted below.
|
||||||
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("; ");
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Emit a self-contained server bundle at .next/standalone so the Docker
|
// 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`.
|
// 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",
|
value: "max-age=63072000; includeSubDomains; preload",
|
||||||
},
|
},
|
||||||
{ key: "X-DNS-Prefetch-Control", value: "off" },
|
{ key: "X-DNS-Prefetch-Control", value: "off" },
|
||||||
{ key: "Content-Security-Policy", value: contentSecurityPolicy },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ const PROTECTED_PATHS = [
|
|||||||
|
|
||||||
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
|
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) {
|
export async function proxy(request: NextRequest) {
|
||||||
const pathname = request.nextUrl.pathname
|
const pathname = request.nextUrl.pathname
|
||||||
|
|
||||||
@@ -48,7 +69,21 @@ export async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.redirect(url)
|
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 = {
|
export const config = {
|
||||||
|
|||||||
Reference in New Issue
Block a user