feat: implement secure uploadProofOfPayment with multi-layer validation
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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: "" } }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user