Sanitize CSV exports against formula injection
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user