mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-11-03 19:08:50 +03:00
@ZaphodB fix(security): attachments rendered inline as the type their sender declared
Raw view served an attachment inline with the Content-Type from the message, only forcing PDF by file name. A part named photo.png declared as text/html is previewed as an image by extension, and its link opened an HTML page on the webmail origin. The raw CSP (script-src 'none'; child-src 'none') stopped scripts, but replaced the global policy, so forms, meta refresh, base and remote styles and images all worked: a convincing phishing page under the real hostname. - inlineContentType() allows only raster images, PDF, audio, video and text/plain to be shown as declared. Other text/* is shown as text/plain (an HTML attachment displays its source); anything else, SVG and XML included, is sent as an application/octet-stream download. - The raw CSP adds default-src 'none', form-action 'none' and base-uri 'none', with explicit img, media, style and object sources for what raw view legitimately shows. object-src 'self' keeps the browser PDF viewer usable (not verified in a browser here). test/raw-inline-type.php covers the type decision, parameters and case.
This commit is contained in:
parent
bf0aa84fe7
commit
49dd9a7c7c
3 changed files with 90 additions and 2 deletions
|
|
@ -212,6 +212,21 @@ trait Raw
|
|||
}
|
||||
}
|
||||
|
||||
// The type comes from the message: whoever sent the mail decides it.
|
||||
// Viewed inline under this origin, text/html (or SVG, XML, ...)
|
||||
// becomes a page of the sender's choosing; the CSP stops scripts
|
||||
// but not forms, meta refresh or remote styles. Only a few types
|
||||
// are shown inline as declared.
|
||||
if (!$bDownload) {
|
||||
$sInlineType = static::inlineContentType($sContentType);
|
||||
if (null === $sInlineType) {
|
||||
$bDownload = true;
|
||||
$sContentType = 'application/octet-stream';
|
||||
} else {
|
||||
$sContentType = $sInlineType;
|
||||
}
|
||||
}
|
||||
|
||||
if (!\headers_sent()) {
|
||||
\header('Content-Type: '.$sContentType);
|
||||
\MailSo\Base\Http::setContentDisposition($bDownload ? 'attachment' : 'inline', ['filename' => $sFileName]);
|
||||
|
|
@ -262,6 +277,27 @@ trait Raw
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Content-Type to view an attachment inline with, or null to make it a
|
||||
* download. Raster images, PDF, audio, video and plain text are shown as
|
||||
* declared; any other text/* is shown as text/plain, so an HTML attachment
|
||||
* displays its source instead of rendering as a page on this origin.
|
||||
*/
|
||||
public static function inlineContentType(string $sContentType) : ?string
|
||||
{
|
||||
$sType = \strtolower(\trim(\explode(';', $sContentType, 2)[0]));
|
||||
if (\in_array($sType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp',
|
||||
'application/pdf', 'text/plain'], true)
|
||||
|| \preg_match('#^(audio|video)/[a-z0-9.+-]+$#', $sType)
|
||||
) {
|
||||
return $sType;
|
||||
}
|
||||
if (\str_starts_with($sType, 'text/')) {
|
||||
return 'text/plain';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function loadImage($resource, bool $bDetectImageOrientation = false, int $iThumbnailBoxSize = 0) : \SnappyMail\Image
|
||||
{
|
||||
if (\extension_loaded('gmagick')) { $handler = 'gmagick'; }
|
||||
|
|
|
|||
|
|
@ -339,8 +339,12 @@ class ServiceActions
|
|||
$sMethodName = 'Raw'.$sAction;
|
||||
if (\method_exists($this->oActions, $sMethodName)) {
|
||||
\header('X-Raw-Action: '.$sMethodName);
|
||||
\header('Content-Security-Policy: script-src \'none\'; child-src \'none\'; object-src \'none\'');
|
||||
|
||||
// Raw responses carry content from the message. Besides scripts and
|
||||
// frames, deny forms, base and everything not needed to show an
|
||||
// image, media, a PDF or plain text.
|
||||
\header('Content-Security-Policy: default-src \'none\'; script-src \'none\'; child-src \'none\'; '
|
||||
. 'form-action \'none\'; base-uri \'none\'; img-src \'self\' data:; media-src \'self\'; '
|
||||
. 'style-src \'self\' \'unsafe-inline\'; object-src \'self\'');
|
||||
$sRawError = '';
|
||||
$this->oActions->SetActionParams(array(
|
||||
'RawKey' => empty($this->aPaths[3]) ? '' : $this->aPaths[3],
|
||||
|
|
|
|||
48
test/raw-inline-type.php
Normal file
48
test/raw-inline-type.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
// Run with: php test/raw-inline-type.php
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$file = dirname(__DIR__).'/tachyon/v/0.0.0/app/libraries/'.str_replace('\\', '/', $class).'.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
function check(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
class RawInlineType
|
||||
{
|
||||
use \Tachyon\Actions\Raw;
|
||||
}
|
||||
|
||||
$cases = [
|
||||
// shown as declared
|
||||
'image/png' => 'image/png',
|
||||
'IMAGE/JPEG; name="x.jpg"' => 'image/jpeg',
|
||||
'application/pdf' => 'application/pdf',
|
||||
'video/mp4' => 'video/mp4',
|
||||
'audio/ogg; codecs=opus' => 'audio/ogg',
|
||||
'text/plain; charset=utf-8' => 'text/plain',
|
||||
// shown as source, never rendered
|
||||
'text/html' => 'text/plain',
|
||||
'Text/HTML; charset=utf-8' => 'text/plain',
|
||||
'text/xml' => 'text/plain',
|
||||
// downloaded
|
||||
'image/svg+xml' => null,
|
||||
'application/xhtml+xml' => null,
|
||||
'application/xml' => null,
|
||||
'application/octet-stream' => null,
|
||||
'multipart/related' => null,
|
||||
'' => null,
|
||||
];
|
||||
foreach ($cases as $in => $expected) {
|
||||
$got = RawInlineType::inlineContentType($in);
|
||||
check($expected === $got, "'{$in}': expected ".var_export($expected, true).', got '.var_export($got, true));
|
||||
}
|
||||
|
||||
echo "raw-inline-type: ok\n";
|
||||
Loading…
Add table
Add a link
Reference in a new issue