Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
Deploy config: - .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the propertymanagement.network domain so they bake into the client bundle; add custom domains block (apex + www); wire Sentry DSN (server + browser). Included pending work from the audit-fixes branch: - AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default) - Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010 - Outbound webhooks / Zapier integration - PayPal removal (Stripe-only billing) - Storage hardening (fail-loud when Spaces unconfigured), security fixes Verified: full production Docker build (same build-args as DO) passes clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
917a06ee85
commit
5495b94924
+81
-1
@@ -143,6 +143,53 @@ function sanitizeSegment(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff a storage key lives in the given owner's namespace (`<ownerId>/…`).
|
||||
* Keys are generated server-side as `<sanitized ownerId>/<scope>/<file>`, so any
|
||||
* client-supplied key/path whose first segment differs belongs to another tenant
|
||||
* (or is malformed) and must be rejected.
|
||||
*/
|
||||
export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean {
|
||||
if (!key || !ownerId) return false
|
||||
const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0]
|
||||
return first === sanitizeSegment(ownerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight magic-byte check: reject a file whose real content doesn't match
|
||||
* its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types
|
||||
* without a reliable file signature (csv/txt) are allowed through. `head` should
|
||||
* be the first ~16 bytes of the file.
|
||||
*/
|
||||
export function contentMatchesExtension(head: Buffer, ext: string): boolean {
|
||||
const at = (offset: number, sig: number[]) =>
|
||||
head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b)
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF
|
||||
case "png":
|
||||
return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return at(0, [0xff, 0xd8, 0xff])
|
||||
case "gif":
|
||||
return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8
|
||||
case "webp":
|
||||
return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP
|
||||
case "docx":
|
||||
case "xlsx":
|
||||
return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK)
|
||||
case "doc":
|
||||
case "xls":
|
||||
return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip
|
||||
case "csv":
|
||||
case "txt":
|
||||
return true // no reliable signature
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -194,6 +241,36 @@ export async function saveFile(
|
||||
return { key, size: file.size, type }
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist raw bytes under `${userId}/${scope}/<random>.<ext>` (server-generated,
|
||||
* so the key is always in the owner's namespace) and return the storage key.
|
||||
* Used for server-side artifacts like signed e-sign PDFs.
|
||||
*/
|
||||
export async function saveBuffer(
|
||||
buffer: Buffer,
|
||||
opts: { userId: string; scope: string; ext: string }
|
||||
): Promise<{ key: string }> {
|
||||
const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"
|
||||
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}`
|
||||
if (usingSpaces()) {
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: SPACES_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentTypeForKey(key),
|
||||
ACL: "private",
|
||||
})
|
||||
)
|
||||
} else {
|
||||
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 }
|
||||
}
|
||||
|
||||
export async function readFile(key: string): Promise<Buffer> {
|
||||
if (usingSpaces()) {
|
||||
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
|
||||
@@ -227,7 +304,10 @@ export async function presignGetUrl(
|
||||
return toCdnUrl(signed)
|
||||
}
|
||||
|
||||
export async function deleteFile(key: string): Promise<void> {
|
||||
export async function deleteFile(key: string, ownerId: string): Promise<void> {
|
||||
// Defense in depth: never delete an object outside the caller's own namespace,
|
||||
// even if a stored storage_path was tampered with to point at another tenant.
|
||||
if (!keyBelongsToOwner(key, ownerId)) return
|
||||
try {
|
||||
if (usingSpaces()) {
|
||||
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
|
||||
|
||||
Reference in New Issue
Block a user