From e497ad1da6c6e4c5198f247bf217131615842781 Mon Sep 17 00:00:00 2001 From: Knee Cola Date: Mon, 8 Dec 2025 00:17:10 +0100 Subject: [PATCH] feat: implement secure uploadProofOfPayment with multi-layer validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security improvements: - Add checksum validation (prevents unauthorized access) - Add IP-based rate limiting (prevents abuse) - Replace MIME type check with PDF magic bytes validation - Add shareTTL expiry validation - Add automatic cleanup of expired shares - Sanitize error messages (generic responses to clients) Breaking change: Function signature now requires checksum parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- app/lib/actions/billActions.ts | 199 +++++++++++++++++++-------------- 1 file changed, 113 insertions(+), 86 deletions(-) diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index dfb1d52..4211425 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -10,6 +10,9 @@ import { gotoHomeWithMessage } from './navigationActions'; import { getTranslations, getLocale } from "next-intl/server"; import { IntlTemplateFn } from '@/app/i18n'; import { unstable_noStore, revalidatePath } from 'next/cache'; +import { validateShareChecksum } from '../shareChecksum'; +import { validatePdfFile } from '../validators/pdfValidator'; +import { checkUploadRateLimit } from '../uploadRateLimiter'; export type State = { errors?: { @@ -488,94 +491,118 @@ export const deleteBillById = withUser(async (user: AuthenticatedUser, locationI /** * Uploads proof of payment for the given bill - * @param locationID - The ID of the location - * @param formData - FormData containing the file - * @returns Promise with success status + * SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP */ -export const uploadProofOfPayment = async (locationID: string, billID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => { +export const uploadProofOfPayment = async ( + locationID: string, + billID: string, + checksum: string, + formData: FormData, + ipAddress?: string +): Promise<{ success: boolean; error?: string }> => { - unstable_noStore(); + unstable_noStore(); - try { - // First validate that the file is acceptable - const file = formData.get('proofOfPayment') as File; - - // validate max file size from env variable - const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10); - const maxFileSizeBytes = maxFileSizeKB * 1024; - - if (file && file.size > maxFileSizeBytes) { - return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` }; - } - - // Validate file type - if (file && file.size > 0 && file.type !== 'application/pdf') { - return { success: false, error: 'Only PDF files are accepted' }; - } - - // update the bill in the mongodb - const dbClient = await getDbClient(); - - const projection = { - // attachment is not required in this context - this will reduce data transfer - "bills.attachment": 0, - // ommit file content - not needed here - this will reduce data transfer - "bills.proofOfPayment.fileContentsBase64": 0, - }; - - // Checking if proof of payment already exists - - // find a location with the given locationID - const billLocation = await dbClient.collection("lokacije").findOne( - { - _id: locationID, - }, - { - projection - }) - - if (!billLocation) { - console.log(`Location ${locationID} not found - Proof of payment upload failed`); - return { success: false, error: "Location not found - Proof of payment upload failed" }; - } - - // find a bill with the given billID - const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); - - - if (bill?.proofOfPayment?.uploadedAt) { - return { success: false, error: 'Proof payment already uploaded for this bill' }; - } - - const attachment = await serializeAttachment(file); - - if (!attachment) { - return { success: false, error: 'Invalid file' }; - } - - // Add proof of payment to the bill - await dbClient.collection("lokacije").updateOne( - { - _id: locationID // find a location with the given locationID - }, - { - $set: { - "bills.$[elem].proofOfPayment": { - ...attachment - } - } - }, { - arrayFilters: [ - { "elem._id": { $eq: billID } } // find a bill with the given billID - ] - }); - - // Invalidate the location view cache - revalidatePath(`/share/location/${locationID}`, 'page'); - - return { success: true }; - } catch (error: any) { - console.error('Error uploading proof of payment for a bill:', error); - return { success: false, error: error.message || 'Upload failed' }; + try { + // 1. VALIDATE CHECKSUM (stateless, fast) + if (!validateShareChecksum(locationID, checksum)) { + return { success: false, error: 'Invalid share link' }; } + + // 2. RATE LIMITING (per IP) + if (ipAddress) { + const rateLimit = checkUploadRateLimit(ipAddress); + if (!rateLimit.allowed) { + return { + success: false, + error: `Too many uploads. Try again in ${Math.ceil(rateLimit.resetIn / 60)} minutes.` + }; + } + } + + // 3. DATABASE VALIDATION + const dbClient = await getDbClient(); + + const location = await dbClient.collection("lokacije").findOne( + { _id: locationID }, + { projection: { userId: 1, bills: 1, shareTTL: 1 } } + ); + + if (!location || !location.userId) { + return { success: false, error: 'Invalid request' }; + } + + // Check sharing is active and not expired + if (!location.shareTTL || new Date() > location.shareTTL) { + return { success: false, error: 'This content is no longer shared' }; + } + + // Verify bill exists in location + const bill = location.bills.find(b => b._id === billID); + if (!bill) { + return { success: false, error: 'Invalid request' }; + } + + // Check if proof of payment already uploaded + if (bill.proofOfPayment?.uploadedAt) { + return { success: false, error: 'Proof of payment already uploaded for this bill' }; + } + + // 4. FILE VALIDATION + const file = formData.get('proofOfPayment') as File; + + if (!file || file.size === 0) { + return { success: false, error: 'No file provided' }; + } + + // Validate PDF content (magic bytes, not just MIME type) + const pdfValidation = await validatePdfFile(file); + if (!pdfValidation.valid) { + return { success: false, error: pdfValidation.error }; + } + + // 5. SERIALIZE & STORE FILE + const attachment = await serializeAttachment(file); + if (!attachment) { + return { success: false, error: 'Failed to process file' }; + } + + // 6. UPDATE DATABASE + await dbClient.collection("lokacije").updateOne( + { _id: locationID }, + { + $set: { + "bills.$[elem].proofOfPayment": attachment + } + }, + { + arrayFilters: [{ "elem._id": { $eq: billID } }] + } + ); + + // 7. CLEANUP EXPIRED SHARES (integrated, no cron needed) + await cleanupExpiredShares(dbClient); + + // 8. REVALIDATE CACHE + revalidatePath(`/share/location/${locationID}/${checksum}`, 'page'); + + return { success: true }; + + } catch (error: any) { + console.error('Upload error:', error); + return { success: false, error: 'Upload failed. Please try again.' }; + } +}; + +/** + * Clean up expired shares during upload processing + * Removes shareTTL and shareFirstVisitedAt from expired locations + */ +async function cleanupExpiredShares(dbClient: any) { + const now = new Date(); + + await dbClient.collection("lokacije").updateMany( + { shareTTL: { $lt: now } }, + { $unset: { shareTTL: "", shareFirstVisitedAt: "" } } + ); } \ No newline at end of file