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:
@@ -0,0 +1,428 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { encryptBlob } from '@/lib/encryption';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload, File, X, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const PLAN_LIMITS = {
|
||||
free: {
|
||||
maxFileSizeMB: 2048,
|
||||
maxMonthlyTransferSizeMB: 10240,
|
||||
expiryText: '48 hours after sending',
|
||||
},
|
||||
rookie: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 30720,
|
||||
expiryText: '7 days after sending',
|
||||
},
|
||||
pro: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 1024 * 1024,
|
||||
expiryText: '30 days after sending',
|
||||
},
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
||||
|
||||
export default function SendPage() {
|
||||
const { data: session } = useSession();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free');
|
||||
const [remainingMB, setRemainingMB] = useState(Infinity);
|
||||
const [usedMB, setUsedMB] = useState(0);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadStep, setUploadStep] = useState('');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
const [currentChunk, setCurrentChunk] = useState(0);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
|
||||
const totalSizeMB = totalSize / 1024 / 1024;
|
||||
const isOverLimit =
|
||||
totalSizeMB > PLAN_LIMITS[plan].maxFileSizeMB || totalSizeMB > remainingMB;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlan = async () => {
|
||||
const res = await fetch('/api/usage');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlan(data.plan);
|
||||
setRemainingMB(data.remainingMB);
|
||||
setUsedMB(data.usedMB);
|
||||
}
|
||||
};
|
||||
if (session?.user?.email) fetchPlan();
|
||||
}, [session]);
|
||||
|
||||
const handleFileAdd = (newFiles: File[]) => {
|
||||
const unique = newFiles.filter(
|
||||
(newFile) =>
|
||||
!files.find(
|
||||
(existing) =>
|
||||
existing.name === newFile.name && existing.size === newFile.size
|
||||
)
|
||||
);
|
||||
setFiles((prev) => [...prev, ...unique]);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) return;
|
||||
handleFileAdd(Array.from(e.target.files));
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.files) {
|
||||
handleFileAdd(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
dropRef.current?.classList.remove('border-blue-500');
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerFileInput = () => fileInputRef.current?.click();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!files.length)
|
||||
return setUploadMessage('Please select at least one file');
|
||||
if (!recipient || !password)
|
||||
return setUploadMessage('All fields are required');
|
||||
if (isOverLimit)
|
||||
return setUploadMessage('File size exceeds your plan limits');
|
||||
const senderEmail = session?.user?.email;
|
||||
if (!senderEmail)
|
||||
return setUploadMessage('Unable to get sender email. Please log in.');
|
||||
|
||||
try {
|
||||
setUploadStep('Preparing files');
|
||||
const file = files[0]; // Upload first file for now
|
||||
|
||||
setUploadStep('Encrypting');
|
||||
// Encrypt the file using the password
|
||||
const { encryptedBlob } = await encryptBlob(file, password);
|
||||
|
||||
// Convert blob to Uint8Array once to avoid multiple arrayBuffer() calls
|
||||
// (which can cause "NotReadableError" on some browsers)
|
||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||
const iv = encryptedBytes.slice(0, 12);
|
||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||
|
||||
// Calculate chunks
|
||||
const CHUNK_SIZE_BYTES = CHUNK_SIZE;
|
||||
const totalChunksCount = Math.ceil(encryptedBytes.length / CHUNK_SIZE_BYTES);
|
||||
setTotalChunks(totalChunksCount);
|
||||
|
||||
// Generate upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
// Upload chunks
|
||||
setUploadStep('Uploading');
|
||||
for (let i = 0; i < totalChunksCount; i++) {
|
||||
setCurrentChunk(i + 1);
|
||||
|
||||
const start = i * CHUNK_SIZE_BYTES;
|
||||
const end = Math.min(start + CHUNK_SIZE_BYTES, encryptedBytes.length);
|
||||
const chunkBytes = encryptedBytes.slice(start, end);
|
||||
const chunkBlob = new Blob([chunkBytes]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('chunk', chunkBlob);
|
||||
formData.append('uploadId', uploadId);
|
||||
formData.append('chunkIndex', i.toString());
|
||||
formData.append('totalChunks', totalChunksCount.toString());
|
||||
formData.append('chunkSize', CHUNK_SIZE_BYTES.toString());
|
||||
|
||||
// Only send metadata on first chunk
|
||||
if (i === 0) {
|
||||
formData.append('email', recipient);
|
||||
formData.append('password', password);
|
||||
formData.append('sender', senderEmail);
|
||||
formData.append('originalFilename', file.name);
|
||||
formData.append('encryptionIv', ivBase64);
|
||||
const filenames = files.map((f) => f.name);
|
||||
formData.append('filenames', JSON.stringify(filenames));
|
||||
formData.append('message', message);
|
||||
}
|
||||
|
||||
const uploadRes = await fetch('/api/chunk-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const error = await uploadRes.json();
|
||||
throw new Error(error.message || `Chunk ${i} upload failed`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const chunkProgress = ((i + 1) / totalChunksCount) * 100;
|
||||
setUploadProgress(Math.round(chunkProgress));
|
||||
}
|
||||
|
||||
// Finalize upload
|
||||
setUploadStep('Finalizing');
|
||||
const finalRes = await fetch(`/api/chunk-upload?uploadId=${uploadId}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (!finalRes.ok) {
|
||||
const error = await finalRes.json();
|
||||
throw new Error(error.message || 'Failed to finalize upload');
|
||||
}
|
||||
|
||||
const result = await finalRes.json();
|
||||
|
||||
setUploadStep('');
|
||||
setUploadMessage('Success! 🎉');
|
||||
toast.success('Your transfer has been sent successfully!');
|
||||
setFiles([]);
|
||||
setRecipient('');
|
||||
setPassword('');
|
||||
setUploadProgress(100);
|
||||
setTimeout(() => setUploadMessage(''), 3000);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setUploadStep('');
|
||||
setUploadMessage('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function UploadStatusToast({
|
||||
step,
|
||||
progress,
|
||||
message,
|
||||
currentChunk,
|
||||
totalChunks,
|
||||
}: {
|
||||
step: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
currentChunk: number;
|
||||
totalChunks: number;
|
||||
}) {
|
||||
const showSpinner = ['Encrypting', 'Uploading', 'Finalizing'].includes(step);
|
||||
const isError =
|
||||
message.toLowerCase().includes('error') || message.includes('fail');
|
||||
|
||||
if (!step && !message) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 bg-slate-100 dark:bg-slate-800 rounded-lg shadow-md flex flex-col space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{showSpinner && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isError ? 'text-red-600' : 'text-slate-800 dark:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{step}{currentChunk && totalChunks ? ` (${currentChunk}/${totalChunks})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!isError && step === 'Uploading' && (
|
||||
<>
|
||||
<Progress value={progress} className="h-2" />
|
||||
<p className="text-xs text-red-600 font-medium">
|
||||
⚠️ Please do not close this page during upload!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* File Upload Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Upload Files
|
||||
</h3>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dropRef.current?.classList.add('border-blue-500');
|
||||
}}
|
||||
onDragLeave={() =>
|
||||
dropRef.current?.classList.remove('border-blue-500')
|
||||
}
|
||||
className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
|
||||
<Upload className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Drag & drop files here
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
or click to select files
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={triggerFileInput}
|
||||
>
|
||||
Select Files
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List */}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
Selected Files ({files.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<File className="w-4 h-4 flex-shrink-0 text-slate-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-800 rounded"
|
||||
>
|
||||
<X className="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size Info */}
|
||||
<div className="mt-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p>Total Size: {formatFileSize(totalSize)}</p>
|
||||
{remainingMB !== Infinity && (
|
||||
<p>Remaining: {remainingMB.toFixed(2)} MB</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transfer Details Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Transfer Details
|
||||
</h3>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="recipient" className="text-slate-700 dark:text-slate-300">
|
||||
Recipient Email
|
||||
</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
type="email"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-slate-700 dark:text-slate-300">
|
||||
Encryption Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a strong password"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-slate-700 dark:text-slate-300">
|
||||
Message (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Add a message to your recipient..."
|
||||
className="mt-1 resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-700 dark:text-red-400 text-sm">
|
||||
⚠️ File size exceeds your plan limits
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!files.length || isOverLimit || uploadStep !== ''}
|
||||
className="w-full"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Transfer
|
||||
</Button>
|
||||
|
||||
{uploadMessage && (
|
||||
<UploadStatusToast
|
||||
step={uploadStep}
|
||||
progress={uploadProgress}
|
||||
message={uploadMessage}
|
||||
currentChunk={currentChunk}
|
||||
totalChunks={totalChunks}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user