diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/csv-field.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/csv-field.ts new file mode 100644 index 00000000..b049d175 --- /dev/null +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/csv-field.ts @@ -0,0 +1,14 @@ +const FORMULA_PREFIX = /^[\s]*[=+\-@\t\r\n=+-@]/u + +/** + * Encode one session-export field without letting untrusted text become a spreadsheet formula. + * Numeric metrics remain numeric; text still uses RFC-style quote doubling when CSV syntax requires it. + */ +export function csvField(value: string | number | null | undefined) { + if (value === null || value === undefined) return '' + let text = String(value) + if (typeof value !== 'number' && FORMULA_PREFIX.test(text)) { + text = "'" + text + } + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text +} diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts new file mode 100644 index 00000000..1be8941c --- /dev/null +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'vitest' +import { csvField } from './csv-field' + +test('csvField handles null/undefined', () => { + expect(csvField(null)).toBe('') + expect(csvField(undefined)).toBe('') +}) + +test('csvField handles normal strings', () => { + expect(csvField('hello')).toBe('hello') + expect(csvField('hello world')).toBe('hello world') +}) + +test('csvField handles numbers', () => { + expect(csvField(123)).toBe('123') + expect(csvField(-5)).toBe('-5') + expect(csvField(0)).toBe('0') +}) + +test('csvField handles quotes and delimiters without opening a new cell', () => { + expect(csvField('hello, world')).toBe('"hello, world"') + expect(csvField('hello"world')).toBe('"hello""world"') + expect(csvField('hello\nworld')).toBe('"hello\nworld"') + expect(csvField('safe",=1+1')).toBe('"safe"",=1+1"') +}) + +test('csvField neutralizes spreadsheet formula prefixes', () => { + expect(csvField('=1+1')).toBe("'=1+1") + expect(csvField('+1+1')).toBe("'+1+1") + expect(csvField('-1+1')).toBe("'-1+1") + expect(csvField('@1+1')).toBe("'@1+1") + expect(csvField('\t1+1')).toBe("'\t1+1") + expect(csvField('\r1+1')).toBe('"\'\r1+1"') + expect(csvField('\n=1+1')).toBe('"\'\n=1+1"') + expect(csvField(' =1+1')).toBe("' =1+1") + expect(csvField('\uFEFF=1+1')).toBe("'\uFEFF=1+1") + expect(csvField('=1+1')).toBe("'=1+1") + expect(csvField('+1+1')).toBe("'+1+1") + expect(csvField('-1+1')).toBe("'-1+1") + expect(csvField('@1+1')).toBe("'@1+1") +}) diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts index 7d6a4d4a..34230d6e 100644 --- a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts @@ -10,6 +10,7 @@ import { resolveOrgScopedProjectIds, } from '@/lib/server/dashboard-route-helper' import { canAccessIndividualData, forbiddenByRole } from '@/lib/server/rbac' +import { csvField } from './csv-field' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -71,12 +72,6 @@ function mapSessionItem(session: SessionWithInclude): SessionItem { } } -function csvField(value: string | number | null | undefined) { - if (value === null || value === undefined) return '' - const text = String(value) - return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text -} - function buildSessionsCsv(sessions: SessionWithInclude[]) { const headers = [ 'Session ID',