Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening

Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+179
View File
@@ -0,0 +1,179 @@
import { createHmac, randomBytes, timingSafeEqual } from "crypto"
import { and, asc, eq, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_deliveries, webhook_endpoints } from "@/lib/db/schema"
import { isSafeWebhookUrl } from "./ssrf"
// ============================================================================
// Webhook delivery: signing + HTTP POST + retry bookkeeping.
//
// Signature scheme (Stripe-style, HMAC-SHA256):
// header X-PMN-Signature: t=<unix>,v1=<hex>
// signed `${t}.${rawBody}`
// Receivers recompute the HMAC with their endpoint secret and compare. A short
// timestamp lets them reject replays.
// ============================================================================
const SECRET_PREFIX = "whsec_"
const DELIVERY_TIMEOUT_MS = 10_000
const MAX_RESPONSE_CHARS = 2_000
// Retry backoff (minutes) indexed by the attempt number just completed.
const BACKOFF_MINUTES = [1, 5, 15, 60, 180]
type EndpointRow = typeof webhook_endpoints.$inferSelect
type DeliveryRow = typeof webhook_deliveries.$inferSelect
/** Generate a new endpoint signing secret, e.g. `whsec_<64 hex>`. */
export function generateWebhookSecret(): string {
return `${SECRET_PREFIX}${randomBytes(32).toString("hex")}`
}
/** Compute the `X-PMN-Signature` header value for a raw body + secret. */
export function signaturePayload(secret: string, timestamp: number, body: string): string {
return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")
}
function signatureHeader(secret: string, body: string): { header: string; timestamp: number } {
const timestamp = Math.floor(Date.now() / 1000)
const v1 = signaturePayload(secret, timestamp, body)
return { header: `t=${timestamp},v1=${v1}`, timestamp }
}
/**
* Verify an inbound signature header against a body + secret. Exposed so that a
* receiver built on this codebase (and our own tests) can validate deliveries.
* Tolerates clock skew up to `toleranceSeconds` (default 5 min).
*/
export function verifySignature(
secret: string,
header: string,
body: string,
toleranceSeconds = 300
): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const [k, v] = kv.split("=")
return [k?.trim(), v?.trim()]
})
)
const t = Number(parts.t)
const given = parts.v1
if (!Number.isFinite(t) || !given) return false
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false
const expected = signaturePayload(secret, t, body)
const a = Buffer.from(expected)
const b = Buffer.from(given)
return a.length === b.length && timingSafeEqual(a, b)
}
function backoffIso(attemptsCompleted: number): string {
const minutes = BACKOFF_MINUTES[attemptsCompleted - 1] ?? BACKOFF_MINUTES[BACKOFF_MINUTES.length - 1]
return new Date(Date.now() + minutes * 60_000).toISOString()
}
/**
* Attempt to deliver a single delivery row to its endpoint, then persist the
* outcome (status, attempt count, next_attempt_at) and update the endpoint's
* health fields. Never throws — returns whether the POST succeeded.
*/
export async function attemptDelivery(delivery: DeliveryRow, endpoint: EndpointRow): Promise<boolean> {
const body = JSON.stringify(delivery.payload)
const nowIso = new Date().toISOString()
const attemptsNow = delivery.attempts + 1
let ok = false
let responseStatus: number | null = null
let responseBody: string | null = null
let error: string | null = null
try {
// Re-check for SSRF at delivery time — DNS may have changed since creation.
if (!(await isSafeWebhookUrl(endpoint.url))) {
throw new Error("Destination blocked by SSRF protection")
}
const { header } = signatureHeader(endpoint.secret, body)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
try {
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "PMN-Webhooks/1.0",
"x-pmn-event": delivery.event,
"x-pmn-delivery": delivery.id,
"x-pmn-webhook-id": endpoint.id,
"x-pmn-signature": header,
},
body,
signal: controller.signal,
redirect: "manual",
})
responseStatus = res.status
responseBody = (await res.text().catch(() => "")).slice(0, MAX_RESPONSE_CHARS)
ok = res.status >= 200 && res.status < 300
if (!ok) error = `Endpoint returned HTTP ${res.status}`
} finally {
clearTimeout(timer)
}
} catch (e) {
error = e instanceof Error ? (e.name === "AbortError" ? "Request timed out" : e.message) : "Delivery failed"
}
const exhausted = !ok && attemptsNow >= delivery.max_attempts
await db
.update(webhook_deliveries)
.set({
status: ok ? "success" : exhausted ? "failed" : "pending",
attempts: attemptsNow,
response_status: responseStatus,
response_body: responseBody,
error: ok ? null : error,
delivered_at: ok ? nowIso : delivery.delivered_at,
next_attempt_at: ok || exhausted ? delivery.next_attempt_at : backoffIso(attemptsNow),
updated_at: nowIso,
})
.where(eq(webhook_deliveries.id, delivery.id))
await db
.update(webhook_endpoints)
.set(
ok
? { last_success_at: nowIso, failure_count: 0, last_error: null }
: { last_error_at: nowIso, last_error: error, failure_count: endpoint.failure_count + 1 }
)
.where(eq(webhook_endpoints.id, endpoint.id))
return ok
}
/**
* Cron drain: deliver every pending delivery whose retry time has come, for all
* accounts. Returns counts for the cron response. Endpoints that are disabled
* are left untouched (their pending deliveries resume if re-enabled).
*/
export async function processDueDeliveries(limit = 100): Promise<{ processed: number; delivered: number }> {
const nowIso = new Date().toISOString()
const due = await db.query.webhook_deliveries.findMany({
where: and(
eq(webhook_deliveries.status, "pending"),
lte(webhook_deliveries.next_attempt_at, nowIso)
),
with: { endpoint: true },
orderBy: asc(webhook_deliveries.next_attempt_at),
limit,
})
let delivered = 0
let processed = 0
for (const row of due) {
const { endpoint, ...delivery } = row
if (!endpoint || endpoint.status !== "active") continue
processed++
if (await attemptDelivery(delivery as DeliveryRow, endpoint)) delivered++
}
return { processed, delivered }
}
+114
View File
@@ -0,0 +1,114 @@
import { randomUUID } from "crypto"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_deliveries, webhook_endpoints } from "@/lib/db/schema"
import { PING_EVENT, type WebhookEnvelope, type WebhookEvent } from "./events"
import { attemptDelivery } from "./deliver"
/** Build the delivery envelope for an event. `id` is a unique per-event id. */
function buildEnvelope(
event: WebhookEvent | typeof PING_EVENT,
data: Record<string, unknown>
): WebhookEnvelope {
return {
id: `evt_${randomUUID().replace(/-/g, "")}`,
event,
created_at: new Date().toISOString(),
data,
}
}
// ============================================================================
// Event emission — the one function domain code calls after a mutation.
//
// await emitWebhookEvent({ ownerId, event: "tenant.created", data: {...} })
//
// It resolves the account's subscribed endpoints, records a pending delivery per
// endpoint, and fires an immediate best-effort delivery in the background (this
// app runs as a persistent Node server, so post-response work completes). The
// webhooks cron is the safety net for retries and process restarts.
// ============================================================================
type EndpointRow = typeof webhook_endpoints.$inferSelect
async function activeSubscribedEndpoints(
ownerId: string,
event: WebhookEvent
): Promise<EndpointRow[]> {
const endpoints = await db.query.webhook_endpoints.findMany({
where: and(eq(webhook_endpoints.user_id, ownerId), eq(webhook_endpoints.status, "active")),
})
// Empty `events` means "subscribe to everything".
return endpoints.filter((e) => e.events.length === 0 || e.events.includes(event))
}
export async function emitWebhookEvent(opts: {
ownerId: string
event: WebhookEvent
data: Record<string, unknown>
}): Promise<void> {
const { ownerId, event, data } = opts
try {
const endpoints = await activeSubscribedEndpoints(ownerId, event)
if (!endpoints.length) return
const envelope = buildEnvelope(event, data)
// Give the immediate background attempt ~60s before the cron would retry, so
// the two never race to double-deliver the same row.
const nextAttempt = new Date(Date.now() + 60_000).toISOString()
const rows = await db
.insert(webhook_deliveries)
.values(
endpoints.map((e) => ({
user_id: ownerId,
endpoint_id: e.id,
event,
payload: envelope as unknown as Record<string, unknown>,
next_attempt_at: nextAttempt,
}))
)
.returning()
const byId = new Map(endpoints.map((e) => [e.id, e]))
void Promise.allSettled(
rows.map((r) => {
const ep = byId.get(r.endpoint_id)
return ep ? attemptDelivery(r, ep) : Promise.resolve(false)
})
).catch(() => {})
} catch {
// The webhook subsystem must never break the originating request.
}
}
/**
* Synchronously deliver a one-off `ping` to a single endpoint (the dashboard
* "Send test event" button). Awaited so the caller can show the result.
*/
export async function deliverTestPing(
endpoint: EndpointRow
): Promise<{ ok: boolean; responseStatus: number | null; error: string | null }> {
const envelope = buildEnvelope(PING_EVENT, {
message: "Test event from Property Management Network",
endpoint_id: endpoint.id,
})
const [row] = await db
.insert(webhook_deliveries)
.values({
user_id: endpoint.user_id,
endpoint_id: endpoint.id,
event: PING_EVENT,
payload: envelope as unknown as Record<string, unknown>,
max_attempts: 1,
next_attempt_at: new Date().toISOString(),
})
.returning()
const ok = await attemptDelivery(row, endpoint)
const updated = await db.query.webhook_deliveries.findFirst({
where: eq(webhook_deliveries.id, row.id),
columns: { response_status: true, error: true },
})
return { ok, responseStatus: updated?.response_status ?? null, error: updated?.error ?? null }
}
+68
View File
@@ -0,0 +1,68 @@
// ============================================================================
// Webhook event catalog.
//
// The single source of truth for the outbound-webhook / Zapier integration.
// Every event a landlord can subscribe to is declared here. `emitWebhookEvent`
// (see ./emit) fans an event out to all of an account's endpoints that either
// subscribe to the event id or subscribe to everything (empty `events` array).
//
// This module is intentionally dependency-free (no `crypto`, no db) so it can be
// imported from client components (e.g. the settings form) and shared schemas.
// ============================================================================
export const WEBHOOK_EVENTS = [
{
id: "property.created",
label: "Property created",
description: "A property was added to the portfolio.",
},
{
id: "tenant.created",
label: "Tenant created",
description: "A new tenant was added.",
},
{
id: "maintenance.created",
label: "Maintenance request opened",
description: "A maintenance request was submitted (dashboard, API, or tenant portal).",
},
{
id: "maintenance.updated",
label: "Maintenance status changed",
description: "A maintenance request moved to a new status (e.g. resolved).",
},
{
id: "payment.recorded",
label: "Payment recorded",
description: "A rent payment record was created.",
},
{
id: "payment.paid",
label: "Payment marked paid",
description: "A rent payment was marked as paid.",
},
{
id: "lease.created",
label: "Lease created",
description: "A lease was created for a tenant.",
},
] as const
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]["id"]
/** All valid event ids, plus the reserved `ping` used by the "Send test" button. */
export const WEBHOOK_EVENT_IDS = WEBHOOK_EVENTS.map((e) => e.id) as WebhookEvent[]
export const PING_EVENT = "ping" as const
export function isWebhookEvent(value: unknown): value is WebhookEvent {
return typeof value === "string" && WEBHOOK_EVENT_IDS.includes(value as WebhookEvent)
}
/** The JSON envelope every webhook POST body uses. */
export type WebhookEnvelope = {
id: string
event: WebhookEvent | typeof PING_EVENT
created_at: string
data: Record<string, unknown>
}
+121
View File
@@ -0,0 +1,121 @@
import { lookup } from "dns/promises"
import { isIP } from "net"
// ============================================================================
// SSRF protection for outbound webhooks.
//
// Webhook URLs are attacker-controllable input that the server dials on a
// schedule. Without guardrails a tenant could point one at http://169.254.169.254
// (cloud metadata) or an internal service and use our servers as a proxy. We:
// 1. require https (http allowed only outside production, for local testing);
// 2. reject credentials / non-default-ish shapes;
// 3. reject hostnames that ARE private/reserved IP literals; and
// 4. resolve the hostname and reject if ANY resolved address is private.
//
// Set WEBHOOKS_ALLOW_PRIVATE_HOSTS=true to bypass (1) https-in-prod is still
// enforced) and the private-range checks — intended ONLY for local dev where the
// receiver runs on localhost.
// ============================================================================
const ALLOW_PRIVATE = process.env.WEBHOOKS_ALLOW_PRIVATE_HOSTS === "true"
export class WebhookUrlError extends Error {}
/** True for IPv4 addresses in a private, loopback, link-local or reserved range. */
function isPrivateIPv4(ip: string): boolean {
const parts = ip.split(".").map((n) => parseInt(n, 10))
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true
const [a, b] = parts
if (a === 0) return true // 0.0.0.0/8 "this network"
if (a === 10) return true // private
if (a === 127) return true // loopback
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64.0.0/10
if (a === 169 && b === 254) return true // link-local (incl. 169.254.169.254 metadata)
if (a === 172 && b >= 16 && b <= 31) return true // private 172.16.0.0/12
if (a === 192 && b === 0) return true // 192.0.0.0/24 IETF protocol assignments
if (a === 192 && b === 168) return true // private
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking 198.18.0.0/15
if (a >= 224) return true // multicast (224/4) + reserved (240/4) + broadcast
return false
}
/** True for IPv6 loopback, unspecified, ULA, link-local, multicast, or mapped-v4. */
function isPrivateIPv6(ip: string): boolean {
const addr = ip.toLowerCase().split("%")[0] // strip zone id
if (addr === "::1" || addr === "::") return true
// IPv4-mapped / -compatible (e.g. ::ffff:169.254.169.254) — check the v4 part.
const mapped = addr.match(/(?:^::ffff:|^::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (mapped) return isPrivateIPv4(mapped[1])
const head = addr.replace(/^\[|\]$/g, "")
if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb"))
return true // fe80::/10 link-local
if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local
if (head.startsWith("ff")) return true // ff00::/8 multicast
return false
}
function isPrivateAddress(ip: string): boolean {
const kind = isIP(ip)
if (kind === 4) return isPrivateIPv4(ip)
if (kind === 6) return isPrivateIPv6(ip)
return true // not a parseable IP → treat as unsafe
}
/**
* Validate a user-supplied webhook URL and, unless private hosts are allowed,
* resolve it to confirm it does not point at an internal address. Throws
* WebhookUrlError with a user-facing message on any violation.
*/
export async function assertSafeWebhookUrl(raw: string): Promise<void> {
let url: URL
try {
url = new URL(raw)
} catch {
throw new WebhookUrlError("Enter a valid absolute URL.")
}
const isProd = process.env.NODE_ENV === "production"
if (url.protocol !== "https:" && !(url.protocol === "http:" && !isProd)) {
throw new WebhookUrlError("Webhook URLs must use https://")
}
if (url.username || url.password) {
throw new WebhookUrlError("Webhook URLs must not contain credentials.")
}
const host = url.hostname.replace(/^\[|\]$/g, "")
if (ALLOW_PRIVATE) return
if (host.toLowerCase() === "localhost" || host.toLowerCase().endsWith(".localhost")) {
throw new WebhookUrlError("Webhook URLs must be publicly reachable, not localhost.")
}
// If the host is an IP literal, check it directly.
if (isIP(host)) {
if (isPrivateAddress(host)) {
throw new WebhookUrlError("Webhook URLs must not point at private or reserved IP addresses.")
}
return
}
// Otherwise resolve it and reject if any address is internal.
let addresses: { address: string }[]
try {
addresses = await lookup(host, { all: true })
} catch {
throw new WebhookUrlError("Could not resolve the webhook host.")
}
if (!addresses.length || addresses.some((a) => isPrivateAddress(a.address))) {
throw new WebhookUrlError("Webhook host resolves to a private or reserved address.")
}
}
/** Non-throwing variant used at delivery time. */
export async function isSafeWebhookUrl(raw: string): Promise<boolean> {
try {
await assertSafeWebhookUrl(raw)
return true
} catch {
return false
}
}