Whitelist PDF context options (#5924)

This commit is contained in:
Kevin Papst
2026-04-26 09:28:28 +02:00
committed by GitHub
parent 3b051e4aa5
commit 7a559a09e6
6 changed files with 126 additions and 16 deletions

24
composer.lock generated
View File

@@ -1308,16 +1308,16 @@
},
{
"name": "doctrine/migrations",
"version": "3.9.6",
"version": "3.9.7",
"source": {
"type": "git",
"url": "https://github.com/doctrine/migrations.git",
"reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1"
"reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1",
"reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1",
"url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec",
"reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec",
"shasum": ""
},
"require": {
@@ -1391,7 +1391,7 @@
],
"support": {
"issues": "https://github.com/doctrine/migrations/issues",
"source": "https://github.com/doctrine/migrations/tree/3.9.6"
"source": "https://github.com/doctrine/migrations/tree/3.9.7"
},
"funding": [
{
@@ -1407,7 +1407,7 @@
"type": "tidelift"
}
],
"time": "2026-02-11T06:46:11+00:00"
"time": "2026-04-23T19:33:20+00:00"
},
{
"name": "doctrine/orm",
@@ -2728,16 +2728,16 @@
},
{
"name": "kevinpapst/tabler-bundle",
"version": "2.2.0",
"version": "2.2.1",
"source": {
"type": "git",
"url": "https://github.com/kevinpapst/TablerBundle.git",
"reference": "d0f4130d7cba33015bbd798bfe529eeb4f19ac3f"
"reference": "236b9312e282437fe9df0c0a54004e80b497ec13"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/d0f4130d7cba33015bbd798bfe529eeb4f19ac3f",
"reference": "d0f4130d7cba33015bbd798bfe529eeb4f19ac3f",
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/236b9312e282437fe9df0c0a54004e80b497ec13",
"reference": "236b9312e282437fe9df0c0a54004e80b497ec13",
"shasum": ""
},
"require": {
@@ -2787,7 +2787,7 @@
"description": "Admin/Backend theme bundle for Symfony based on Tabler.io",
"support": {
"issues": "https://github.com/kevinpapst/TablerBundle/issues",
"source": "https://github.com/kevinpapst/TablerBundle/tree/2.2.0"
"source": "https://github.com/kevinpapst/TablerBundle/tree/2.2.1"
},
"funding": [
{
@@ -2799,7 +2799,7 @@
"type": "github"
}
],
"time": "2026-02-15T08:43:44+00:00"
"time": "2026-04-25T19:40:34+00:00"
},
{
"name": "league/csv",

View File

@@ -120,6 +120,19 @@ final class MPdfConverter implements HtmlToPdfConverter
$mpdf->creator = Constants::SOFTWARE;
if (\count($associatedFiles) > 0) {
// remove "path" so mPDF will not use file_get_contents() on local files
// callers must pre-read and pass the bytes via "content"
$associatedFiles = array_map(static function ($entry): array {
if (!\is_array($entry)) {
return [];
}
if (\array_key_exists('path', $entry)) {
unset($entry['path']);
}
return $entry;
}, $associatedFiles);
$mpdf->SetAssociatedFiles($associatedFiles);
}

View File

@@ -15,10 +15,27 @@ namespace App\Pdf;
*/
final class PdfContext
{
/**
* Keys that may be set from inside a (sandboxed) Twig template.
*
* Sinks that read the local filesystem inside mPDF (e.g. `associated_files`
* with a `path` entry, or `additional_xmp_rdf`) must NOT appear here. Those
* remain reachable via InvoiceModel::setOption() from PHP code.
*/
private const ALLOWED_KEYS = [
'filename', 'mode', 'format', 'orientation', 'default_font', 'default_font_size', 'fonts',
'margin_left', 'margin_right', 'margin_top', 'margin_bottom', 'margin_header', 'margin_footer',
'setAutoTopMargin', 'setAutoBottomMargin', 'PDFA', 'PDFAauto', 'useActiveForms',
];
private array $options = [];
public function setOption(string $key, string|int|array|null|bool $value): void
{
if (!\in_array($key, self::ALLOWED_KEYS, true)) {
return;
}
$this->options[$key] = $value;
}

View File

@@ -37,4 +37,56 @@ class MPdfConverterTest extends KernelTestCase
preg_match('/\/Creator \((.*)\)/', $result, $matches);
self::assertCount(2, $matches);
}
public function testAssociatedFilesPathIsStripped(): void
{
$kernel = self::bootKernel();
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
// Plant a sentinel on disk that an attacker would try to exfiltrate via
// mPDF's `SetAssociatedFiles`. The legitimate ZUGFeRD path uses
// `content` (pre-read bytes); we additionally pass `path` to confirm
// it is stripped before reaching mPDF.
$sentinelPath = tempnam(sys_get_temp_dir(), 'kimai-pdf-leak-');
self::assertNotFalse($sentinelPath);
$sentinelBytes = 'KIMAI_LEAK_SENTINEL_' . bin2hex(random_bytes(8));
file_put_contents($sentinelPath, $sentinelBytes);
$legitimateContent = 'KIMAI_LEGITIMATE_CONTENT_' . bin2hex(random_bytes(8));
try {
$sut = new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir);
$result = $sut->convertToPdf('<h1>Test</h1>', [
'associated_files' => [
[
'name' => 'attachment.txt',
'mime' => 'text/plain',
'description' => 'mixed entry',
'AFRelationship' => 'Alternative',
'path' => $sentinelPath,
'content' => $legitimateContent,
],
],
]);
} finally {
@unlink($sentinelPath);
}
self::assertNotEmpty($result);
// Decompress every FlateDecode stream in the produced PDF. The
// sentinel must not appear; the explicitly-supplied `content` must.
$allDecompressed = '';
if (preg_match_all('/stream\r?\n(.*?)\r?\nendstream/s', $result, $streams) > 0) {
foreach ($streams[1] as $stream) {
$decoded = @gzuncompress($stream);
if ($decoded !== false) {
$allDecompressed .= $decoded;
}
}
}
self::assertStringNotContainsString($sentinelBytes, $result);
self::assertStringNotContainsString($sentinelBytes, $allDecompressed);
self::assertStringContainsString($legitimateContent, $allDecompressed);
}
}

View File

@@ -25,12 +25,40 @@ class PdfContextTest extends TestCase
self::assertNull($sut->getOption('unknown'));
}
public function testSetterAndGetter(): void
public function testAllowedKeysRoundTrip(): void
{
$sut = new PdfContext();
self::assertNull($sut->getOption('unknown'));
$sut->setOption('margin_top', '12');
$sut->setOption('format', 'A4-P');
$sut->setOption('PDFA', true);
$sut->setOption('fonts', ['custom' => ['R' => 'custom.ttf']]);
self::assertEquals('12', $sut->getOption('margin_top'));
self::assertEquals('A4-P', $sut->getOption('format'));
self::assertTrue($sut->getOption('PDFA'));
self::assertEquals(['custom' => ['R' => 'custom.ttf']], $sut->getOption('fonts'));
self::assertCount(4, $sut->getOptions());
}
/**
* Templates running in the Twig sandbox must not be able to push the
* file-disclosure sinks (`associated_files` with `path`, `additional_xmp_rdf`)
* or arbitrary mPDF config keys through PdfContext.
*/
public function testForbiddenAndUnknownKeysAreDropped(): void
{
$sut = new PdfContext();
$sut->setOption('associated_files', [['path' => '/etc/passwd']]);
$sut->setOption('additional_xmp_rdf', '<rdf:Description/>');
$sut->setOption('tempDir', '/tmp');
$sut->setOption('unknown', 'foo');
self::assertEquals('foo', $sut->getOption('unknown'));
self::assertNull($sut->getOption('associated_files'));
self::assertNull($sut->getOption('additional_xmp_rdf'));
self::assertNull($sut->getOption('tempDir'));
self::assertNull($sut->getOption('unknown'));
self::assertEmpty($sut->getOptions());
}
}

View File

@@ -1668,7 +1668,7 @@ parameters:
-
message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#"
count: 1
count: 2
path: Pdf/MPdfConverterTest.php
-