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:
Leon Serfaty
2026-07-01 13:56:34 -04:00
co-authored by Claude Opus 4.8
parent 857b9a7811
commit 969d5d4c8a
18 changed files with 224 additions and 103 deletions
+40 -3
View File
@@ -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) {
</body>
</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>`
}
+5 -1
View File
@@ -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")
+1 -1
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
+3
View File
@@ -29,6 +29,9 @@ export function contentTypeForKey(key: string): string {
/** Resolve a storage key to an absolute path, refusing path traversal. */
function resolveKey(key: string): string {
const clean = key.replace(/^\/+/, "")
if (clean.split(/[\\/]+/).some((seg) => 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")
+39 -32
View File
@@ -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<typeof propertySchema>
@@ -95,11 +95,11 @@ export type RentPaymentFormValues = z.infer<typeof rentPaymentSchema>
export type MaintenanceFormValues = z.infer<typeof maintenanceSchema>
export type LeaseFormValues = z.infer<typeof leaseSchema>
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<typeof expenseSchema>
export type VendorFormValues = z.infer<typeof vendorSchema>
export type InspectionFormValues = z.infer<typeof inspectionSchema>