From 09ae96edf2ccdd752794ff85fe332123e996c6fe Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Sun, 18 Oct 2026 12:01:56 -0500 Subject: [PATCH] Security: Neutralize formulas in CSV exports --- main/admin/user_import.php | 2 +- main/inc/lib/export.lib.inc.php | 68 ++++++++++++++++++++- main/inc/lib/myspace.lib.php | 2 +- main/survey/reporting.php | 4 +- tests/unit/ExportCsvFormulaSecurityTest.php | 56 +++++++++++++++++ 5 files changed, 128 insertions(+), 4 deletions(-) mode change 100755 => 100644 main/inc/lib/export.lib.inc.php mode change 100755 => 100644 main/survey/reporting.php create mode 100644 tests/unit/ExportCsvFormulaSecurityTest.php diff --git a/main/admin/user_import.php b/main/admin/user_import.php index ae2f937ee04..50f4ed705cc 100644 --- a/main/admin/user_import.php +++ b/main/admin/user_import.php @@ -579,7 +579,7 @@ function saveCsvFile(array $data = [], string $file = 'example', string $enclosu $writer->writeItem([]); continue; } - $item = array_map('trim', $item); + $item = Export::escapeCsvRow(array_map('trim', $item)); $writer->writeItem($item); } $writer->finish(); diff --git a/main/inc/lib/export.lib.inc.php b/main/inc/lib/export.lib.inc.php old mode 100755 new mode 100644 index 5aa1f9e7326..43b36faa07b --- a/main/inc/lib/export.lib.inc.php +++ b/main/inc/lib/export.lib.inc.php @@ -22,6 +22,72 @@ public function __construct() { } + /** + * Prevent CSV cells from being interpreted as spreadsheet formulas. + * Keep stored values unchanged; call this only at the CSV output boundary. + * + * @param mixed $value + * + * @return mixed + */ + public static function escapeCsvCell($value) + { + if (!is_string($value) || '' === $value) { + return $value; + } + + $significant = ltrim($value, " \t\r\n"); + if (in_array($value[0], ["\t", "\r", "\n"], true) || + ('' !== $significant && in_array($significant[0], ['=', '+', '-', '@'], true)) + ) { + return "'".$value; + } + + return $value; + } + + /** + * @return array + */ + public static function escapeCsvRow(array $row) + { + return array_map([self::class, 'escapeCsvCell'], $row); + } + + /** + * Normalize manually assembled semicolon-delimited CSV before download. + * Unlike string replacement, this respects quoted fields and newlines. + */ + public static function escapeDelimitedCsvContent($content, $delimiter = ';') + { + if ('' === $content) { + return $content; + } + + $input = fopen('php://temp', 'w+'); + $output = fopen('php://temp', 'w+'); + if (false === $input || false === $output) { + throw new \RuntimeException('Unable to open CSV transformation stream.'); + } + + fwrite($input, $content); + rewind($input); + while (false !== ($row = fgetcsv($input, 0, $delimiter, '"', ''))) { + fputcsv($output, self::escapeCsvRow($row), $delimiter, '"', ''); + } + + rewind($output); + $result = stream_get_contents($output); + fclose($input); + fclose($output); + + if (false === $result) { + throw new \RuntimeException('Unable to read CSV transformation stream.'); + } + + return $result; + } + /** * Export tabular data to CSV-file. * @@ -50,7 +116,7 @@ public static function arrayToCsv($data, $filename = 'export', $writeOnly = fals $writer->writeItem([]); continue; } - $item = array_map('trim', $item); + $item = self::escapeCsvRow(array_map('trim', $item)); $writer->writeItem($item); } $writer->finish(); diff --git a/main/inc/lib/myspace.lib.php b/main/inc/lib/myspace.lib.php index 952583ae0c4..2a499b37406 100644 --- a/main/inc/lib/myspace.lib.php +++ b/main/inc/lib/myspace.lib.php @@ -155,7 +155,7 @@ public function export_csv($header, $data, $file_name = 'export.csv') $info .= "\r\n"; } - fwrite($open, $info); + fwrite($open, Export::escapeDelimitedCsvContent($info)); fclose($open); @chmod($file_name, api_get_permissions_for_new_files()); diff --git a/main/survey/reporting.php b/main/survey/reporting.php old mode 100755 new mode 100644 index 0046e8d0d8a..ff26a12af95 --- a/main/survey/reporting.php +++ b/main/survey/reporting.php @@ -70,7 +70,9 @@ // no break case 'csv': default: - $data = SurveyUtil::export_complete_report($survey_data, $userId, $compact, $lpItemId); + $data = Export::escapeDelimitedCsvContent( + SurveyUtil::export_complete_report($survey_data, $userId, $compact, $lpItemId) + ); $filename = 'survey_results_'.$survey_id.($compact ? '_compact' : '').'.csv'; header('Content-type: application/octet-stream'); header('Content-Type: application/force-download'); diff --git a/tests/unit/ExportCsvFormulaSecurityTest.php b/tests/unit/ExportCsvFormulaSecurityTest.php new file mode 100644 index 00000000000..f40b1c050bb --- /dev/null +++ b/tests/unit/ExportCsvFormulaSecurityTest.php @@ -0,0 +1,56 @@ +assertSame("'".$value, Export::escapeCsvCell($value)); + } + } + + public function testSafeValuesAreUnmodified() + { + foreach (['Alice', 'Alice =1+1', "'=1+1", '', -42, 1.5, null, false] as $value) { + $this->assertSame($value, Export::escapeCsvCell($value)); + } + } + + public function testQuotedSurveyAnswersKeepTheirColumns() + { + $source = "Name;Answer;Note\nAlice;\"=HYPERLINK(\"\"https://example.invalid\"\",\"\"click\"\")\";\"Some; text\"\n"; + $result = Export::escapeDelimitedCsvContent($source); + $stream = fopen('php://temp', 'w+'); + fwrite($stream, $result); + rewind($stream); + $this->assertSame(['Name', 'Answer', 'Note'], fgetcsv($stream, 0, ';', '"', '')); + $this->assertSame( + ['Alice', "'=HYPERLINK(\"https://example.invalid\",\"click\")", 'Some; text'], + fgetcsv($stream, 0, ';', '"', '') + ); + fclose($stream); + } + + public function testUnquotedFormulaAfterSeparatorIsEscaped() + { + $result = Export::escapeDelimitedCsvContent("Name;Answer\nAlice;=1+1\n"); + $this->assertSame("Name;Answer\nAlice;'=1+1\n", $result); + } +}