From e9925f2e4eb4b3ecb1fd49a99c24375470f9cda9 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 3 Jun 2026 00:00:37 -0700 Subject: [PATCH] Sanitize CSV exports against formula injection --- src/app/api/send/route.ts | 15 +++++++++++-- .../api/websites/[websiteId]/export/route.ts | 22 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index d90db01d8..191046740 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -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(), diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts index f298bbcf5..511025b8d 100644 --- a/src/app/api/websites/[websiteId]/export/route.ts +++ b/src/app/api/websites/[websiteId]/export/route.ts @@ -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): Record => { + const sanitized: Record = {}; + 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, });