Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions __tests__/lib/security/kyc-private-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
31 changes: 23 additions & 8 deletions app/api/kyc-documents/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions lib/security/kyc-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 79 additions & 0 deletions scripts/migrate-public-kyc-blobs.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading