From 49dd9a7c7c55532d322237ff1fa63d4193959df7 Mon Sep 17 00:00:00 2001 From: djmaze <> Date: Mon, 26 Oct 2026 00:29:19 +0100 Subject: [PATCH] @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. --- .../app/libraries/RainLoop/Actions/Raw.php | 36 ++++++++++++++ .../app/libraries/RainLoop/ServiceActions.php | 8 +++- test/raw-inline-type.php | 48 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 test/raw-inline-type.php diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Raw.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Raw.php index ef73f9c7f..d7f1c7cdb 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Raw.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Raw.php @@ -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'; } diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php b/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php index 372c0b462..2392a9cbb 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/ServiceActions.php @@ -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], diff --git a/test/raw-inline-type.php b/test/raw-inline-type.php new file mode 100644 index 000000000..7d06eab75 --- /dev/null +++ b/test/raw-inline-type.php @@ -0,0 +1,48 @@ + '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";