Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion main/admin/user_import.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
68 changes: 67 additions & 1 deletion main/inc/lib/export.lib.inc.php
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion main/inc/lib/myspace.lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
4 changes: 3 additions & 1 deletion main/survey/reporting.php
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/ExportCsvFormulaSecurityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/* For licensing terms, see /license.txt */

require_once dirname(__DIR__, 2).'/main/inc/lib/export.lib.inc.php';

use PHPUnit\Framework\TestCase;

class ExportCsvFormulaSecurityTest extends TestCase
{
public function testDangerousPrefixesAreEscaped()
{
foreach ([
'=1+1',
'+SUM(1,2)',
'-CMD',
'@SUM(A1:A2)',
"\tvalue",
"\rvalue",
"\nvalue",
' =1+1',
" \t@SUM(A1:A2)",
'-42',
] as $value) {
$this->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);
}
}
Loading