2e688c8e52
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>
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
// pages/api/send.ts
|
|
import { prisma } from '@/lib/prisma';
|
|
import { UPLOAD_DIR } from '@/lib/config';
|
|
import { PLAN_CONFIG } from '@/lib/subscription';
|
|
import { sendMailjetEmail } from '@/lib/mailjet';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import bcrypt from 'bcrypt';
|
|
import path from 'path';
|
|
import { IncomingForm, File as FormidableFile } from 'formidable';
|
|
import fs from 'fs';
|
|
import { promises as fsPromises } from 'fs';
|
|
import { NextApiRequest, NextApiResponse } from 'next';
|
|
import { getServerSession } from 'next-auth/next';
|
|
import { authOptions } from '@/lib/auth';
|
|
|
|
export const config = {
|
|
api: {
|
|
bodyParser: false,
|
|
},
|
|
};
|
|
|
|
interface ParsedFields {
|
|
[key: string]: string | string[] | undefined;
|
|
}
|
|
|
|
interface ParsedFiles {
|
|
[key: string]: FormidableFile | FormidableFile[] | undefined;
|
|
}
|
|
|
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
if (req.method !== 'POST') {
|
|
return res.status(405).json({ success: false, message: 'Method not allowed' });
|
|
}
|
|
|
|
const session = await getServerSession(req, res, authOptions);
|
|
if (!session?.user?.email) {
|
|
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
|
}
|
|
|
|
const form = new IncomingForm({
|
|
uploadDir: UPLOAD_DIR,
|
|
keepExtensions: true,
|
|
maxFileSize: 100 * 1024 * 1024 * 1024, // 100GB
|
|
});
|
|
|
|
const parseForm = (): Promise<{ fields: ParsedFields; files: ParsedFiles }> =>
|
|
new Promise((resolve, reject) => {
|
|
form.parse(req, (err: Error | null, fields: any, files: any) => {
|
|
if (err) return reject(err);
|
|
resolve({ fields, files });
|
|
});
|
|
});
|
|
|
|
try {
|
|
const { fields, files } = await parseForm();
|
|
|
|
// Extract form fields
|
|
const encryptedFile = Array.isArray(files.file) ? files.file[0] : files.file;
|
|
const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email;
|
|
const password = Array.isArray(fields.password) ? fields.password[0] : fields.password;
|
|
// Sender is taken from the session, never the request body, so a caller
|
|
// cannot post transfers (and consume quota) as another user.
|
|
const sender = session.user.email;
|
|
const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames;
|
|
const message = Array.isArray(fields.message) ? fields.message[0] : fields.message;
|
|
const encryptionIvStr = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv;
|
|
const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename;
|
|
|
|
if (!encryptedFile || !recipient || !sender) {
|
|
return res.status(400).json({ success: false, message: 'Missing required fields' });
|
|
}
|
|
|
|
// Ensure upload directory exists
|
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
}
|
|
|
|
// Generate final filename and move encrypted file
|
|
const finalFilename = uuidv4() + '.enc';
|
|
const finalPath = path.join(UPLOAD_DIR, finalFilename);
|
|
|
|
// Use streaming copy for large files to avoid permission issues
|
|
await new Promise<void>((resolve, reject) => {
|
|
const readStream = fs.createReadStream(encryptedFile.filepath);
|
|
const writeStream = fs.createWriteStream(finalPath);
|
|
|
|
readStream.on('error', reject);
|
|
writeStream.on('error', reject);
|
|
writeStream.on('finish', resolve);
|
|
|
|
readStream.pipe(writeStream);
|
|
});
|
|
|
|
// Clean up temp file after successful copy
|
|
try {
|
|
await fsPromises.unlink(encryptedFile.filepath);
|
|
} catch (err) {
|
|
console.warn('Failed to clean up temp file:', err);
|
|
}
|
|
|
|
// Verify user exists
|
|
const user = await prisma.user.findUnique({ where: { email: sender } });
|
|
if (!user) {
|
|
fs.unlinkSync(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];
|
|
|
|
// Check file size against plan limits
|
|
const stats = fs.statSync(finalPath);
|
|
if (limits.maxFileSize !== Infinity && stats.size > limits.maxFileSize) {
|
|
fs.unlinkSync(finalPath);
|
|
return res.status(413).json({
|
|
success: false,
|
|
message: `File exceeds max size for your plan (${plan})`
|
|
});
|
|
}
|
|
|
|
// Check monthly transfer limits
|
|
const startOfMonth = new Date();
|
|
startOfMonth.setDate(1);
|
|
startOfMonth.setHours(0, 0, 0, 0);
|
|
|
|
const transfersThisMonth = await prisma.transfer.findMany({
|
|
where: {
|
|
senderEmail: 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)
|
|
) {
|
|
fs.unlinkSync(finalPath);
|
|
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
|
|
}
|
|
|
|
// Hash password if provided
|
|
const hash = password ? await bcrypt.hash(password, 10) : null;
|
|
|
|
// Parse filenames if provided
|
|
let totalFiles = 1;
|
|
if (filenames) {
|
|
try {
|
|
const filesArray = JSON.parse(filenames);
|
|
if (Array.isArray(filesArray)) {
|
|
totalFiles = filesArray.length;
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
|
|
|
// Create transfer record with encrypted file
|
|
const transfer = await prisma.transfer.create({
|
|
data: {
|
|
senderEmail: sender,
|
|
recipientEmail: recipient,
|
|
passwordHash: hash,
|
|
downloadUrl: uuidv4(),
|
|
expiresAt,
|
|
filenames: filenames || null,
|
|
message: message || null,
|
|
totalFiles,
|
|
totalSize: stats.size,
|
|
userId: user?.id,
|
|
files: {
|
|
create: [
|
|
{
|
|
name: originalFilename || 'file',
|
|
path: finalPath,
|
|
size: stats.size,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Update file with encryption IV using raw query
|
|
if (encryptionIvStr) {
|
|
await (prisma as any).$executeRaw`
|
|
UPDATE "File" SET "encryptionIv" = ${encryptionIvStr}
|
|
WHERE "transferId" = ${transfer.id}
|
|
`;
|
|
}
|
|
|
|
// Send email notification
|
|
const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`;
|
|
|
|
await sendMailjetEmail({
|
|
to: recipient,
|
|
subject: 'You\'ve received an encrypted file',
|
|
text: `
|
|
${sender} sent you an encrypted file via TransferTribe.
|
|
|
|
Link: ${link}
|
|
|
|
${message ? `Message:\n${message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it.
|
|
`.trim(),
|
|
});
|
|
|
|
return res.status(200).json({ success: true });
|
|
} catch (err: any) {
|
|
console.error('Upload failed:', err);
|
|
return res.status(500).json({ success: false, message: 'Upload failed: ' + err.message });
|
|
}
|
|
}
|