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
@@ -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<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
|
||||
.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 })
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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() {
|
||||
<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;">
|
||||
<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;">
|
||||
Property Management Network — Automated Follow-up System
|
||||
</p>
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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) {
|
||||
<p style="margin:8px 0 0;opacity:.8;font-size:14px;">Property Management Network</p>
|
||||
</div>
|
||||
<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;">
|
||||
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 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.
|
||||
</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;">
|
||||
Sent by ${profile?.full_name ?? "Your Landlord"} via Property Management Network
|
||||
Sent by ${escapeHtml(profile?.full_name ?? "Your Landlord")} via Property Management Network
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user