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
+218 -11
View File
@@ -1,9 +1,18 @@
import { promises as fs } from "fs"
import path from "path"
import { randomBytes } from "crypto"
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
type GetObjectCommandOutput,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
// Root directory for uploaded files. Kept OUTSIDE the public web root so files
// are only ever served through the auth-gated /api/files route.
// Local-disk fallback root. Used only when object storage is not configured.
// Kept OUTSIDE the public web root so files are only served through /api/files.
const STORAGE_DIR = path.resolve(process.cwd(), process.env.STORAGE_DIR ?? "./storage")
const MIME_BY_EXT: Record<string, string> = {
@@ -26,12 +35,103 @@ export function contentTypeForKey(key: string): string {
return MIME_BY_EXT[ext] ?? "application/octet-stream"
}
/** Resolve a storage key to an absolute path, refusing path traversal. */
function resolveKey(key: string): string {
// Single source of truth for what users may upload. Deliberately excludes svg
// and any html/script types, which can execute JavaScript when served inline
// from our origin. Enforce this at EVERY upload entry point (see /api/upload
// and /api/documents) — divergence is how an allowlist gets bypassed.
export const ALLOWED_UPLOAD_EXTENSIONS = [
"pdf", "png", "jpg", "jpeg", "gif", "webp",
"doc", "docx", "xls", "xlsx", "csv", "txt",
] as const
export function extOf(filename: string): string {
return filename.split(".").pop()?.toLowerCase() ?? ""
}
export function isAllowedUploadExt(filename: string): boolean {
return (ALLOWED_UPLOAD_EXTENSIONS as readonly string[]).includes(extOf(filename))
}
// ── Object storage (DigitalOcean Spaces / S3-compatible) ──────────────────────
// When SPACES_* are configured, uploads and serving use the bucket instead of
// local disk. The key scheme (`<userId>/<scope>/<file>`) is identical either
// way, so existing /api/files/<key> URLs stored in the DB keep working after the
// backend switch — only the bytes move.
const SPACES_BUCKET = process.env.SPACES_BUCKET ?? ""
export function usingSpaces(): boolean {
return Boolean(process.env.SPACES_KEY && process.env.SPACES_SECRET && SPACES_BUCKET)
}
/**
* Thrown when a write is attempted in production without object storage
* configured. The local-disk fallback is EPHEMERAL on App Platform, so silently
* using it means uploads vanish on the next deploy. We fail loud instead.
*/
export class StorageNotConfiguredError extends Error {
constructor() {
super(
"Object storage (SPACES_*) is not configured. Refusing to write uploads to " +
"ephemeral local disk in production — files would be lost on the next deploy."
)
this.name = "StorageNotConfiguredError"
}
}
function newClient(endpoint: string | undefined): S3Client {
return new S3Client({
region: process.env.SPACES_REGION || "us-east-1",
endpoint,
forcePathStyle: false,
credentials: {
accessKeyId: process.env.SPACES_KEY!,
secretAccessKey: process.env.SPACES_SECRET!,
},
})
}
// Origin client — used for all mutating/reading operations (PUT/GET/DELETE/HEAD).
let _s3: S3Client | null = null
function s3(): S3Client {
if (!_s3) _s3 = newClient(process.env.SPACES_ENDPOINT) // e.g. https://nyc3.digitaloceanspaces.com
return _s3
}
// Rewrite a presigned origin URL to the Spaces CDN edge host when the CDN is
// enabled. The URL is signed against the ORIGIN host; the CDN forwards requests
// to origin with the origin Host header, so the SigV4 signature still validates.
// (Signing directly against the CDN host is rejected by origin with 403.)
function toCdnUrl(signedUrl: string): string {
const cdn = process.env.SPACES_CDN_ENDPOINT
const origin = process.env.SPACES_ENDPOINT
if (!cdn || !origin) return signedUrl
try {
const originHost = new URL(origin).host // e.g. nyc3.digitaloceanspaces.com
const cdnHost = new URL(cdn).host // e.g. nyc3.cdn.digitaloceanspaces.com
const u = new URL(signedUrl)
if (u.host.endsWith(originHost)) {
u.host = u.host.slice(0, u.host.length - originHost.length) + cdnHost
return u.toString()
}
return signedUrl
} catch {
return signedUrl
}
}
// ── key helpers ───────────────────────────────────────────────────────────────
/** Validate a storage key, refusing empty/traversal segments. Returns it cleaned. */
function assertSafeKey(key: string): string {
const clean = key.replace(/^\/+/, "")
if (clean.split(/[\\/]+/).some((seg) => seg === ".." || seg === ".")) {
if (!clean || clean.split(/[\\/]+/).some((seg) => seg === "" || seg === "." || seg === "..")) {
throw new Error("Invalid storage path")
}
return clean
}
/** Resolve a key to an absolute local path (local-disk backend only). */
function resolveKey(key: string): string {
const clean = assertSafeKey(key)
const abs = path.resolve(STORAGE_DIR, clean)
if (abs !== STORAGE_DIR && !abs.startsWith(STORAGE_DIR + path.sep)) {
throw new Error("Invalid storage path")
@@ -43,6 +143,18 @@ function sanitizeSegment(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
}
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
if (!body) return Buffer.alloc(0)
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
const stream = body as { transformToByteArray?: () => Promise<Uint8Array> }
if (typeof stream.transformToByteArray === "function") {
return Buffer.from(await stream.transformToByteArray())
}
const chunks: Buffer[] = []
for await (const chunk of body as AsyncIterable<Uint8Array>) chunks.push(Buffer.from(chunk))
return Buffer.concat(chunks)
}
/**
* Persist an uploaded File under `${userId}/${scope}/<random>.<ext>` and return
* the storage key (relative path). Optionally pass `fixedName` to make the file
@@ -57,23 +169,118 @@ export async function saveFile(
? sanitizeSegment(opts.fixedName)
: `${Date.now()}-${randomBytes(6).toString("hex")}`
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${base}.${ext}`
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())
await fs.writeFile(abs, buffer)
const type = file.type || contentTypeForKey(key)
return { key, size: file.size, type: file.type || contentTypeForKey(key) }
if (usingSpaces()) {
await s3().send(
new PutObjectCommand({
Bucket: SPACES_BUCKET,
Key: key,
Body: buffer,
ContentType: type,
ACL: "private",
})
)
} else {
// In production the local-disk backend is ephemeral (lost on redeploy), so a
// misconfigured Spaces setup must fail loudly rather than silently drop data.
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, buffer)
}
return { key, size: file.size, type }
}
export async function readFile(key: string): Promise<Buffer> {
if (usingSpaces()) {
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
return bodyToBuffer(res.Body)
}
return fs.readFile(resolveKey(key))
}
/**
* Presigned, time-limited GET URL for an object in Spaces — lets the browser
* fetch the bytes directly from the bucket (offloading them from the app) while
* access stays gated: the caller must pass auth + ownership checks before this
* is issued, and the URL expires. `ResponseContent*` control how the browser
* treats the file (inline image vs. attachment download) with the right type.
*/
export async function presignGetUrl(
key: string,
opts: { expiresIn?: number; disposition?: "inline" | "attachment"; filename?: string } = {}
): Promise<string> {
const safe = assertSafeKey(key)
// The key's basename is already generated/sanitized at upload; keep only
// filename-safe characters for the Content-Disposition header.
const filename = (opts.filename ?? safe.split("/").pop() ?? "file").replace(/[^A-Za-z0-9._-]/g, "_")
const cmd = new GetObjectCommand({
Bucket: SPACES_BUCKET,
Key: safe,
ResponseContentType: contentTypeForKey(safe),
ResponseContentDisposition: `${opts.disposition ?? "inline"}; filename="${filename}"`,
})
const signed = await getSignedUrl(s3(), cmd, { expiresIn: opts.expiresIn ?? 3600 })
return toCdnUrl(signed)
}
export async function deleteFile(key: string): Promise<void> {
try {
await fs.unlink(resolveKey(key))
if (usingSpaces()) {
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
} else {
await fs.unlink(resolveKey(key))
}
} catch {
// Already gone — ignore.
}
}
async function walkDirSize(dir: string): Promise<number> {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return 0 // directory doesn't exist yet → 0 bytes
}
let total = 0
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) total += await walkDirSize(full)
else if (e.isFile()) {
try {
total += (await fs.stat(full)).size
} catch {
// File vanished between readdir and stat — ignore.
}
}
}
return total
}
/**
* Total bytes currently stored for a user, across whichever backend is active.
* Files are namespaced under `<userId>/…`, so we sum that prefix. Used to
* enforce per-plan storage quotas at upload time.
*/
export async function getUserStorageBytes(userId: string): Promise<number> {
const prefix = `${sanitizeSegment(userId)}/`
if (usingSpaces()) {
let total = 0
let token: string | undefined
do {
const res = await s3().send(
new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token })
)
for (const obj of res.Contents ?? []) total += obj.Size ?? 0
token = res.IsTruncated ? res.NextContinuationToken : undefined
} while (token)
return total
}
return walkDirSize(path.join(STORAGE_DIR, sanitizeSegment(userId)))
}