diff --git a/app/frontend/src/lib/csv-validation.ts b/app/frontend/src/lib/csv-validation.ts index 4bd0578..0c43aec 100644 --- a/app/frontend/src/lib/csv-validation.ts +++ b/app/frontend/src/lib/csv-validation.ts @@ -3,6 +3,25 @@ import { fetchClient } from '@/lib/mock-api/client'; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; +export const CSV_MAX_ROWS = 10_000; + +export const FIELD_MAX_LENGTHS: Record = { + name: 120, + fullname: 120, + recipientname: 120, + wallet: 64, + walletaddress: 64, + stellarwallet: 64, + publickey: 64, + phone: 20, + phonenumber: 20, + mobile: 20, +}; + +const DEFAULT_FIELD_MAX_LENGTH = 255; + +const INJECTION_PATTERN = /^[=+\-@\t\r]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; + export type WizardStep = 1 | 2 | 3 | 4; export type ValidationSeverity = 'valid' | 'warning' | 'error'; @@ -57,6 +76,37 @@ function normalizeRecord(record: Record): Record maxLen) { + messages.push({ + severity: 'error', + field: fieldKey, + message: `Value exceeds the ${maxLen}-character limit (${value.length} chars).`, + }); + } + + if (INJECTION_PATTERN.test(value)) { + messages.push({ + severity: 'error', + field: fieldKey, + message: 'Value contains disallowed characters or may be a formula injection attempt.', + }); + } + + return messages; +} + export async function parseRecipientsCsv(file: File): Promise { return new Promise((resolve, reject) => { Papa.parse>(file, { @@ -69,10 +119,32 @@ export async function parseRecipientsCsv(file: File): Promise { return; } - const rows = (results.data ?? []) + const rawRows = (results.data ?? []) .map(normalizeRecord) - .filter(row => Object.values(row).some(Boolean)) - .map((values, index) => ({ index: index + 1, values })); + .filter(row => Object.values(row).some(Boolean)); + + if (rawRows.length > CSV_MAX_ROWS) { + reject( + new Error( + `The CSV file contains ${rawRows.length.toLocaleString()} rows, which exceeds the ` + + `${CSV_MAX_ROWS.toLocaleString()}-row limit. Split the file and import in batches.` + ) + ); + return; + } + + const rows = rawRows.map((values, index) => { + const sanitized = Object.fromEntries( + Object.entries(values).map(([key, val]) => [ + key, + sanitizeField( + val, + FIELD_MAX_LENGTHS[key.toLowerCase().replace(/[_\s-]+/g, '')] ?? DEFAULT_FIELD_MAX_LENGTH + ), + ]) + ); + return { index: index + 1, values: sanitized }; + }); const headers = results.meta.fields?.map(header => header.trim()).filter(Boolean) ?? @@ -124,6 +196,12 @@ function buildLocalValidation(rows: CsvPreviewRow[]): ValidationResult { messages.push({ severity: 'warning', field: 'phone', message: 'Phone number is missing.' }); } + for (const [fieldKey, fieldValue] of Object.entries(values)) { + if (fieldValue) { + messages.push(...validateField(fieldKey, fieldValue)); + } + } + const status: ValidationSeverity = messages.some(message => message.severity === 'error') ? 'error' : messages.some(message => message.severity === 'warning') diff --git a/app/frontend/src/lib/mock-api/handlers.ts b/app/frontend/src/lib/mock-api/handlers.ts index 69423af..857a460 100644 --- a/app/frontend/src/lib/mock-api/handlers.ts +++ b/app/frontend/src/lib/mock-api/handlers.ts @@ -227,6 +227,15 @@ const campaignUpdateHandler: MockHandler = async (url, options) => { }; const recipientsImportValidateHandler: MockHandler = async (_url, options) => { + const MAX_ROWS = 10_000; + const MAX_FIELD_LENGTH: Record = { + name: 120, fullname: 120, recipientname: 120, + wallet: 64, walletaddress: 64, stellarwallet: 64, publickey: 64, + phone: 20, phonenumber: 20, mobile: 20, + }; + const DEFAULT_MAX = 255; + const INJECTION_RE = /^[=+\-@\t\r]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; + const body = options?.body; if (!(body instanceof FormData)) { @@ -256,6 +265,17 @@ const recipientsImportValidateHandler: MockHandler = async (_url, options) => { .map(value => value.trim()) .filter(Boolean); + if (dataLines.length > MAX_ROWS) { + return new Response( + JSON.stringify({ + success: false, + message: `CSV exceeds the ${MAX_ROWS.toLocaleString()}-row limit ` + + `(${dataLines.length.toLocaleString()} rows found). Split the file and import in batches.`, + }), + { status: 422, headers: { 'Content-Type': 'application/json' } } + ); + } + const normalizedHeaders = headers.map(header => header.toLowerCase().replace(/[_\s-]+/g, '')); const nameIndex = normalizedHeaders.findIndex(header => ['name', 'fullname', 'recipientname'].includes(header)); const walletIndex = normalizedHeaders.findIndex(header => ['wallet', 'walletaddress', 'stellarwallet', 'publickey'].includes(header)); @@ -282,6 +302,28 @@ const recipientsImportValidateHandler: MockHandler = async (_url, options) => { messages.push({ severity: 'warning', field: 'phone', message: 'Phone number is missing.' }); } + headers.forEach((header, colIndex) => { + const fieldValue = values[colIndex] ?? ''; + const normalizedKey = header.toLowerCase().replace(/[_\s-]+/g, ''); + const maxLen = MAX_FIELD_LENGTH[normalizedKey] ?? DEFAULT_MAX; + + if (fieldValue.length > maxLen) { + messages.push({ + severity: 'error', + field: header, + message: `Value exceeds the ${maxLen}-character limit (${fieldValue.length} chars).`, + }); + } + + if (fieldValue && INJECTION_RE.test(fieldValue)) { + messages.push({ + severity: 'error', + field: header, + message: 'Value contains disallowed characters or may be a formula injection attempt.', + }); + } + }); + const status = messages.some(message => message.severity === 'error') ? 'error'