Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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")
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down
Loading