Add TransferTribe app and fix critical transfer security flaws

The application source was untracked, so this commit brings it under
version control together with fixes for the issues found while auditing
it. Notable fixes:

Authorization
- Require a session and scope to senderEmail on /api/transfers/[id]
  (GET, DELETE) and .../resend. These were unauthenticated over an
  autoincrement id, so the ids could be walked to soft-delete any
  transfer, read sender/recipient metadata, or make the app mail
  arbitrary recipients.
- Require a session on the legacy /api/send and /api/chunk-upload
  endpoints, and take the sender from the session rather than a request
  field so transfers cannot be posted as another user.

Encryption
- Replace the chunk encryption scheme. Every chunk was encrypted under
  one shared session IV with its auth tag discarded, which reuses the
  AES-GCM keystream (XORing two ciphertexts recovers plaintext without
  the key) and left the stored file undecryptable, surfacing to users as
  a wrong-password error. Chunks are now self-contained frames carrying
  their own random IV and auth tag, behind a magic+salt header.
- Files written by the previous format now report UNSUPPORTED_FORMAT
  instead of a misleading password error.

Download
- Verify the password against the stored bcrypt hash before serving a
  file, and enforce expiresAt and DELETED/EXPIRED status.
- Move the password from the query string into a POST body so it stays
  out of access logs and Referer headers.
- Record a download only after successful authentication.
- Decrypt frame by frame through a stream instead of buffering the whole
  file, and encode the Content-Disposition filename per RFC 5987.

Data exposure
- /api/download ran before the password prompt and returned the full
  transfer row, including absolute server file paths. It now returns
  only what the pre-password screen renders; filenames, message and
  recipient are withheld until /api/verify succeeds.

Correctness
- Fix BigInt handling that made /api/transfers and /api/transfers/[id]
  fail unconditionally (JSON.stringify cannot serialize BigInt, and
  seeding a BigInt reduce with 0 throws).
- Fail loudly on a missing chunk during reassembly rather than silently
  writing a corrupt file.
- Meter plan usage in plaintext bytes rather than on-disk encrypted size.

Ignore /uploads: it holds runtime transfer payloads, not source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-08 08:48:22 +02:00
parent 95a4bc74fa
commit 2e688c8e52
64 changed files with 9268 additions and 169 deletions
+412
View File
@@ -0,0 +1,412 @@
// pages/api/chunk-upload.ts
// Chunked upload endpoint for large files with encryption
import { NextApiRequest, NextApiResponse } from 'next';
import { IncomingForm } from 'formidable';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { UPLOAD_DIR } from '@/lib/config';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcrypt';
import { PLAN_CONFIG } from '@/lib/subscription';
import { sendMailjetEmail } from '@/lib/mailjet';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
export const config = {
api: {
bodyParser: false,
},
};
interface ChunkMetadata {
uploadId: string;
chunkIndex: number;
totalChunks: number;
chunkSize: number;
}
interface UploadSession {
uploadId: string;
totalSize: number;
totalChunks: number;
receivedChunks: Set<number>;
chunkSize: number;
sender: string;
recipient: string;
password?: string;
encryptionIv: string;
originalFilename: string;
message?: string;
filenames?: string;
createdAt: Date;
}
// Store active upload sessions in memory (in production, use Redis)
const uploadSessions = new Map<string, UploadSession>();
// Cleanup abandoned uploads every 5 minutes (default 24h timeout)
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours
setInterval(() => {
const now = Date.now();
for (const [uploadId, session] of uploadSessions.entries()) {
if (now - session.createdAt.getTime() > SESSION_TIMEOUT) {
uploadSessions.delete(uploadId);
// Clean up temp files
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
fsPromises.rm(chunkDir, { recursive: true }).catch(err =>
console.warn(`Failed to clean abandoned upload ${uploadId}:`, err)
);
}
}
}, 5 * 60 * 1000); // Run every 5 minutes
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user?.email) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const sessionEmail = session.user.email;
if (req.method === 'POST') {
return handleChunkUpload(req, res, sessionEmail);
} else if (req.method === 'GET') {
return handleStatusCheck(req, res, sessionEmail);
} else if (req.method === 'PUT') {
return handleChunkComplete(req, res, sessionEmail);
}
return res.status(405).json({ success: false, message: 'Method not allowed' });
}
async function handleChunkUpload(
req: NextApiRequest,
res: NextApiResponse,
sessionEmail: string
) {
const form = new IncomingForm({
uploadDir: path.join(UPLOAD_DIR, '.tmp'),
keepExtensions: true,
});
// Ensure temp directory exists
try {
await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true });
} catch (err) {
console.error('Failed to create temp directory:', err);
}
return new Promise((resolve) => {
form.parse(req, async (err: Error | null, fields: any, files: any) => {
if (err) {
return resolve(res.status(400).json({ success: false, message: 'Parse error: ' + err.message }));
}
try {
const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk;
const uploadId = Array.isArray(fields.uploadId) ? fields.uploadId[0] : fields.uploadId;
const chunkIndex = parseInt(Array.isArray(fields.chunkIndex) ? fields.chunkIndex[0] : fields.chunkIndex);
const totalChunks = parseInt(Array.isArray(fields.totalChunks) ? fields.totalChunks[0] : fields.totalChunks);
const chunkSize = parseInt(Array.isArray(fields.chunkSize) ? fields.chunkSize[0] : fields.chunkSize);
if (!chunkFile || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks)) {
return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' }));
}
// Get or create upload session
let session = uploadSessions.get(uploadId);
if (session && session.sender !== sessionEmail) {
return resolve(res.status(403).json({ success: false, message: 'Forbidden' }));
}
if (!session) {
const sender = sessionEmail;
const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email;
const password = Array.isArray(fields.password) ? fields.password[0] : fields.password;
const encryptionIv = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv;
const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename;
const message = Array.isArray(fields.message) ? fields.message[0] : fields.message;
const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames;
if (!sender || !recipient || !encryptionIv) {
return resolve(res.status(400).json({ success: false, message: 'Missing required fields' }));
}
// Verify user exists
const user = await prisma.user.findUnique({ where: { email: sender } });
if (!user) {
return resolve(res.status(404).json({ success: false, message: 'User not found' }));
}
session = {
uploadId,
totalSize: chunkSize * totalChunks,
totalChunks,
receivedChunks: new Set(),
chunkSize,
sender,
recipient,
password,
encryptionIv,
originalFilename,
message,
filenames,
createdAt: new Date(),
};
// Check plan limits before accepting upload
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
const limits = PLAN_CONFIG[plan];
if (limits.maxFileSize !== Infinity && session.totalSize > limits.maxFileSize) {
return resolve(res.status(413).json({
success: false,
message: `File exceeds max size for your plan (${plan})`
}));
}
uploadSessions.set(uploadId, session);
}
// Save chunk to temp location
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
await fsPromises.mkdir(chunkDir, { recursive: true });
const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`);
await new Promise<void>((resolve, reject) => {
const readStream = fs.createReadStream(chunkFile.filepath);
const writeStream = fs.createWriteStream(chunkPath);
readStream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
readStream.pipe(writeStream);
});
// Clean up formidable temp file
try {
await fsPromises.unlink(chunkFile.filepath);
} catch (err) {
console.warn('Failed to clean up temp file:', err);
}
session.receivedChunks.add(chunkIndex);
return resolve(res.status(200).json({
success: true,
uploadId,
chunkIndex,
receivedChunks: Array.from(session.receivedChunks),
}));
} catch (err: any) {
console.error('Chunk upload error:', err);
return resolve(res.status(500).json({ success: false, message: 'Upload error: ' + err.message }));
}
});
});
}
async function handleStatusCheck(
req: NextApiRequest,
res: NextApiResponse,
sessionEmail: string
) {
const { uploadId } = req.query;
if (!uploadId || typeof uploadId !== 'string') {
return res.status(400).json({ success: false, message: 'Missing uploadId' });
}
const session = uploadSessions.get(uploadId);
if (!session || session.sender !== sessionEmail) {
return res.status(404).json({ success: false, message: 'Upload session not found' });
}
return res.status(200).json({
success: true,
uploadId,
totalChunks: session.totalChunks,
receivedChunks: Array.from(session.receivedChunks),
isComplete: session.receivedChunks.size === session.totalChunks,
});
}
async function handleChunkComplete(
req: NextApiRequest,
res: NextApiResponse,
sessionEmail: string
) {
const { uploadId } = req.query;
if (!uploadId || typeof uploadId !== 'string') {
return res.status(400).json({ success: false, message: 'Missing uploadId' });
}
const session = uploadSessions.get(uploadId);
if (!session || session.sender !== sessionEmail) {
return res.status(404).json({ success: false, message: 'Upload session not found' });
}
// Check if all chunks received
if (session.receivedChunks.size !== session.totalChunks) {
return res.status(400).json({
success: false,
message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`,
});
}
try {
// Reassemble chunks into final file
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
const finalFilename = uuidv4() + '.enc';
const finalPath = path.join(UPLOAD_DIR, finalFilename);
const writeStream = fs.createWriteStream(finalPath);
for (let i = 0; i < session.totalChunks; i++) {
const chunkPath = path.join(chunkDir, `chunk-${i}`);
// Retry reading with exponential backoff to handle file locks
let chunkData: Buffer | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
chunkData = await fsPromises.readFile(chunkPath);
break;
} catch (err: any) {
if (attempt < 2 && err.code === 'EACCES') {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
} else {
throw err;
}
}
}
if (chunkData) {
writeStream.write(chunkData);
}
}
await new Promise<void>((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
writeStream.end();
});
// Get final file size
const stats = await fsPromises.stat(finalPath);
// Verify user and check monthly limits
const user = await prisma.user.findUnique({ where: { email: session.sender } });
if (!user) {
await fsPromises.unlink(finalPath);
return res.status(404).json({ success: false, message: 'User not found' });
}
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
const limits = PLAN_CONFIG[plan];
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const transfersThisMonth = await prisma.transfer.findMany({
where: {
senderEmail: session.sender,
createdAt: { gte: startOfMonth },
},
});
const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0));
if (
(limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) ||
(limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(stats.size) > BigInt(limits.maxTransferSizePerMonth))
) {
await fsPromises.unlink(finalPath);
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
}
// Hash password
const hash = session.password ? await bcrypt.hash(session.password, 10) : null;
// Parse filenames
let totalFiles = 1;
if (session.filenames) {
try {
const filesArray = JSON.parse(session.filenames);
if (Array.isArray(filesArray)) {
totalFiles = filesArray.length;
}
} catch {}
}
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
// Create transfer record
const transfer = await prisma.transfer.create({
data: {
senderEmail: session.sender,
recipientEmail: session.recipient,
passwordHash: hash,
downloadUrl: uuidv4(),
expiresAt,
filenames: session.filenames || null,
message: session.message || null,
totalFiles,
totalSize: stats.size,
userId: user.id,
},
});
// Create file record separately
await prisma.file.create({
data: {
name: session.originalFilename || 'file',
path: finalPath,
size: stats.size,
transferId: transfer.id,
},
});
// Update transfer with encryption IV in filenames field (temporary workaround)
// TODO: Fix Prisma type generation for encryptionIv field
if (session.encryptionIv) {
// Store IV in a separate metadata approach or update via raw query
await (prisma as any).$executeRaw`
UPDATE "File" SET "encryptionIv" = ${session.encryptionIv}
WHERE "transferId" = ${transfer.id}
`;
}
// Send email
const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`;
await sendMailjetEmail({
to: session.recipient,
subject: 'You\'ve received an encrypted file',
text: `
${session.sender} sent you an encrypted file via TransferTribe.
Link: ${link}
${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it.
`.trim(),
});
// Clean up session and temp files
uploadSessions.delete(uploadId);
try {
await fsPromises.rm(chunkDir, { recursive: true });
} catch (err) {
console.warn('Failed to clean up temp directory:', err);
}
return res.status(200).json({
success: true,
message: 'Upload complete',
downloadUrl: transfer.downloadUrl,
});
} catch (err: any) {
console.error('Chunk completion error:', err);
return res.status(500).json({ success: false, message: 'Completion error: ' + err.message });
}
}