Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Add escaping to the write_csv() method
  • Loading branch information
schlessera committed May 6, 2025
commit 96e00a6f63e90a57a1f45796ab6f4a4e5500b45d
9 changes: 9 additions & 0 deletions php/utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@
*/
function write_csv( $fd, $rows, $headers = [] ) {
if ( ! empty( $headers ) ) {
$headers = array_map( __NAMESPACE__ . '\escape_csv_value', $headers );
fputcsv( $fd, $headers, ',', '"', '\\' );
}

Expand All @@ -426,6 +427,7 @@
$row = pick_fields( $row, $headers );
}

$row = array_map( __NAMESPACE__ . '\escape_csv_value', $row );
fputcsv( $fd, array_values( $row ), ',', '"', '\\' );
}
}
Expand Down Expand Up @@ -2043,6 +2045,13 @@
* @return string Escaped value.
*/
function escape_csv_value( $value ) {
if ( null === $value ) {
return '';

Check warning on line 2049 in php/utils.php

View check run for this annotation

Codecov / codecov/patch

php/utils.php#L2049

Added line #L2049 was not covered by tests
}

// Convert to string if not already
$value = (string) $value;

if (
in_array(
substr( $value, 0, 1 ),
Expand Down
121 changes: 121 additions & 0 deletions tests/UtilsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,127 @@ public static function dataEscapeCsvValue() {
];
}

public function testWriteCsv() {
// Create a temporary file
$temp_file = tmpfile();

// Test data with various cases that need escaping
$headers = [ 'name', 'formula', 'quoted', 'comma', 'backslash' ];
$rows = [
[
'name' => 'John Doe',
'formula' => '=SUM(A1:A2)',
'quoted' => 'Contains "quotes"',
'comma' => 'Item 1, Item 2',
'backslash' => 'C:\\path\\to\\file',
],
[
'name' => '@username',
'formula' => '+1234',
'quoted' => "'Single quotes'",
'comma' => '-123,45',
'backslash' => 'Escape \\this',
],
];

// Write to CSV
Utils\write_csv( $temp_file, $rows, $headers );

// Rewind file and read contents
rewind( $temp_file );
$csv_content = stream_get_contents( $temp_file );

// Normalize line endings for cross-platform testing
$csv_content = str_replace( "\r\n", "\n", $csv_content );

// Check individual components instead of the exact string
$this->assertStringContainsString( 'name,formula,quoted,comma,backslash', $csv_content );
$this->assertStringContainsString( '"John Doe"', $csv_content );
$this->assertStringContainsString( '\'=SUM(A1:A2)', $csv_content );
$this->assertStringContainsString( '"Contains ""quotes"""', $csv_content );
$this->assertStringContainsString( '"Item 1, Item 2"', $csv_content );
$this->assertStringContainsString( '\'@username', $csv_content );
$this->assertStringContainsString( '\'Single quotes\'', $csv_content );
$this->assertStringContainsString( '\'+1234', $csv_content );
$this->assertStringContainsString( '\'-123,45', $csv_content );
}

public function testWriteCsvWithoutHeaders() {
// Create a temporary file
$temp_file = tmpfile();

// Test data without using headers
$rows = [
[ 'John Doe', '=SUM(A1:A2)', 'Contains "quotes"' ],
[ '@username', '+1234', '-amount' ],
];

// Write to CSV without headers
Utils\write_csv( $temp_file, $rows );

// Rewind file and read contents
rewind( $temp_file );
$csv_content = stream_get_contents( $temp_file );

// Normalize line endings for cross-platform testing
$csv_content = str_replace( "\r\n", "\n", $csv_content );

// Check individual components instead of the exact string
$this->assertStringContainsString( '"John Doe"', $csv_content );
$this->assertStringContainsString( '\'=SUM(A1:A2)', $csv_content );
$this->assertStringContainsString( '"Contains ""quotes"""', $csv_content );
$this->assertStringContainsString( '\'@username', $csv_content );
$this->assertStringContainsString( '\'+1234', $csv_content );
$this->assertStringContainsString( '\'-amount', $csv_content );
}

public function testWriteCsvWithFieldPicking() {
// Create a temporary file
$temp_file = tmpfile();

// Test data with additional fields that should be filtered out
$rows = [
[
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
'formula' => '=HYPERLINK("http://malicious.com")',
'extra' => 'Should not appear',
],
[
'id' => 2,
'name' => '@username',
'email' => 'user@example.com',
'formula' => '+1234',
'extra' => 'Should not appear',
],
];

// Only include these headers (should filter the rows accordingly)
$headers = [ 'id', 'name', 'email', 'formula' ];

// Write to CSV, which should filter fields based on headers
Utils\write_csv( $temp_file, $rows, $headers );

// Rewind file and read contents
rewind( $temp_file );
$csv_content = stream_get_contents( $temp_file );

// Normalize line endings for cross-platform testing
$csv_content = str_replace( "\r\n", "\n", $csv_content );

// Check individual components instead of the exact string
$this->assertStringContainsString( 'id,name,email,formula', $csv_content );
$this->assertStringContainsString( '1,"John Doe",john@example.com', $csv_content );
$this->assertStringContainsString( '\'=HYPERLINK', $csv_content );
$this->assertStringContainsString( '2,\'@username,user@example.com', $csv_content );
$this->assertStringContainsString( '\'+1234', $csv_content );

// Make sure 'extra' field is not in the output
$this->assertStringNotContainsString( 'extra', $csv_content );
$this->assertStringNotContainsString( 'Should not appear', $csv_content );
}

public function testReplacePathConstsAddSlashes() {
$expected = "define( 'ABSPATH', dirname( 'C:\\\\Users\\\\test\'s\\\\site' ) . '/' );";
$source = "define( 'ABSPATH', dirname( __FILE__ ) . '/' );";
Expand Down