mirror of
https://github.com/PHPOffice/PhpSpreadsheet.git
synced 2026-09-15 04:26:25 +00:00
461 lines
20 KiB
PHP
461 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace PhpOffice\PhpSpreadsheetTests\Reader\Xlsx;
|
|
|
|
use InvalidArgumentException;
|
|
use PhpOffice\PhpSpreadsheet\Reader\Exception;
|
|
use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
|
|
use PhpOffice\PhpSpreadsheet\Shared\File;
|
|
use PhpOffice\PhpSpreadsheet\Shared\Xlsx\AgileEncryption;
|
|
use PHPUnit\Framework\TestCase;
|
|
use ReflectionMethod;
|
|
|
|
class AgileEncryptionTest extends TestCase
|
|
{
|
|
private const FIXTURE = 'tests/data/Reader/XLSX/agile-encrypted-excel.xlsx';
|
|
|
|
public function testReadExcelAgileEncryptionFixture(): void
|
|
{
|
|
// Generated by Microsoft Excel 16.111.2. Password-to-open: open.
|
|
$temporaryFiles = self::spreadsheetTemporaryFiles();
|
|
$reader = new Xlsx();
|
|
$reader->setEncryptionPassword('open');
|
|
$spreadsheet = $reader->load(self::FIXTURE);
|
|
|
|
self::assertSame('agile encryption fixture', $spreadsheet->getActiveSheet()->getCell('A1')->getValue());
|
|
self::assertSame(42, $spreadsheet->getActiveSheet()->getCell('B1')->getValue());
|
|
$spreadsheet->disconnectWorksheets();
|
|
self::assertSame($temporaryFiles, self::spreadsheetTemporaryFiles());
|
|
}
|
|
|
|
public function testCanReadEncryptedWorkbook(): void
|
|
{
|
|
self::assertTrue((new Xlsx())->canRead(self::FIXTURE));
|
|
self::assertFalse((new Xlsx())->canRead('tests/data/Reader/XLS/sample.xls'));
|
|
}
|
|
|
|
public function testNonEncryptedOleFallsBackToNormalXlsxLoading(): void
|
|
{
|
|
$this->expectException(Exception::class);
|
|
(new Xlsx())->load('tests/data/Reader/XLS/sample.xls');
|
|
}
|
|
|
|
public function testListWorksheetMetadataForEncryptedWorkbook(): void
|
|
{
|
|
$reader = (new Xlsx())->setEncryptionPassword('open');
|
|
|
|
self::assertSame(['Sheet1'], $reader->listWorksheetNames(self::FIXTURE));
|
|
self::assertSame(
|
|
[[
|
|
'worksheetName' => 'Sheet1',
|
|
'lastColumnLetter' => 'B',
|
|
'lastColumnIndex' => 1,
|
|
'totalRows' => 1,
|
|
'totalColumns' => 2,
|
|
'sheetState' => 'visible',
|
|
]],
|
|
$reader->listWorksheetInfo(self::FIXTURE)
|
|
);
|
|
}
|
|
|
|
public function testEncryptedWorkbookRequiresPassword(): void
|
|
{
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('XLSX encryption password required');
|
|
(new Xlsx())->load(self::FIXTURE);
|
|
}
|
|
|
|
public function testEncryptedWorkbookRejectsIncorrectPassword(): void
|
|
{
|
|
$temporaryFiles = self::spreadsheetTemporaryFiles();
|
|
|
|
try {
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('XLSX encryption password is incorrect');
|
|
(new Xlsx())->setEncryptionPassword('wrong')->load(self::FIXTURE);
|
|
} finally {
|
|
self::assertSame($temporaryFiles, self::spreadsheetTemporaryFiles());
|
|
}
|
|
}
|
|
|
|
public function testEncryptedWorkbookRejectsSpinCountAboveConfiguredMaximum(): void
|
|
{
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
(new Xlsx())
|
|
->setEncryptionPassword('open')
|
|
->setMaxEncryptionSpinCount(99999)
|
|
->load(self::FIXTURE);
|
|
}
|
|
|
|
public function testEncryptedWorkbookAcceptsSpinCountAtConfiguredMaximum(): void
|
|
{
|
|
$spreadsheet = (new Xlsx())
|
|
->setEncryptionPassword('open')
|
|
->setMaxEncryptionSpinCount(100000)
|
|
->load(self::FIXTURE);
|
|
|
|
self::assertSame('agile encryption fixture', $spreadsheet->getActiveSheet()->getCell('A1')->getValue());
|
|
$spreadsheet->disconnectWorksheets();
|
|
}
|
|
|
|
public function testRejectsInvalidMaximumEncryptionSpinCount(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
(new Xlsx())->setMaxEncryptionSpinCount(AgileEncryption::MAX_SPIN_COUNT + 1);
|
|
}
|
|
|
|
public function testRejectsNegativeMaximumEncryptionSpinCount(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
(new Xlsx())->setMaxEncryptionSpinCount(-1);
|
|
}
|
|
|
|
public function testRejectsTamperedEncryptedPackage(): void
|
|
{
|
|
$package = AgileEncryption::encrypt("PK\x03\x04test", 'password');
|
|
$lastByte = strlen($package['encryptedPackage']) - 1;
|
|
$package['encryptedPackage'][$lastByte] = chr(ord($package['encryptedPackage'][$lastByte]) ^ 1);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('integrity check failed');
|
|
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'password');
|
|
}
|
|
|
|
public function testRejectsMalformedEncryptedPackage(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Malformed encrypted XLSX package');
|
|
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), substr($package['encryptedPackage'], 0, 7), 'password');
|
|
}
|
|
|
|
public function testRejectsMalformedDecryptedSecretKey(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
$info = AgileEncryption::parse($package['encryptionInfo']);
|
|
$info['encryptedKey'] = '';
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Malformed XLSX encryption information');
|
|
AgileEncryption::decrypt($info, $package['encryptedPackage'], 'password');
|
|
}
|
|
|
|
public function testRejectsMalformedEncryptionInfo(): void
|
|
{
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Malformed XLSX encryption information');
|
|
AgileEncryption::parse("\x04\x00\x04\x00\x40\x00\x00\x00not XML");
|
|
}
|
|
|
|
public function testParserMaximumCannotExceedAbsoluteSpinCountCeiling(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
$encryptionInfo = str_replace('spinCount="10"', 'spinCount="10000001"', $package['encryptionInfo']);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
AgileEncryption::parse($encryptionInfo, PHP_INT_MAX);
|
|
}
|
|
|
|
public function testRejectsEncryptionInfoWithUnexpectedNamespace(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password');
|
|
$encryptionInfo = str_replace('xmlns="http://schemas.microsoft.com/office/2006/encryption"', 'xmlns="urn:unexpected"', $package['encryptionInfo']);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
AgileEncryption::parse($encryptionInfo);
|
|
}
|
|
|
|
public function testAcceptsEncryptionInfoWithPrefixedEncryptionNamespace(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password');
|
|
$encryptionInfo = str_replace('<encryption ', '<e:encryption xmlns:e="http://schemas.microsoft.com/office/2006/encryption" ', $package['encryptionInfo']);
|
|
$encryptionInfo = str_replace('</encryption>', '</e:encryption>', $encryptionInfo);
|
|
|
|
self::assertSame('package', AgileEncryption::decrypt(AgileEncryption::parse($encryptionInfo), $package['encryptedPackage'], 'password'));
|
|
}
|
|
|
|
public function testRejectsEncryptionInfoWithUnsupportedCipherChaining(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password');
|
|
$encryptionInfo = str_replace('cipherChaining="ChainingModeCBC"', 'cipherChaining="ChainingModeCFB"', $package['encryptionInfo']);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
AgileEncryption::parse($encryptionInfo);
|
|
}
|
|
|
|
public function testRejectsEncryptionInfoWithInvalidBase64Data(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password');
|
|
$encryptionInfo = str_replace('saltValue="', 'saltValue="!', $package['encryptionInfo']);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Malformed XLSX encryption information');
|
|
AgileEncryption::parse($encryptionInfo);
|
|
}
|
|
|
|
public function testRejectsEncryptionInfoWithDuplicateRequiredElement(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password');
|
|
$encryptionInfo = str_replace('</keyEncryptors>', '</keyEncryptors><dataIntegrity/>', $package['encryptionInfo']);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
AgileEncryption::parse($encryptionInfo);
|
|
}
|
|
|
|
public function testRejectsEncryptionInfoWithIncorrectDecodedValueLength(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password');
|
|
$encryptionInfo = preg_replace('/saltValue="[^"]+"/', 'saltValue=""', $package['encryptionInfo'], 1);
|
|
self::assertIsString($encryptionInfo);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Malformed XLSX encryption information');
|
|
AgileEncryption::parse($encryptionInfo);
|
|
}
|
|
|
|
public function testRejectsEncryptionInfoWithNonDecimalNumericAttribute(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password');
|
|
$encryptionInfo = str_replace('keyBits="256"', 'keyBits="256bits"', $package['encryptionInfo']);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Malformed XLSX encryption information');
|
|
AgileEncryption::parse($encryptionInfo);
|
|
}
|
|
|
|
public function testEncryptRequiresSupportedProfileAndPassword(): void
|
|
{
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('XLSX encryption password required');
|
|
AgileEncryption::encrypt('package', '');
|
|
}
|
|
|
|
public function testDecryptRequiresPassword(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('XLSX encryption password required');
|
|
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], '');
|
|
}
|
|
|
|
public function testDecryptRejectsIncorrectPassword(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('XLSX encryption password is incorrect');
|
|
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'wrong');
|
|
}
|
|
|
|
public function testDecryptsUnicodePassword(): void
|
|
{
|
|
$password = 'pāssw🔐rd';
|
|
$package = AgileEncryption::encrypt('package', $password, 128, 'SHA1', 10);
|
|
|
|
self::assertSame('package', AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], $password));
|
|
}
|
|
|
|
public function testEncryptRejectsUnsupportedProfile(): void
|
|
{
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
AgileEncryption::encrypt('package', 'password', 64, 'SHA512', 10);
|
|
}
|
|
|
|
public function testRejectsUnsupportedEncryptionInfo(): void
|
|
{
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
AgileEncryption::parse("\x04\x00\x04\x00\x00\x00\x00\x00");
|
|
}
|
|
|
|
public function testPasswordDoesNotAffectNormalXlsx(): void
|
|
{
|
|
$reader = (new Xlsx())->setEncryptionPassword('unused');
|
|
$spreadsheet = $reader->load('tests/data/Reader/XLSX/threesheets.xlsx');
|
|
|
|
self::assertSame(3, $spreadsheet->getSheetCount());
|
|
$spreadsheet->disconnectWorksheets();
|
|
}
|
|
|
|
public function testSupportsApprovedAgileProfiles(): void
|
|
{
|
|
foreach ([[128, 'SHA1'], [192, 'SHA256'], [256, 'SHA384'], [256, 'SHA512']] as [$keyBits, $hashAlgorithm]) {
|
|
$package = AgileEncryption::encrypt('profile test', 'password', $keyBits, $hashAlgorithm, 10);
|
|
$info = AgileEncryption::parse($package['encryptionInfo']);
|
|
self::assertSame($keyBits, $info['keyBits']);
|
|
self::assertSame($hashAlgorithm, $info['hashAlgorithm']);
|
|
self::assertSame('profile test', AgileEncryption::decrypt($info, $package['encryptedPackage'], 'password'));
|
|
}
|
|
}
|
|
|
|
public function testDecryptsMultipleEncryptedSegments(): void
|
|
{
|
|
$plain = str_repeat('x', 4097);
|
|
$package = AgileEncryption::encrypt($plain, 'password', 128, 'SHA1', 10);
|
|
|
|
self::assertSame($plain, AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'password'));
|
|
}
|
|
|
|
public function testDecryptFileStreamsMultipleEncryptedSegments(): void
|
|
{
|
|
$plain = str_repeat('x', 4097);
|
|
$package = AgileEncryption::encrypt($plain, 'password', 128, 'SHA1', 10);
|
|
$encryptedFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-encrypted-');
|
|
$plainFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-plain-');
|
|
self::assertNotFalse($encryptedFilename);
|
|
self::assertNotFalse($plainFilename);
|
|
|
|
try {
|
|
file_put_contents($encryptedFilename, $package['encryptedPackage']);
|
|
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $encryptedFilename, $plainFilename, 'password');
|
|
self::assertSame($plain, file_get_contents($plainFilename));
|
|
} finally {
|
|
unlink($encryptedFilename);
|
|
unlink($plainFilename);
|
|
}
|
|
}
|
|
|
|
public function testDecryptFileDoesNotTruncateOutputWhenInputCannotBeOpened(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
|
|
self::assertNotFalse($outputFilename);
|
|
file_put_contents($outputFilename, 'preserve this content');
|
|
|
|
try {
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Could not open XLSX package for decryption');
|
|
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), 'does-not-exist.xlsx', $outputFilename, 'password');
|
|
} finally {
|
|
self::assertSame('preserve this content', file_get_contents($outputFilename));
|
|
unlink($outputFilename);
|
|
}
|
|
}
|
|
|
|
public function testDecryptFileDoesNotTruncateOutputWhenPasswordIsIncorrect(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
|
|
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
|
|
self::assertNotFalse($inputFilename);
|
|
self::assertNotFalse($outputFilename);
|
|
file_put_contents($inputFilename, $package['encryptedPackage']);
|
|
file_put_contents($outputFilename, 'preserve this content');
|
|
|
|
try {
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('XLSX encryption password is incorrect');
|
|
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputFilename, 'wrong');
|
|
} finally {
|
|
self::assertSame('preserve this content', file_get_contents($outputFilename));
|
|
unlink($inputFilename);
|
|
unlink($outputFilename);
|
|
}
|
|
}
|
|
|
|
public function testDecryptFileClosesInputWhenOutputCannotBeOpened(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
|
|
$outputDirectory = sys_get_temp_dir() . '/phpspreadsheet-output-' . uniqid();
|
|
self::assertNotFalse($inputFilename);
|
|
mkdir($outputDirectory);
|
|
file_put_contents($inputFilename, $package['encryptedPackage']);
|
|
|
|
try {
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Could not open XLSX package for decryption');
|
|
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputDirectory, 'password');
|
|
} finally {
|
|
unlink($inputFilename);
|
|
rmdir($outputDirectory);
|
|
}
|
|
}
|
|
|
|
public function testDecryptFileRejectsTamperedEncryptedPackage(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
$lastByte = strlen($package['encryptedPackage']) - 1;
|
|
$package['encryptedPackage'][$lastByte] = chr(ord($package['encryptedPackage'][$lastByte]) ^ 1);
|
|
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
|
|
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
|
|
self::assertNotFalse($inputFilename);
|
|
self::assertNotFalse($outputFilename);
|
|
file_put_contents($inputFilename, $package['encryptedPackage']);
|
|
file_put_contents($outputFilename, 'preserve this content');
|
|
|
|
try {
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('integrity check failed');
|
|
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputFilename, 'password');
|
|
} finally {
|
|
self::assertSame('preserve this content', file_get_contents($outputFilename));
|
|
unlink($inputFilename);
|
|
unlink($outputFilename);
|
|
}
|
|
}
|
|
|
|
public function testDecryptFileRejectsMalformedDecryptedSecretKey(): void
|
|
{
|
|
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
|
|
$info = AgileEncryption::parse($package['encryptionInfo']);
|
|
$info['encryptedKey'] = '';
|
|
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
|
|
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
|
|
self::assertNotFalse($inputFilename);
|
|
self::assertNotFalse($outputFilename);
|
|
file_put_contents($inputFilename, $package['encryptedPackage']);
|
|
|
|
try {
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('Malformed XLSX encryption information');
|
|
AgileEncryption::decryptFile($info, $inputFilename, $outputFilename, 'password');
|
|
} finally {
|
|
unlink($inputFilename);
|
|
unlink($outputFilename);
|
|
}
|
|
}
|
|
|
|
public function testRejectsUnrepresentableEncryptedPackageSize(): void
|
|
{
|
|
$method = new ReflectionMethod(AgileEncryption::class, 'unpackSize');
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('too large for this platform');
|
|
$high = PHP_INT_SIZE < 8 ? 1 : 0x80000000;
|
|
$method->invoke(null, pack('V2', 0, $high));
|
|
}
|
|
|
|
public function testRejectsUnrepresentableEncryptedPackageSizeOn32BitPlatform(): void
|
|
{
|
|
$method = new ReflectionMethod(AgileEncryption::class, 'sizeFromWords');
|
|
$this->expectException(Exception::class);
|
|
$this->expectExceptionMessage('too large for this platform');
|
|
$method->invoke(null, 0, 1, 4, 2147483647);
|
|
}
|
|
|
|
public function testAcceptsRepresentableEncryptedPackageSizeOn32BitPlatform(): void
|
|
{
|
|
$method = new ReflectionMethod(AgileEncryption::class, 'sizeFromWords');
|
|
self::assertSame(123, $method->invoke(null, 123, 0, 4, 2147483647));
|
|
}
|
|
|
|
/** @return string[] */
|
|
private static function spreadsheetTemporaryFiles(): array
|
|
{
|
|
$temporaryFiles = glob(File::sysGetTempDir() . '/phpspreadsheet*') ?: [];
|
|
sort($temporaryFiles);
|
|
|
|
return $temporaryFiles;
|
|
}
|
|
}
|