Skip to content

Commit

Permalink
Backport Security Fix
Browse files Browse the repository at this point in the history
  • Loading branch information
oleibman committed Nov 10, 2024
1 parent 8628d63 commit 08d4e08
Show file tree
Hide file tree
Showing 18 changed files with 210 additions and 102 deletions.
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
- '8.1'
- '8.2'
- '8.3'
- '8.4'

include:
- php-version: 'nightly'
Expand Down
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com)
and this project adheres to [Semantic Versioning](https://semver.org).

## TBD - 2.3.1
## 2024-11-10 - 2.3.1

### Fixed

- Backported security patches.
- Write ignoredErrors Tag Before Drawings. Backport of [PR #4212](https://github.com/PHPOffice/PhpSpreadsheet/pull/4212) intended for 3.4.0.
- Changes to ROUNDDOWN/ROUNDUP/TRUNC. Backport of [PR #4214](https://github.com/PHPOffice/PhpSpreadsheet/pull/4214) intended for 3.4.0.

### Added

- Method to Test Whether Csv Will Be Affected by Php9 (backport of PR #4189 intended for 3.4.0).
- Method to Test Whether Csv Will Be Affected by Php9. Backport of [PR #4189](https://github.com/PHPOffice/PhpSpreadsheet/pull/4189) intended for 3.4.0.

## 2024-09-29 - 2.3.0

Expand Down
49 changes: 22 additions & 27 deletions src/PhpSpreadsheet/Calculation/MathTrig/Round.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
// following added in Php8.4
use RoundingMode;

class Round
{
Expand Down Expand Up @@ -67,31 +69,28 @@ public static function up($number, $digits): array|string|float
return 0.0;
}

$digitsPlus1 = $digits + 1;
if ($number < 0.0) {
if ($digitsPlus1 < 0) {
return round($number - 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_DOWN);
}
$result = sprintf("%.{$digitsPlus1}F", $number - 0.5 * 0.1 ** $digits);

return round((float) $result, $digits, PHP_ROUND_HALF_DOWN);
if (PHP_VERSION_ID >= 80400) {
return round(
(float) (string) $number,
$digits,
RoundingMode::AwayFromZero //* @phpstan-ignore-line
);
}
if ($digitsPlus1 < 0) {
return round($number + 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_DOWN);
if ($number < 0.0) {
return round($number - 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_DOWN);
}
$result = sprintf("%.{$digitsPlus1}F", $number + 0.5 * 0.1 ** $digits);
return round((float) $result, $digits, PHP_ROUND_HALF_DOWN);
return round($number + 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_DOWN);
}
/**
* ROUNDDOWN.
*
* Rounds a number down to a specified number of decimal places
*
* @param array|float $number Number to round, or can be an array of numbers
* @param array|int $digits Number of digits to which you want to round $number, or can be an array of numbers
* @param null|array|float|string $number Number to round, or can be an array of numbers
* @param array|float|int|string $digits Number of digits to which you want to round $number, or can be an array of numbers
*
* @return array|float|string Rounded Number, or a string containing an error
* If an array of numbers is passed as the argument, then the returned result will also be an array
Expand All @@ -114,23 +113,19 @@ public static function down($number, $digits): array|string|float
return 0.0;
}

$digitsPlus1 = $digits + 1;
if ($number < 0.0) {
if ($digitsPlus1 < 0) {
return round($number + 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_UP);
}
$result = sprintf("%.{$digitsPlus1}F", $number + 0.5 * 0.1 ** $digits);

return round((float) $result, $digits, PHP_ROUND_HALF_UP);
if (PHP_VERSION_ID >= 80400) {
return round(
(float) (string) $number,
$digits,
RoundingMode::TowardsZero //* @phpstan-ignore-line
);
}
if ($digitsPlus1 < 0) {
return round($number - 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_UP);
if ($number < 0.0) {
return round($number + 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_UP);
}
$result = sprintf("%.{$digitsPlus1}F", $number - 0.5 * 0.1 ** $digits);

return round((float) $result, $digits, PHP_ROUND_HALF_UP);
return round($number - 0.5 * 0.1 ** $digits, $digits, PHP_ROUND_HALF_UP);
}
/**
Expand Down
37 changes: 4 additions & 33 deletions src/PhpSpreadsheet/Calculation/MathTrig/Trunc.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace PhpOffice\PhpSpreadsheet\Calculation\MathTrig;

use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;

class Trunc
{
Expand All @@ -19,47 +18,19 @@ class Trunc
* (or possibly that value minus 1).
* Excel is unlikely to do any better.
*
* @param array|float $value Or can be an array of values
* @param array|int $digits Or can be an array of values
* @param null|array|float|string $value Or can be an array of values
* @param array|float|int|string $digits Or can be an array of values
*
* @return array|float|string Truncated value, or a string containing an error
* If an array of numbers is passed as an argument, then the returned result will also be an array
* with the same dimensions
*/
public static function evaluate(array|float|string|null $value = 0, array|int|string $digits = 0): array|float|string
public static function evaluate(array|float|string|null $value = 0, array|float|int|string $digits = 0): array|float|string
{
if (is_array($value) || is_array($digits)) {
return self::evaluateArrayArguments([self::class, __FUNCTION__], $value, $digits);
}

try {
$value = Helpers::validateNumericNullBool($value);
$digits = Helpers::validateNumericNullSubstitution($digits, null);
} catch (Exception $e) {
return $e->getMessage();
}

if ($value == 0) {
return $value;
}

if ($value >= 0) {
$minusSign = '';
} else {
$minusSign = '-';
$value = -$value;
}
$digits = (int) floor($digits);
if ($digits < 0) {
$result = (float) (substr(sprintf('%.0F', $value), 0, $digits) . str_repeat('0', -$digits));

return ($minusSign === '') ? $result : -$result;
}
$decimals = (floor($value) == (int) $value) ? (PHP_FLOAT_DIG - strlen((string) (int) $value)) : $digits;
$resultString = ($decimals < 0) ? sprintf('%F', $value) : sprintf('%.' . $decimals . 'F', $value);
$regExp = '/([.]\\d{' . $digits . '})\\d+$/';
$result = $minusSign . (preg_replace($regExp, '$1', $resultString) ?? $resultString);

return (float) $result;
return Round::down($value, $digits);
}
}
52 changes: 35 additions & 17 deletions src/PhpSpreadsheet/Reader/Security/XmlScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

class XmlScanner
{
private const ENCODING_PATTERN = '/encoding\\s*=\\s*(["\'])(.+?)\\1/s';
private const ENCODING_UTF7 = '/encoding\\s*=\\s*(["\'])UTF-7\\1/si';

private string $pattern;

/** @var ?callable */
Expand Down Expand Up @@ -36,29 +39,41 @@ private static function forceString(mixed $arg): string
private function toUtf8(string $xml): string
{
$charset = $this->findCharSet($xml);
$foundUtf7 = $charset === 'UTF-7';
if ($charset !== 'UTF-8') {
$testStart = '/^.{0,4}\\s*<?xml/s';
$startWithXml1 = preg_match($testStart, $xml);
$xml = self::forceString(mb_convert_encoding($xml, 'UTF-8', $charset));

$charset = $this->findCharSet($xml);
if ($charset !== 'UTF-8') {
throw new Reader\Exception('Suspicious Double-encoded XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
if ($startWithXml1 === 1 && preg_match($testStart, $xml) !== 1) {
throw new Reader\Exception('Double encoding not permitted');
}
$foundUtf7 = $foundUtf7 || (preg_match(self::ENCODING_UTF7, $xml) === 1);
$xml = preg_replace(self::ENCODING_PATTERN, '', $xml) ?? $xml;
} else {
$foundUtf7 = $foundUtf7 || (preg_match(self::ENCODING_UTF7, $xml) === 1);
}
if ($foundUtf7) {
throw new Reader\Exception('UTF-7 encoding not permitted');
}
if (substr($xml, 0, Reader\Csv::UTF8_BOM_LEN) === Reader\Csv::UTF8_BOM) {
$xml = substr($xml, Reader\Csv::UTF8_BOM_LEN);
}

return $xml;
}

private function findCharSet(string $xml): string
{
$patterns = [
'/encoding\\s*=\\s*"([^"]*]?)"/',
"/encoding\\s*=\\s*'([^']*?)'/",
];

foreach ($patterns as $pattern) {
if (preg_match($pattern, $xml, $matches)) {
return strtoupper($matches[1]);
}
if (substr($xml, 0, 4) === "\x4c\x6f\xa7\x94") {
throw new Reader\Exception('EBCDIC encoding not permitted');
}
$encoding = Reader\Csv::guessEncodingBom('', $xml);
if ($encoding !== '') {
return $encoding;
}
$xml = str_replace("\0", '', $xml);
if (preg_match(self::ENCODING_PATTERN, $xml, $matches)) {
return strtoupper($matches[2]);
}

return 'UTF-8';
Expand All @@ -71,13 +86,16 @@ private function findCharSet(string $xml): string
*/
public function scan($xml): string
{
// Don't rely purely on libxml_disable_entity_loader()
$pattern = '/\\0*' . implode('\\0*', str_split($this->pattern)) . '\\0*/';

$xml = "$xml";
if (preg_match($pattern, $xml)) {
throw new Reader\Exception('Detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
}

$xml = $this->toUtf8($xml);

// Don't rely purely on libxml_disable_entity_loader()
$pattern = '/\\0?' . implode('\\0?', str_split($this->pattern)) . '\\0?/';

if (preg_match($pattern, $xml)) {
throw new Reader\Exception('Detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
}
Expand All @@ -90,7 +108,7 @@ public function scan($xml): string
}

/**
* Scan theXML for use of <!ENTITY to prevent XXE/XEE attacks.
* Scan the XML for use of <!ENTITY to prevent XXE/XEE attacks.
*/
public function scanFile(string $filestream): string
{
Expand Down
13 changes: 2 additions & 11 deletions src/PhpSpreadsheet/Reader/Xml.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
use PhpOffice\PhpSpreadsheet\Settings;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Shared\File;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\SheetView;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
Expand Down Expand Up @@ -79,7 +78,8 @@ public function canRead(string $filename): bool
];

// Open file
$data = file_get_contents($filename) ?: '';
$data = (string) file_get_contents($filename);
$data = $this->getSecurityScannerOrThrow()->scan($data);

// Why?
//$data = str_replace("'", '"', $data); // fix headers with single quote
Expand All @@ -93,15 +93,6 @@ public function canRead(string $filename): bool
break;
}
}

// Retrieve charset encoding
if (preg_match('/<?xml.*encoding=[\'"](.*?)[\'"].*?>/m', $data, $matches)) {
$charSet = strtoupper($matches[1]);
if (preg_match('/^ISO-8859-\d[\dL]?$/i', $charSet) === 1) {
$data = StringHelper::convertEncoding($data, 'UTF-8', $charSet);
$data = (string) preg_replace('/(<?xml.*encoding=[\'"]).*?([\'"].*?>)/um', '$1' . 'UTF-8' . '$2', $data, 1);
}
}
$this->fileContents = $data;

return $valid;
Expand Down
6 changes: 3 additions & 3 deletions src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ public function writeWorksheet(PhpspreadsheetWorksheet $worksheet, array $string
// Breaks
$this->writeBreaks($objWriter, $worksheet);

// IgnoredErrors
$this->writeIgnoredErrors($objWriter);

// Drawings and/or Charts
$this->writeDrawings($objWriter, $worksheet, $includeCharts);

Expand All @@ -135,9 +138,6 @@ public function writeWorksheet(PhpspreadsheetWorksheet $worksheet, array $string
// AlternateContent
$this->writeAlternateContent($objWriter, $worksheet);

// IgnoredErrors
$this->writeIgnoredErrors($objWriter);

// BackgroundImage must come after ignored, before table
$this->writeBackgroundImage($objWriter, $worksheet);

Expand Down
48 changes: 40 additions & 8 deletions tests/PhpSpreadsheetTests/Reader/Security/XmlScannerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ public static function providerValidXML(): array
self::assertNotFalse($glob);
foreach ($glob as $file) {
$filename = realpath($file);
$expectedResult = file_get_contents($file);
$expectedResult = (string) file_get_contents($file);
if (preg_match('/UTF-16(LE|BE)?/', $file, $matches) == 1) {
$expectedResult = (string) mb_convert_encoding($expectedResult, 'UTF-8', $matches[0]);
$expectedResult = preg_replace('/encoding\\s*=\\s*[\'"]UTF-\\d+(LE|BE)?[\'"]/', '', $expectedResult) ?? $expectedResult;
}
$tests[basename($file)] = [$filename, $expectedResult];
}

Expand Down Expand Up @@ -132,19 +136,47 @@ public function testEncodingAllowsMixedCase(): void
self::assertSame($input, $output);
}

public function testUtf7Whitespace(): void
/**
* @dataProvider providerInvalidXlsx
*/
public function testInvalidXlsx(string $filename, string $message): void
{
$this->expectException(ReaderException::class);
$this->expectExceptionMessage('Double-encoded');
$this->expectExceptionMessage($message);
$reader = new Xlsx();
$reader->load('tests/data/Reader/XLSX/utf7white.dontuse');
$reader->load("tests/data/Reader/XLSX/$filename");
}

public function testUtf8Entity(): void
public static function providerInvalidXlsx(): array
{
return [
['utf7white.dontuse', 'UTF-7 encoding not permitted'],
['utf7quoteorder.dontuse', 'UTF-7 encoding not permitted'],
['utf8and16.dontuse', 'Double encoding not permitted'],
['utf8and16.entity.dontuse', 'Detected use of ENTITY'],
['utf8entity.dontuse', 'Detected use of ENTITY'],
['utf16entity.dontuse', 'Detected use of ENTITY'],
['ebcdic.dontuse', 'EBCDIC encoding not permitted'],
];
}

/**
* @dataProvider providerValidUtf16
*/
public function testValidUtf16(string $filename): void
{
$this->expectException(ReaderException::class);
$this->expectExceptionMessage('Detected use of ENTITY');
$reader = new Xlsx();
$reader->load('tests/data/Reader/XLSX/utf8entity.dontuse');
$spreadsheet = $reader->load("tests/data/Reader/XLSX/$filename");
$sheet = $spreadsheet->getActiveSheet();
self::assertSame(1, $sheet->getCell('A1')->getValue());
$spreadsheet->disconnectWorksheets();
}

public static function providerValidUtf16(): array
{
return [
['utf16be.xlsx'],
['utf16be.bom.xlsx'],
];
}
}
Loading

0 comments on commit 08d4e08

Please sign in to comment.