Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
40 changes: 35 additions & 5 deletions src/Mail/HtmlComposer.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
namespace Nette\Mail;

use Nette;
use Nette\Utils\FileSystem;
use Nette\Utils\Strings;
use function array_map, array_reverse, explode, implode, is_string, rtrim, strlen, substr, substr_replace, trim, urldecode;
use function array_key_exists, array_map, array_reverse, explode, finfo_file, finfo_open, implode, is_file, is_string, rtrim, str_starts_with, strlen, substr, substr_replace, trim, urldecode;
use const DIRECTORY_SEPARATOR, FILEINFO_MIME_TYPE;


/**
Expand All @@ -31,7 +33,9 @@ public function __construct(
/**
* Enables embedding of local images referenced in HTML. The path is the base directory
* for resolving relative image references in <img src>, <body background>, url(...) in
* style attributes/<style> tags, and the [[file]] placeholder.
* style attributes/<style> tags, and the [[file]] placeholder. A reference to a file that is
* missing, is not an image or is outside of the base directory throws an exception, except
* the [[file]] placeholder, which is left untouched.
*/
public function embedImages(string $basePath): static
{
Expand Down Expand Up @@ -124,9 +128,18 @@ private static function embedImagesInHtml(string $html, string $basePath, Messag
);
foreach (array_reverse($matches) as $m) {
$file = rtrim($basePath, '/\\') . '/' . (isset($m[4]) ? $m[4][0] : urldecode($m[3][0]));
if (!isset($cids[$file])) {
$contentId = $mail->addEmbeddedFile($file)->getHeader('Content-ID');
$cids[$file] = is_string($contentId) ? substr($contentId, 1, -1) : '';
if (!array_key_exists($file, $cids)) {
$cids[$file] = null;
if (self::isEmbeddableImage($file, $basePath)) {
$contentId = $mail->addEmbeddedFile($file)->getHeader('Content-ID');
$cids[$file] = is_string($contentId) ? substr($contentId, 1, -1) : '';
} elseif (!isset($m[4])) { // [[...]] may be just a text
throw new Nette\InvalidArgumentException("File '$file' referenced in HTML is missing, is not an image or is outside of the base path.");
}
}

if ($cids[$file] === null) {
continue;
}

$html = substr_replace(
Expand All @@ -138,4 +151,21 @@ private static function embedImagesInHtml(string $html, string $basePath, Messag
}
return $html;
}


/**
* Checks that the file is an image inside the base path, so that a reference in HTML
* coming from untrusted content cannot embed an arbitrary file into the email.
*/
private static function isEmbeddableImage(string $file, string $basePath): bool
{
$basePath = rtrim(FileSystem::normalizePath($basePath), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (!str_starts_with(FileSystem::normalizePath($file), $basePath) || !is_file($file)) {
return false;
}

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$contentType = $finfo ? finfo_file($finfo, $file) : false;
return is_string($contentType) && str_starts_with($contentType, 'image/');
}
}
35 changes: 35 additions & 0 deletions tests/Mail/HtmlComposer.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,41 @@ test('embedImages() embeds local images and rewrites src to cid:', function () {
});


test('embedImages() fails on references to a missing file, a non-image or a file outside the base path', function () {
foreach (['<img src="missing.png">', '<img src="example.zip">', '<img src="../HtmlComposer.phpt">', '<img src="%2e%2e/HtmlComposer.phpt">', '<img src="../missing.png">', '<div style="background: url(../HtmlComposer.phpt)">'] as $html) {
Assert::exception(
fn() => (new HtmlComposer($html))
->embedImages(__DIR__ . '/fixtures')
->applyTo(new Message),
Nette\InvalidArgumentException::class,
"File '%a%' referenced in HTML is missing, is not an image or is outside of the base path.",
);
}
});


test('embedImages() leaves [[...]] of a missing file, a non-image or a file outside the base path untouched', function () {
$html = '<p>[[spoiler]] [[private.key]] [[../HtmlComposer.phpt]] [[../missing.png]]</p>';
$mail = new Message;
(new HtmlComposer($html))
->embedImages(__DIR__ . '/fixtures')
->applyTo($mail);

Assert::same($html, $mail->getHtmlBody());
Assert::notContains('Content-ID', $mail->generateMessage());
});


test('embedImages() embeds a path leading back into the base path', function () {
$mail = new Message;
(new HtmlComposer('<img src="../fixtures/background.png">'))
->embedImages(__DIR__ . '/fixtures')
->applyTo($mail);

Assert::match('<img src="cid:%S%@%S%">', $mail->getHtmlBody());
});


test('inlineCss + embedImages combined', function () {
$mail = new Message;
(new HtmlComposer('<html><body><p>Hi</p><img src="background.png"></body></html>'))
Expand Down
Loading