Commit 7fbe487a authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[TASK] Mitigate downstream CSV code injection

* uses stream filter to enclose multi-line content
* adds three choosable strategies dealing with control literals
  + TYPE_REMOVE_CONTROLS - removes control literals (default)
  + TYPE_PREFIX_CONTROLS - prefixes control literal sequence with `'`
  + TYPE_PASSTHROUGH - nothing, passthrough data

The default strategy is `TYPE_REMOVE_CONTROLS` when
invoking `\TYPO3\CMS\Core\Utility\CsvUtility::csvValues`.

Resolves: #94271
Releases: master, 11.3, 10.4, 9.5
Change-Id: I2568a0c2dfa6d4636e211e97d66a513984532cc9
Security-Bulletin: TYPO3-PSA-2021-002
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69974


Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 26198673
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\IO;
/**
* Inspired by https://csv.thephpleague.com/9.0/interoperability/enclose-field/
*
* A unique sequence is added to relevant CSV field values in order to trigger enclosure in fputcsv.
* This stream filter is taking care of removing that sequence again when actually writing to stream.
*/
class CsvStreamFilter extends \php_user_filter
{
protected const NAME = 'csv.typo3';
/**
* @var array contains 'sequence' key for stream filter
* @private
* @internal
*/
public $params = [];
/**
* Implicitly handles stream filter when writing CSV data - example:
*
* @example
* $resource = fopen('file.csv', 'w');
* $modifier = CsvUtility::applyStreamFilter($resource);
* fputcsv($resource, $modifier($fieldValues));
* fclose($resource);
*
* @param resource $in
* @param resource $out
* @param int $consumed
* @param bool $closing
* @return int
*/
public function filter($in, $out, &$consumed, $closing): int
{
while ($bucket = stream_bucket_make_writeable($in)) {
// removes sequence boundary indicator
$bucket->data = str_replace(
$this->params['sequence'],
'',
$bucket->data
);
if ($this->params['LF'] === false) {
// remove line-feed added by `fputcsv` per default
$bucket->data = preg_replace('#\r?\n$#', '', $bucket->data);
}
$consumed += $bucket->datalen;
stream_bucket_append($out, $bucket);
}
return PSFS_PASS_ON;
}
/**
* @param resource $stream
* @param bool $LF whether to apply line-feed
* @return \Closure
*/
public static function applyStreamFilter($stream, bool $LF = true): \Closure
{
self::registerStreamFilter();
// must contain a spacing character to enforce enclosure
$sequence = "\t\x1d\x1e\x1f";
stream_filter_append(
$stream,
self::NAME,
STREAM_FILTER_WRITE,
['sequence' => $sequence, 'LF' => $LF]
);
return self::buildStreamFilterModifier($sequence);
}
/**
* Registers stream filter
*/
protected static function registerStreamFilter()
{
if (in_array(self::NAME, stream_get_filters(), true)) {
return;
}
stream_filter_register(
self::NAME,
static::class
);
}
/**
* @param string $sequence
* @return \Closure
*/
protected static function buildStreamFilterModifier(string $sequence): \Closure
{
return function ($element) use ($sequence) {
foreach ($element as &$value) {
if (is_numeric($value) || $value === '') {
continue;
}
$value = $sequence . $value;
}
unset($value); // de-reference
return $element;
};
}
}
......@@ -15,11 +15,29 @@
namespace TYPO3\CMS\Core\Utility;
use TYPO3\CMS\Core\IO\CsvStreamFilter;
/**
* Class with helper functions for CSV handling
*/
class CsvUtility
{
/**
* whether to passthrough data as is, without any modification
*/
public const TYPE_PASSTHROUGH = 0;
/**
* whether to remove control characters like `=`, `+`, ...
*/
public const TYPE_REMOVE_CONTROLS = 1;
/**
* whether to prefix control characters like `=`, `+`, ...
* to become `'=`, `'+`, ...
*/
public const TYPE_PREFIX_CONTROLS = 2;
/**
* Convert a string, formatted as CSV, into a multidimensional array
*
......@@ -73,17 +91,93 @@ class CsvUtility
/**
* Takes a row and returns a CSV string of the values with $delim (default is ,) and $quote (default is ") as separator chars.
*
* @param array $row Input array of values
* @param string[] $row Input array of values
* @param string $delim Delimited, default is comma
* @param string $quote Quote-character to wrap around the values.
* @param int $type Output behaviour concerning potentially harmful control literals
* @return string A single line of CSV
*/
public static function csvValues(array $row, $delim = ',', $quote = '"')
public static function csvValues(array $row, string $delim = ',', string $quote = '"', int $type = self::TYPE_REMOVE_CONTROLS)
{
$resource = fopen('php://temp', 'w');
if (!is_resource($resource)) {
throw new \RuntimeException('Cannot open temporary data stream for writing', 1625556521);
}
$modifier = CsvStreamFilter::applyStreamFilter($resource, false);
array_map([self::class, 'assertCellValueType'], $row);
if ($type === self::TYPE_REMOVE_CONTROLS) {
$row = array_map([self::class, 'removeControlLiterals'], $row);
} elseif ($type === self::TYPE_PREFIX_CONTROLS) {
$row = array_map([self::class, 'prefixControlLiterals'], $row);
}
fputcsv($resource, $modifier($row), $delim, $quote);
fseek($resource, 0);
$content = stream_get_contents($resource);
return $content;
}
/**
* Prefixes control literals at the beginning of a cell value with a single quote
* (e.g. `=+value` --> `'=+value`)
*
* @param mixed $cellValue
* @return bool|int|float|string|null
*/
protected static function prefixControlLiterals($cellValue)
{
if (!self::shallFilterValue($cellValue)) {
return $cellValue;
}
$cellValue = (string)$cellValue;
return preg_replace('#^([\t\v=+*%/@-])#', '\'${1}', $cellValue);
}
/**
* Removes control literals from the beginning of a cell value
* (e.g. `=+value` --> `value`)
*
* @param mixed $cellValue
* @return bool|int|float|string|null
*/
protected static function removeControlLiterals($cellValue)
{
$out = [];
foreach ($row as $value) {
$out[] = str_replace($quote, $quote . $quote, $value);
if (!self::shallFilterValue($cellValue)) {
return $cellValue;
}
return $quote . implode($quote . $delim . $quote, $out) . $quote;
$cellValue = (string)$cellValue;
return preg_replace('#^([\t\v=+*%/@-]+)+#', '', $cellValue);
}
/**
* Asserts scalar or null types for given cell value.
*
* @param mixed $cellValue
*/
protected static function assertCellValueType($cellValue): void
{
// int, float, string, bool, null
if ($cellValue === null || is_scalar($cellValue)) {
return;
}
throw new \RuntimeException(
sprintf('Unexpected type %s for cell value', gettype($cellValue)),
1625562833
);
}
/**
* Whether cell value shall be filtered, applies to everything
* that is not or cannot be represented as boolean, integer or float.
*
* @param mixed $cellValue
* @return bool
*/
protected static function shallFilterValue($cellValue): bool
{
return $cellValue !== null
&& !is_bool($cellValue)
&& !is_numeric($cellValue)
&& !MathUtility::canBeInterpretedAsInteger($cellValue)
&& !MathUtility::canBeInterpretedAsFloat($cellValue);
}
}
......@@ -94,4 +94,75 @@ class CsvUtilityTest extends UnitTestCase
{
self::assertEquals($expectedResult, CsvUtility::csvToArray($input, $fieldDelimiter, $fieldEnclosure, $maximumColumns));
}
public function csvValuesDataProvider(): array
{
return [
'row with semicolon as delimiter (TYPE_PASSTHROUGH)' => [
['val1', 'val2', 'val3'],
';',
'"',
'"val1";"val2";"val3"',
CsvUtility::TYPE_PASSTHROUGH
],
'row where value contains line feeds (TYPE_PASSTHROUGH)' => [
['val1 line1' . "\n" . 'val1 line2', 'val2 line1' . "\r\n" . 'val2 line2', 'val3'],
',',
'"',
'"val1 line1' . "\n" . 'val1 line2","val2 line1' . "\r\n" . 'val2 line2","val3"',
CsvUtility::TYPE_PASSTHROUGH
],
'row with all possible control chars (TYPE_PASSTHROUGH)' => [
['=val1', '+val2', '*val3', '%val4', '@val5', '-val6'],
',',
'"',
'"=val1","+val2","*val3","%val4","@val5","-val6"',
CsvUtility::TYPE_PASSTHROUGH
],
'row with spacing and delimiting chars (TYPE_PASSTHROUGH)' => [
[' val1', "\tval2", "\nval3", "\r\nval4", ',val5,', '"val6"'],
',',
'"',
'" val1","' . "\tval2" . '","' . "\nval3" . '","' . "\r\nval4" . '",",val5,","""val6"""' ,
CsvUtility::TYPE_PASSTHROUGH
],
'row with all possible control chars (TYPE_PREFIX_CONTROLS)' => [
['=val1', '+val2', '*val3', '%val4', '@val5', '-val6'],
',',
'"',
'"\'=val1","\'+val2","\'*val3","\'%val4","\'@val5","\'-val6"',
CsvUtility::TYPE_PREFIX_CONTROLS
],
'row with spacing and delimiting chars (TYPE_PREFIX_CONTROLS)' => [
[' val1', "\tval2", "\nval3", "\r\nval4", ',val5,', '"val6"'],
',',
'"',
'" val1","' . "'\tval2" . '","' . "'\nval3" . '","' . "'\r\nval4" . '",",val5,","""val6"""' ,
CsvUtility::TYPE_PREFIX_CONTROLS
],
'row with all possible control chars (TYPE_REMOVE_CONTROLS)' => [
['=val1', '+val2', '*val3', '%val4', '@val5', '-val6'],
',',
'"',
'"val1","val2","val3","val4","val5","val6"',
CsvUtility::TYPE_REMOVE_CONTROLS
],
'row with spacing and delimiting chars (TYPE_REMOVE_CONTROLS)' => [
[' val1', "\tval2", "\nval3", "\r\nval4", ',val5,', '"val6"'],
',',
'"',
'" val1","val2","val3","val4",",val5,","""val6"""' ,
CsvUtility::TYPE_REMOVE_CONTROLS
],
];
}
/**
* @dataProvider csvValuesDataProvider
* @test
*/
public function csvValuesReturnsExpectedResult($row, $delimiter, $quote, $expectedResult, $flag)
{
self::assertEquals($expectedResult, CsvUtility::csvValues($row, $delimiter, $quote, $flag));
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment