feat(import): harden workbook parser boundaries
This commit is contained in:
@@ -3,6 +3,8 @@ const CSV_EXTENSION = ".csv";
|
||||
const XLS_EXTENSION = ".xls";
|
||||
|
||||
export const MAX_BROWSER_SPREADSHEET_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_BROWSER_SPREADSHEET_ROWS = 5000;
|
||||
export const MAX_BROWSER_SPREADSHEET_COLUMNS = 200;
|
||||
|
||||
type ExcelJsModule = typeof import("exceljs");
|
||||
let _excelJs: ExcelJsModule | null = null;
|
||||
@@ -117,8 +119,47 @@ function parseCsvMatrix(input: string): string[][] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function matrixToObjects(rows: string[][]): Record<string, string>[] {
|
||||
export function assertTabularMatrixWithinLimits(rows: string[][], contextLabel: string): void {
|
||||
if (rows.length > MAX_BROWSER_SPREADSHEET_ROWS + 1) {
|
||||
throw new Error(
|
||||
`The selected file exceeds the ${MAX_BROWSER_SPREADSHEET_ROWS} row limit for ${contextLabel}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const widestRow = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
if (widestRow > MAX_BROWSER_SPREADSHEET_COLUMNS) {
|
||||
throw new Error(
|
||||
`The selected file exceeds the ${MAX_BROWSER_SPREADSHEET_COLUMNS} column limit for ${contextLabel}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertHeaderRow(headers: string[], contextLabel: string): void {
|
||||
if (headers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blankHeaderIndex = headers.findIndex((header) => header.length === 0);
|
||||
if (blankHeaderIndex >= 0) {
|
||||
throw new Error(
|
||||
`The selected file contains an empty header cell in column ${blankHeaderIndex + 1} and cannot be used for ${contextLabel}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const header of headers) {
|
||||
const normalized = header.toLowerCase();
|
||||
if (seen.has(normalized)) {
|
||||
throw new Error(`The selected file contains duplicate header "${header}" and cannot be used for ${contextLabel}.`);
|
||||
}
|
||||
seen.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function matrixToObjects(rows: string[][], contextLabel: string): Record<string, string>[] {
|
||||
assertTabularMatrixWithinLimits(rows, contextLabel);
|
||||
const headers = (rows[0] ?? []).map((header) => header.trim());
|
||||
assertHeaderRow(headers, contextLabel);
|
||||
if (headers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -203,7 +244,7 @@ async function parseXlsxSpreadsheet(file: File): Promise<Record<string, string>[
|
||||
rows.push(cells);
|
||||
}
|
||||
|
||||
return matrixToObjects(rows);
|
||||
return matrixToObjects(rows, "spreadsheet import");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +255,7 @@ export async function parseSpreadsheet(file: File): Promise<Record<string, strin
|
||||
assertSpreadsheetFile(file);
|
||||
|
||||
if (getFileExtension(file.name) === CSV_EXTENSION) {
|
||||
return matrixToObjects(parseCsvMatrix(await file.text()));
|
||||
return matrixToObjects(parseCsvMatrix(await file.text()), "spreadsheet import");
|
||||
}
|
||||
|
||||
return parseXlsxSpreadsheet(file);
|
||||
|
||||
Reference in New Issue
Block a user