Reports/Export builder (CSV/XLSX), plus a security fix pass
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.
Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
This commit is contained in:
90
lib/export.ts
Normal file
90
lib/export.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
// Shared by every /api/export/* route: define columns once as { header, get },
|
||||
// get both a semicolon CSV (Excel-DE friendly) and a real .xlsx workbook from
|
||||
// the same data + column definitions. kind: "date" tells the xlsx writer to
|
||||
// emit a real date cell (not a text string) for ISO ("YYYY-MM-DD") values.
|
||||
export type ExportColumn<T> = {
|
||||
header: string;
|
||||
get: (row: T) => string | number | boolean | null;
|
||||
kind?: "date";
|
||||
};
|
||||
|
||||
// CSV/Excel formula injection (CWE-1236): a cell whose text begins with
|
||||
// =, +, -, or @ is interpreted as a formula by Excel/Sheets/LibreOffice on
|
||||
// open, not as literal text — dangerous when the source data (employee
|
||||
// names, job titles, free-text notes, audit details, ...) can contain
|
||||
// attacker- or user-supplied strings. Prefixing with a single quote is the
|
||||
// standard mitigation (OWASP CSV Injection cheat sheet); it forces the cell
|
||||
// to render as text at the cost of a visible leading ' for the rare
|
||||
// legitimate value that starts with one of these characters.
|
||||
export function sanitizeForSpreadsheetCell(text: string): string {
|
||||
return /^[=+\-@]/.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
function csvCell(value: string | number | boolean | null): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
|
||||
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
// Leading BOM + semicolon delimiter: Excel's German locale default, and what
|
||||
// makes umlauts render correctly instead of mojibake on open.
|
||||
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
|
||||
const lines = [columns.map((c) => csvCell(c.header)).join(";")];
|
||||
for (const row of rows) {
|
||||
lines.push(columns.map((c) => csvCell(c.get(row))).join(";"));
|
||||
}
|
||||
return "" + lines.join("\r\n");
|
||||
}
|
||||
|
||||
function parseIsoDate(value: string): Date | null {
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName: string): Promise<Uint8Array> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(sheetName.slice(0, 31));
|
||||
|
||||
sheet.columns = columns.map((c) => ({
|
||||
header: c.header,
|
||||
key: c.header,
|
||||
width: Math.min(40, Math.max(12, c.header.length + 4)),
|
||||
style: c.kind === "date" ? { numFmt: "dd.mm.yyyy" } : undefined,
|
||||
}));
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: columns.length } };
|
||||
sheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
|
||||
for (const row of rows) {
|
||||
const record: Record<string, string | number | boolean | Date | null> = {};
|
||||
for (const c of columns) {
|
||||
const value = c.get(row);
|
||||
record[c.header] =
|
||||
c.kind === "date" && typeof value === "string" && value
|
||||
? (parseIsoDate(value) ?? value)
|
||||
: typeof value === "string"
|
||||
? sanitizeForSpreadsheetCell(value)
|
||||
: value;
|
||||
}
|
||||
sheet.addRow(record);
|
||||
}
|
||||
|
||||
const written = await workbook.xlsx.writeBuffer();
|
||||
return new Uint8Array(written);
|
||||
}
|
||||
|
||||
export function exportFilename(base: string, format: "csv" | "xlsx"): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `${base}-${today}.${format}`;
|
||||
}
|
||||
|
||||
export function exportResponseHeaders(filename: string, format: "csv" | "xlsx"): HeadersInit {
|
||||
const contentType =
|
||||
format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv; charset=utf-8";
|
||||
return {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user