diff --git a/.env.example b/.env.example index ee7932e0..642761ad 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,8 @@ NOTIFICATION_WORKER_SECRET=replace_with_a_separate_worker_secret # File uploads / Vercel Blob BLOB_READ_WRITE_TOKEN=replace_with_local_or_preview_blob_token +# Optional only during the one-time public-to-private KYC migration, then revoke it. +KYC_LEGACY_PUBLIC_BLOB_TOKEN= # Stellar Testnet (server-side public identifiers and URLs only; never add private keys) # Mock mode accepts these safe placeholders. Set ENABLE_MOCK_STELLAR=false only diff --git a/__tests__/lib/security/kyc-private-storage.test.ts b/__tests__/lib/security/kyc-private-storage.test.ts new file mode 100644 index 00000000..7dc501fd --- /dev/null +++ b/__tests__/lib/security/kyc-private-storage.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" + +import { isAllowedKycBlobUrl, isPrivateKycBlobUrl } from "@/lib/security/kyc-documents" + +describe("KYC private storage URL policy", () => { + it("recognizes authenticated private Blob URLs", () => { + const url = "https://store.private.blob.vercel-storage.com/kyc/user/document.json" + expect(isAllowedKycBlobUrl(url)).toBe(true) + expect(isPrivateKycBlobUrl(url)).toBe(true) + }) + + it("keeps legacy public URLs distinguishable for migration", () => { + const url = "https://store.public.blob.vercel-storage.com/kyc/user/document.json" + expect(isAllowedKycBlobUrl(url)).toBe(true) + expect(isPrivateKycBlobUrl(url)).toBe(false) + }) + + it("rejects lookalike and non-HTTPS hosts", () => { + expect(isAllowedKycBlobUrl("https://blob.vercel-storage.com.attacker.example/kyc.json")).toBe(false) + expect(isPrivateKycBlobUrl("http://store.private.blob.vercel-storage.com/kyc.json")).toBe(false) + }) +}) diff --git a/app/api/kyc-documents/route.ts b/app/api/kyc-documents/route.ts index ec95ca5a..91e6c127 100644 --- a/app/api/kyc-documents/route.ts +++ b/app/api/kyc-documents/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server" +import { get } from "@vercel/blob" import { z } from "zod" import { finalizeAuthenticatedResponse } from "@/lib/api/route-guard" @@ -8,6 +9,7 @@ import dbConnect from "@/lib/dbConnect" import { decryptKycDocument, isAllowedKycBlobUrl, + isPrivateKycBlobUrl, parseKycDocumentReference, } from "@/lib/security/kyc-documents" import { verifySignedDocumentUrl } from "@/lib/security/kyc-signed-urls" @@ -114,23 +116,36 @@ export async function GET(request: Request) { return NextResponse.json({ message: "Unsupported KYC document reference." }, { status: 400 }) } - const upstreamResponse = await fetch(rawBlobUrl, { cache: "no-store" }) - if (!upstreamResponse.ok) { - return NextResponse.json({ message: "Unable to load document." }, { status: 404 }) - } - let body: Buffer - let contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream" + let encryptedPayload: Buffer + let contentType = "application/octet-stream" let filename = sanitizeFilename(rawBlobUrl.split("/").pop() || "document") + if (isPrivateKycBlobUrl(rawBlobUrl)) { + const privateBlob = await get(rawBlobUrl, { access: "private" }) + if (privateBlob?.statusCode !== 200 || !privateBlob.stream) { + return NextResponse.json({ message: "Unable to load document." }, { status: 404 }) + } + encryptedPayload = Buffer.from(await new Response(privateBlob.stream).arrayBuffer()) + contentType = privateBlob.blob.contentType || contentType + } else { + // Compatibility window for inventoried legacy blobs only. New uploads are + // always private and the migration script revokes these public objects. + const legacyBlob = await fetch(rawBlobUrl, { cache: "no-store" }) + if (!legacyBlob.ok) { + return NextResponse.json({ message: "Unable to load document." }, { status: 404 }) + } + encryptedPayload = Buffer.from(await legacyBlob.arrayBuffer()) + contentType = legacyBlob.headers.get("content-type") || contentType + } + if (secureReference) { - const encryptedPayload = Buffer.from(await upstreamResponse.arrayBuffer()) const decryptedDocument = decryptKycDocument(encryptedPayload) body = decryptedDocument.buffer contentType = decryptedDocument.contentType filename = sanitizeFilename(decryptedDocument.originalFilename) } else { - body = Buffer.from(await upstreamResponse.arrayBuffer()) + body = encryptedPayload } if (docRecord) { diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 1f7002b6..be04398b 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -113,11 +113,15 @@ export async function POST(request: Request) { const storageKey = `kyc/${authContext.user._id.toString()}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${filename}.json` const blob = await put(storageKey, encryptedPayload, { - access: "public", + access: "private", addRandomSuffix: false, contentType: "application/json", }) + if (!blob.url.includes(".private.blob.vercel-storage.com/")) { + throw new Error("KYC uploads require a private Blob store.") + } + const encryptedRef = createKycDocumentReference({ url: blob.url, originalFilename: filename, diff --git a/lib/security/kyc-documents.ts b/lib/security/kyc-documents.ts index aac3579d..25339bde 100644 --- a/lib/security/kyc-documents.ts +++ b/lib/security/kyc-documents.ts @@ -86,6 +86,15 @@ export function isAllowedKycBlobUrl(rawUrl: string) { } } +export function isPrivateKycBlobUrl(rawUrl: string) { + try { + const url = new URL(rawUrl) + return url.protocol === "https:" && /\.private\.blob\.vercel-storage\.com$/i.test(url.hostname) + } catch { + return false + } +} + export function isSupportedKycDocumentReference(reference: string) { if (parseKycDocumentReference(reference)) return true return isAllowedKycBlobUrl(reference) diff --git a/package.json b/package.json index 31e0846d..05de5cd8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "audit:migrate": "tsx scripts/audit-migrate.ts", "audit:verify": "tsx scripts/audit-verify.ts", "fx:legacy-check": "tsx scripts/check-legacy-fx-transactions.ts", + "kyc:migrate-private": "tsx scripts/migrate-public-kyc-blobs.ts", "demo:pool-asset": "tsx scripts/demo-pool-asset.ts", "backup": "tsx scripts/backup/run-backup.ts", "backup:list": "tsx scripts/backup/run-backup.ts --list", diff --git a/scripts/migrate-public-kyc-blobs.ts b/scripts/migrate-public-kyc-blobs.ts new file mode 100644 index 00000000..989e0f66 --- /dev/null +++ b/scripts/migrate-public-kyc-blobs.ts @@ -0,0 +1,79 @@ +import { del, put } from "@vercel/blob" + +import dbConnect from "../lib/dbConnect" +import { createKycDocumentReference, isPrivateKycBlobUrl } from "../lib/security/kyc-documents" +import KycDocument from "../models/KycDocument" + +type MigrationRecord = { + documentId: string + sourceUrl: string + destinationUrl?: string + status: "inventory" | "migrated" | "failed" + error?: string +} + +const dryRun = process.argv.includes("--dry-run") + +function report(record: MigrationRecord) { + // JSON Lines provides an immutable, machine-readable migration inventory for + // deployment logs without printing document contents or owner identifiers. + process.stdout.write(`${JSON.stringify({ ...record, at: new Date().toISOString() })}\n`) +} + +async function migrate() { + await dbConnect() + const documents = await KycDocument.find({ status: { $ne: "deleted" } }) + .select("_id blobUrl storageKey originalFilename contentType") + .lean() + + for (const document of documents) { + const sourceUrl = document.blobUrl + if (!sourceUrl || isPrivateKycBlobUrl(sourceUrl)) continue + + if (dryRun) { + report({ documentId: document._id.toString(), sourceUrl, status: "inventory" }) + continue + } + + try { + const response = await fetch(sourceUrl, { cache: "no-store" }) + if (!response.ok) throw new Error(`legacy blob returned ${response.status}`) + + const migrated = await put(document.storageKey, await response.arrayBuffer(), { + access: "private", + addRandomSuffix: false, + allowOverwrite: true, + contentType: "application/json", + }) + if (!isPrivateKycBlobUrl(migrated.url)) throw new Error("destination store is not private") + + const encryptedRef = createKycDocumentReference({ + url: migrated.url, + originalFilename: document.originalFilename, + contentType: document.contentType, + }) + await KycDocument.updateOne( + { _id: document._id, blobUrl: sourceUrl }, + { $set: { blobUrl: migrated.url, encryptedRef } }, + ) + + const legacyToken = process.env.KYC_LEGACY_PUBLIC_BLOB_TOKEN + if (legacyToken) await del(sourceUrl, { token: legacyToken }) + report({ documentId: document._id.toString(), sourceUrl, destinationUrl: migrated.url, status: "migrated" }) + } catch (error) { + report({ + documentId: document._id.toString(), + sourceUrl, + status: "failed", + error: error instanceof Error ? error.message : "unknown migration error", + }) + } + } +} + +migrate() + .then(() => process.exit(0)) + .catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : "KYC migration failed"}\n`) + process.exit(1) + })