Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@
'url' => '/proxy',
'verb' => 'GET'
],
[
'name' => 'imageProxy#fetch',
'url' => '/api/image/proxy',
'verb' => 'GET'
],
[
'name' => 'settings#index',
'url' => '/api/settings/provisioning',
Expand Down
170 changes: 170 additions & 0 deletions lib/Controller/ImageProxyController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

declare(strict_types=1);

/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Controller;

use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\IMimeTypeDetector;
use OCA\Mail\Service\SvgSanitizer;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\LocalServerException;
use OCP\IRequest;
use OCP\Security\IRemoteHostValidator;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
use function base64_encode;
use function fclose;
use function feof;
use function fread;
use function in_array;
use function is_resource;
use function ltrim;
use function parse_url;
use function str_starts_with;
use function stripos;
use function strlen;

/**
* Downloads an external image server-side so it can be embedded into a composed
* message or a signature as a data: URI.
*
* Loading external images directly in the browser is blocked by the app's
* Content Security Policy, so the user provides a URL, the server fetches it
* once (with the platform's SSRF protection) and returns the bytes as a data:
* URI. The image is later turned into an inline CID attachment when the message
* is sent, see {@see \OCA\Mail\Service\MimeMessage::extractDataUriImages()}.
*/
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class ImageProxyController extends Controller {
/**
* Hard cap on the downloaded image size to avoid exhausting memory and to
* keep outgoing messages within sane limits.
*/
private const MAX_IMAGE_SIZE = 10 * 1024 * 1024;

/**
* Image types that browsers can render in an <img> tag and that are safe to
* embed.
*/
private const ALLOWED_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
];

public function __construct(
string $appName,
IRequest $request,
private IClientService $clientService,
private IRemoteHostValidator $remoteHostValidator,
private IMimeTypeDetector $mimeTypeDetector,
private SvgSanitizer $svgSanitizer,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}

/**
* Fetch an external image and return it as a data: URI.
*
* @NoAdminRequired
* @UserRateThrottle(limit=50, period=60)
*/
#[UserRateLimit(limit: 50, period: 60)]
public function fetch(string $url): JSONResponse {
$scheme = parse_url($url, PHP_URL_SCHEME);
if ($scheme !== 'http' && $scheme !== 'https') {
return new JSONResponse(['message' => 'Invalid URL'], Http::STATUS_BAD_REQUEST);
}

// Reject internal/local hosts up front (SSRF). The client throwing a
// LocalServerException below additionally guards against redirects.
if (!$this->remoteHostValidator->isValid($url)) {
return new JSONResponse(['message' => 'Forbidden URL'], Http::STATUS_FORBIDDEN);
}

$client = $this->clientService->newClient();
try {
$response = $client->get($url, [
'timeout' => 10,
'stream' => true,
]);
} catch (LocalServerException $e) {
$this->logger->warning('Blocked image insert from forbidden URL', [
'exception' => $e,
]);
return new JSONResponse(['message' => 'Forbidden URL'], Http::STATUS_FORBIDDEN);
} catch (ClientExceptionInterface $e) {
$this->logger->info('Could not fetch image to insert', [
'exception' => $e,
]);
return new JSONResponse(['message' => 'Could not fetch image'], Http::STATUS_BAD_GATEWAY);
}

$body = $response->getBody();
if (!is_resource($body)) {
return new JSONResponse(['message' => 'Could not fetch image'], Http::STATUS_BAD_GATEWAY);
}

// Read the response incrementally to enforce the size limit without
// buffering arbitrarily large responses in memory.
$content = '';
while (!feof($body)) {
$chunk = fread($body, 8192);
if ($chunk === false) {
break;
}
$content .= $chunk;
if (strlen($content) > self::MAX_IMAGE_SIZE) {
fclose($body);
return new JSONResponse(['message' => 'Image too large'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
}
}
fclose($body);

// Detect the type from the actual bytes instead of trusting the remote
// Content-Type header.
$mimeType = $this->mimeTypeDetector->detectString($content);

// finfo frequently reports SVG (which is plain XML) as a text/* type, so
// fall back to sniffing the markup to support SVG logos.
if ($mimeType !== 'image/svg+xml' && $this->looksLikeSvg($content)) {
$mimeType = 'image/svg+xml';
}

if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
return new JSONResponse(['message' => 'Unsupported image type'], Http::STATUS_UNSUPPORTED_MEDIA_TYPE);
}

if ($mimeType === 'image/svg+xml') {
$content = $this->svgSanitizer->sanitize($content);
}

$dataUri = 'data:' . $mimeType . ';base64,' . base64_encode($content);
return new JSONResponse(['data' => $dataUri]);
}

/**
* Heuristically decide whether the given bytes are an SVG document.
*/
private function looksLikeSvg(string $content): bool {
$start = ltrim($content);
$hasSvgRoot = str_starts_with($start, '<?xml')
|| str_starts_with($start, '<!--')
|| stripos($start, '<svg') === 0;
return $hasSvgRoot && stripos($content, '<svg') !== false;
}
}
30 changes: 30 additions & 0 deletions lib/Controller/ProxyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OCA\Mail\Html\ProxyHmacGenerator;
use OCA\Mail\Http\ProxyDownloadResponse;
use OCA\Mail\Service\MailManager;
use OCA\Mail\Service\SvgSanitizer;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
Expand All @@ -28,6 +29,9 @@
use Psr\Log\LoggerInterface;
use function file_get_contents;
use function hash_equals;
use function ltrim;
use function stripos;
use function str_starts_with;

#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class ProxyController extends Controller {
Expand All @@ -37,6 +41,7 @@ class ProxyController extends Controller {
private LoggerInterface $logger;
private ProxyHmacGenerator $hmacGenerator;
private MailManager $mailManager;
private SvgSanitizer $svgSanitizer;
private ?string $userId;

public function __construct(string $appName,
Expand All @@ -47,6 +52,7 @@ public function __construct(string $appName,
ProxyHmacGenerator $hmacGenerator,
LoggerInterface $logger,
MailManager $mailManager,
SvgSanitizer $svgSanitizer,
?string $userId) {
parent::__construct($appName, $request);
$this->request = $request;
Expand All @@ -56,6 +62,7 @@ public function __construct(string $appName,
$this->logger = $logger;
$this->hmacGenerator = $hmacGenerator;
$this->mailManager = $mailManager;
$this->svgSanitizer = $svgSanitizer;
$this->userId = $userId;
}

Expand Down Expand Up @@ -112,6 +119,29 @@ public function proxy(string $src, ?int $id, ?string $hmac): Response {
$content = file_get_contents(__DIR__ . '/../../img/blocked-image.png');
}

$content = (string)$content;

// Browsers sniff raster image formats in <img> tags, but they refuse to
// render SVG unless it is served with the image/svg+xml content type.
// Detect and sanitise SVG markup so external SVG logos are displayed
// instead of staying blank. Sanitising also strips any active content in
// case the response is fetched through a direct (non-<img>) navigation.
if ($this->looksLikeSvg($content)) {
$content = $this->svgSanitizer->sanitize($content);
return new ProxyDownloadResponse($content, $src, 'image/svg+xml');
}

return new ProxyDownloadResponse($content, $src, 'application/octet-stream');
}

/**
* Heuristically decide whether the given bytes are an SVG document.
*/
private function looksLikeSvg(string $content): bool {
$start = ltrim($content);
$hasSvgPrologue = str_starts_with($start, '<?xml')
|| str_starts_with($start, '<!--')
|| stripos($start, '<svg') === 0;
return $hasSvgPrologue && stripos($content, '<svg') !== false;
}
}
3 changes: 2 additions & 1 deletion lib/Service/AntiSpamService.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st
$mail->addHeaders($headers);

$mimeMessage = new MimeMessage(
new DataUriParser()
new DataUriParser(),
new SvgSanitizer(),
);

$mimePart = $mimeMessage->build(
Expand Down
9 changes: 9 additions & 0 deletions lib/Service/Html.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ public function sanitizeHtmlMailBody(int $messageId, string $mailBody, array $in
// Rewrite URL for redirection and proxying of content
/** @var HTMLPurifier_HTMLDefinition $def */
$def = $config->getHTMLDefinition(true);

// HTMLPurifier defaults to an HTML 4.01 schema which does not know the
// HTML5 <figure>/<figcaption> elements. Without registering them the
// figure wrappers are stripped and the contained images collapse from
// stacked blocks into inline siblings (rendered side by side). Editors
// such as CKEditor wrap images in figures, so keep them as block elements.
$def->addElement('figure', 'Block', 'Flow', 'Common');
$def->addElement('figcaption', 'Block', 'Flow', 'Common');

$def->info_attr_transform_post['imagesrc'] = new TransformImageSrc($this->urlGenerator);
$def->info_attr_transform_post['cssbackground'] = new TransformStyleURLs($this->urlGenerator);
$def->info_attr_transform_post['htmllinks'] = new TransformHTMLLinks();
Expand Down
3 changes: 2 additions & 1 deletion lib/Service/MailTransmission.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void
$mail->addHeaders($headers);

$mimeMessage = new MimeMessage(
new DataUriParser()
new DataUriParser(),
new SvgSanitizer(),
);
$mimePart = $mimeMessage->build(
$localMessage->getBodyPlain(),
Expand Down
Loading