@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:
djmaze 2026-10-26 00:29:19 +01:00
parent bf0aa84fe7
commit 49dd9a7c7c
3 changed files with 90 additions and 2 deletions

48
test/raw-inline-type.php Normal file
View 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";