Sanitize CSV exports against formula injection

This commit is contained in:
Mike Cao
2026-06-03 00:00:37 -07:00
parent 0c08a27a81
commit e9925f2e4e
2 changed files with 34 additions and 3 deletions
+13 -2
View File
@@ -21,6 +21,17 @@ interface Cache {
iat: number;
}
// Reject strings whose first character is a spreadsheet formula trigger to
// prevent CSV formula injection in analytics exports (defense-in-depth).
const FORMULA_TRIGGER_RE = /^[=+\-@\t\r]/;
const safeStringParam = (maxLen: number) =>
z
.string()
.max(maxLen)
.refine(val => !FORMULA_TRIGGER_RE.test(val), {
message: 'Value must not start with =, +, -, @, tab, or carriage return',
});
const schema = z.object({
type: z.enum(['event', 'identify', 'performance']),
payload: z
@@ -35,8 +46,8 @@ const schema = z.object({
screen: z.string().max(11).optional(),
title: z.string().optional(),
url: urlOrPathParam.optional(),
name: z.string().max(50).optional(),
tag: z.string().max(50).optional(),
name: safeStringParam(50).optional(),
tag: safeStringParam(50).optional(),
ip: z.string().optional(),
userAgent: z.string().optional(),
timestamp: z.coerce.number().int().optional(),
@@ -40,8 +40,28 @@ export async function GET(
const zip = new JSZip();
// Prefix cells whose first character is a formula trigger with a single quote
// to prevent CSV formula injection when opened in spreadsheet applications.
const FORMULA_TRIGGERS = new Set(['=', '+', '-', '@', '\t', '\r']);
const sanitizeCsvValue = (value: unknown): unknown => {
if (typeof value === 'string' && value.length > 0 && FORMULA_TRIGGERS.has(value[0])) {
return `'${value}`;
}
return value;
};
const sanitizeRow = (row: Record<string, unknown>): Record<string, unknown> => {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
sanitized[key] = sanitizeCsvValue(value);
}
return sanitized;
};
const parse = (data: any) => {
return Papa.unparse(data, {
const sanitized = Array.isArray(data) ? data.map(sanitizeRow) : data;
return Papa.unparse(sanitized, {
header: true,
skipEmptyLines: true,
});