Skip to content

Commit 9ca5da4

Browse files
Copilothotlong
andcommitted
security: fix CSV formula injection, CSS injection in report export engine
- Add sanitizeCSVValue() to prevent formula injection in CSV/Excel exports - Add validatePageSize/validateOrientation to prevent CSS injection in HTML/PDF - Whitelist-based validation for CSS values Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 161a7be commit 9ca5da4

1 file changed

Lines changed: 39 additions & 6 deletions

File tree

packages/plugin-report/src/ReportExportEngine.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export function exportAsCSV(report: ReportSchema, data: any[], config?: ReportEx
2020
const fields = report.fields || [];
2121
const headers = fields.map(f => f.label || f.name);
2222
const rows = data.map(row => fields.map(f => {
23-
const val = row[f.name];
23+
let val = row[f.name];
24+
val = sanitizeCSVValue(val);
2425
// Escape CSV values
2526
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
2627
return `"${val.replace(/"/g, '""')}"`;
@@ -55,15 +56,16 @@ export function exportAsJSON(report: ReportSchema, data: any[], config?: ReportE
5556
*/
5657
export function exportAsHTML(report: ReportSchema, data: any[], config?: ReportExportConfig): void {
5758
const fields = report.fields || [];
58-
const orientation = config?.orientation || 'portrait';
59+
const orientation = validateOrientation(config?.orientation);
60+
const pageSize = validatePageSize(config?.pageSize);
5961

6062
const html = `<!DOCTYPE html>
6163
<html>
6264
<head>
6365
<meta charset="utf-8">
6466
<title>${escapeHTML(report.title || 'Report')}</title>
6567
<style>
66-
@page { size: ${config?.pageSize || 'A4'} ${orientation}; margin: 20mm; }
68+
@page { size: ${pageSize} ${orientation}; margin: 20mm; }
6769
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #333; }
6870
h1 { font-size: 24px; margin-bottom: 8px; }
6971
.description { color: #666; margin-bottom: 24px; }
@@ -93,7 +95,8 @@ ${report.description ? `<p class="description">${escapeHTML(report.description)}
9395
*/
9496
export function exportAsPDF(report: ReportSchema, data: any[], config?: ReportExportConfig): void {
9597
const fields = report.fields || [];
96-
const orientation = config?.orientation || 'portrait';
98+
const orientation = validateOrientation(config?.orientation);
99+
const pageSize = validatePageSize(config?.pageSize);
97100

98101
const printWindow = window.open('', '_blank');
99102
if (!printWindow) {
@@ -113,7 +116,7 @@ export function exportAsPDF(report: ReportSchema, data: any[], config?: ReportEx
113116
<meta charset="utf-8">
114117
<title>${escapeHTML(report.title || 'Report')}</title>
115118
<style>
116-
@page { size: ${config?.pageSize || 'A4'} ${orientation}; margin: 20mm; }
119+
@page { size: ${pageSize} ${orientation}; margin: 20mm; }
117120
@media print { .no-print { display: none; } }
118121
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #333; }
119122
h1 { font-size: 24px; margin-bottom: 8px; }
@@ -149,7 +152,8 @@ export function exportAsExcel(report: ReportSchema, data: any[], config?: Report
149152
const fields = report.fields || [];
150153
const headers = fields.map(f => f.label || f.name);
151154
const rows = data.map(row => fields.map(f => {
152-
const val = row[f.name];
155+
let val = row[f.name];
156+
val = sanitizeCSVValue(val);
153157
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n') || val.includes('\t'))) {
154158
return `"${val.replace(/"/g, '""')}"`;
155159
}
@@ -207,6 +211,35 @@ function escapeHTML(str: string): string {
207211
.replace(/'/g, '&#039;');
208212
}
209213

214+
/**
215+
* Sanitize CSV/Excel cell values to prevent formula injection.
216+
* Prefixes values starting with formula characters (=, +, -, @, |) with a tab.
217+
*/
218+
function sanitizeCSVValue(val: any): any {
219+
if (typeof val === 'string' && val.length > 0) {
220+
const firstChar = val.charAt(0);
221+
if (firstChar === '=' || firstChar === '+' || firstChar === '-' || firstChar === '@' || firstChar === '|') {
222+
return `\t${val}`;
223+
}
224+
}
225+
return val;
226+
}
227+
228+
/**
229+
* Validate page size against allowed values
230+
*/
231+
function validatePageSize(pageSize: string | undefined): string {
232+
const allowed = ['A4', 'A3', 'Letter', 'Legal'];
233+
return allowed.includes(pageSize || '') ? pageSize! : 'A4';
234+
}
235+
236+
/**
237+
* Validate orientation against allowed values
238+
*/
239+
function validateOrientation(orientation: string | undefined): string {
240+
return orientation === 'landscape' ? 'landscape' : 'portrait';
241+
}
242+
210243
/**
211244
* Helper to trigger file download
212245
*/

0 commit comments

Comments
 (0)