Skip to content
44 changes: 41 additions & 3 deletions lib/Controller/ProxyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

namespace OCA\Mail\Controller;

use OCA\Mail\Exception\ServiceException;
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 +30,8 @@
use Psr\Log\LoggerInterface;
use function file_get_contents;
use function hash_equals;
use function is_resource;
use function stream_get_contents;

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

Check failure on line 51 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

UndefinedClass

lib/Controller/ProxyController.php:51:3: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 51 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

UndefinedClass

lib/Controller/ProxyController.php:51:3: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 51 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

UndefinedClass

lib/Controller/ProxyController.php:51:3: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 51 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

UndefinedClass

lib/Controller/ProxyController.php:51:3: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)
private ?string $userId,
) {
parent::__construct($appName, $request);
Expand All @@ -65,6 +70,7 @@
* mail does not know whether the mail has been opened.
*
* @return Response|ProxyDownloadResponse
* @throws ServiceException
*/
#[UserRateLimit(limit: 50, period: 60)]
public function proxy(string $src, ?int $id, ?string $hmac): Response {
Expand All @@ -73,7 +79,7 @@

// If strict cookies are set it means we come from the same domain so no open redirect
if (!$this->request->passesStrictCookieCheck()) {
$content = file_get_contents(__DIR__ . '/../../img/blocked-image.png');
$content = $this->getBlockedImage();
return new ProxyDownloadResponse($content, $src, 'application/octet-stream');
}

Expand All @@ -95,17 +101,49 @@
try {
$response = $client->get($src);
$content = $response->getBody();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$content is only a resource with a call like $client->get($src, ['stream' => true]). The actual return for the current request is string.

Please drop is_resource fallback.

To make static code analysis happy add:

// Not a stream request, so the body is a string, never a resource.
assert(is_string($content));

if (is_resource($content)) {
$content = stream_get_contents($content);
if ($content === false) {
$content = $this->getBlockedImage();
}
}
Comment thread
kesselb marked this conversation as resolved.
} catch (ClientExceptionInterface $e) {
$this->logger->notice('Unable to proxy image', ['exception' => $e]);
$content = file_get_contents(__DIR__ . '/../../img/blocked-image.png');
$content = $this->getBlockedImage();
} catch (LocalServerException $e) {
$this->logger->warning('Prevented image proxy access to forbidden URL', [
'blockedUrl' => $src,
'exception' => $e,
]);
$content = file_get_contents(__DIR__ . '/../../img/blocked-image.png');
$content = $this->getBlockedImage();
}

// 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->svgSanitizer->looksLikeSvg($content)) {

Check failure on line 126 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

UndefinedClass

lib/Controller/ProxyController.php:126:7: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 126 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

UndefinedClass

lib/Controller/ProxyController.php:126:7: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 126 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

UndefinedClass

lib/Controller/ProxyController.php:126:7: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 126 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

UndefinedClass

lib/Controller/ProxyController.php:126:7: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
$sanitized = $this->svgSanitizer->sanitize($content);

Check failure on line 127 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

UndefinedClass

lib/Controller/ProxyController.php:127:17: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 127 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

UndefinedClass

lib/Controller/ProxyController.php:127:17: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 127 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

UndefinedClass

lib/Controller/ProxyController.php:127:17: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)

Check failure on line 127 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

UndefinedClass

lib/Controller/ProxyController.php:127:17: UndefinedClass: Class, interface or enum named OCA\Mail\Service\SvgSanitizer does not exist (see https://psalm.dev/019)
if ($sanitized === '') {
$content = $this->getBlockedImage();
return new ProxyDownloadResponse($content, $src, 'application/octet-stream');
}
return new ProxyDownloadResponse($sanitized, $src, 'image/svg+xml');
}

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

Check failure on line 135 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

PossiblyNullArgument

lib/Controller/ProxyController.php:135:36: PossiblyNullArgument: Argument 1 of OCA\Mail\Http\ProxyDownloadResponse::__construct cannot be null, possibly null value provided (see https://psalm.dev/078)

Check failure on line 135 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

PossiblyNullArgument

lib/Controller/ProxyController.php:135:36: PossiblyNullArgument: Argument 1 of OCA\Mail\Http\ProxyDownloadResponse::__construct cannot be null, possibly null value provided (see https://psalm.dev/078)

Check failure on line 135 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

PossiblyNullArgument

lib/Controller/ProxyController.php:135:36: PossiblyNullArgument: Argument 1 of OCA\Mail\Http\ProxyDownloadResponse::__construct cannot be null, possibly null value provided (see https://psalm.dev/078)

Check failure on line 135 in lib/Controller/ProxyController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

PossiblyNullArgument

lib/Controller/ProxyController.php:135:36: PossiblyNullArgument: Argument 1 of OCA\Mail\Http\ProxyDownloadResponse::__construct cannot be null, possibly null value provided (see https://psalm.dev/078)
}

/**
* @throws ServiceException
*/
private function getBlockedImage(): string {
$content = file_get_contents(__DIR__ . '/../../img/blocked-image.png');
if ($content === false) {
throw new ServiceException('Could not read blocked image');
}

return $content;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
163 changes: 163 additions & 0 deletions lib/Service/SvgSanitizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Service;

use DOMAttr;
use DOMDocument;
use DOMElement;
use DOMXPath;

/**
* Removes active content from SVG markup before it is embedded into or sent
* with a message. SVGs are rendered in an <img>/CID context where scripts do
* not execute, but they are still sanitised as defence in depth: any document
* that cannot be parsed safely is dropped entirely.
*/
class SvgSanitizer {
/** Elements that can carry or execute active content. */
private const FORBIDDEN_ELEMENTS = [
'script',
'foreignObject',
'handler',
'listener',
'set',
];
Comment thread
joeldj-nl marked this conversation as resolved.

/** Attributes that carry URL references and must not point off-document. */
private const URL_ATTRIBUTES = ['href', 'xlink:href', 'src', 'action', 'formaction'];

/** Reject payloads larger than this to prevent DoS via oversized documents. */
private const MAX_SVG_BYTES = 2 * 1024 * 1024;

/**
* @param string $svg The raw (decoded) SVG markup
* @return string The sanitised markup, or an empty string if it cannot be
* parsed safely
*/
public function sanitize(string $svg): string {
if (trim($svg) === '' || strlen($svg) > self::MAX_SVG_BYTES) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at nextcloud/server#62162 and check if we have those cases covered.

return '';
}

// A DOCTYPE or entity declaration is not needed for plain SVG graphics
// and is a common XXE / entity-expansion vector. Reject such documents.
if (preg_match('/<!DOCTYPE|<!ENTITY/i', $svg) === 1) {
return '';
}

// An XSL/Transform namespace signals a client-side transformation
// stylesheet that can execute JavaScript in some browsers. Reject the
// document outright, matching server-side hardening in nextcloud/server.
if (str_contains($svg, 'http://www.w3.org/1999/XSL/Transform')) {
return '';
}

$dom = new DOMDocument();
$previousErrors = libxml_use_internal_errors(true);
// LIBXML_NONET forbids any network access while parsing.
$loaded = $dom->loadXML($svg, LIBXML_NONET);
Comment thread
joeldj-nl marked this conversation as resolved.
libxml_clear_errors();
libxml_use_internal_errors($previousErrors);

if (!$loaded || $dom->documentElement === null) {
return '';
}

$xpath = new DOMXPath($dom);

// Remove processing instructions (e.g. <?xml-stylesheet type="text/xsl"?>).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ?> ends php and make the linter fails.

// Document-level PIs are already excluded by saveXML($dom->documentElement),
// but PIs nested inside the root element are handled here.
$pis = $xpath->query('//processing-instruction()');
if ($pis !== false) {
foreach (iterator_to_array($pis) as $pi) {
$pi->parentNode?->removeChild($pi);
}
}

// Remove dangerous elements. Matching on the local name catches them
// regardless of any namespace prefix (e.g. <x:script>).
foreach (self::FORBIDDEN_ELEMENTS as $tag) {
$nodes = $xpath->query('//*[local-name() = "' . $tag . '"]');
if ($nodes !== false) {
foreach (iterator_to_array($nodes) as $node) {
$node->parentNode?->removeChild($node);
}
}
}

// Sanitise <style> element content: strip external CSS url() references.
$styleNodes = $xpath->query('//*[local-name() = "style"]');
if ($styleNodes !== false) {
foreach ($styleNodes as $node) {
$node->textContent = $this->stripCssUrls($node->textContent);
}
}

$elements = $xpath->query('//*');
if ($elements !== false) {
foreach ($elements as $element) {
if ($element instanceof DOMElement) {
$this->stripDangerousAttributes($element);
}
}
}

$result = $dom->saveXML($dom->documentElement);
return $result === false ? '' : $result;
}

/**
* Heuristically decide whether the given bytes are an SVG document.
*/
public function looksLikeSvg(string $content): bool {
$start = ltrim($content);
if (str_starts_with($start, "\xEF\xBB\xBF")) {
$start = ltrim(substr($start, 3));
}
$hasSvgPrologue = str_starts_with($start, '<?xml')
|| stripos($start, '<svg') === 0;
return $hasSvgPrologue && stripos($content, '<svg') !== false;
}

private function stripDangerousAttributes(DOMElement $element): void {
/** @var DOMAttr $attribute */
foreach (iterator_to_array($element->attributes) as $attribute) {
$name = strtolower($attribute->nodeName);
$value = trim($attribute->nodeValue ?? '');

// Inline event handlers (onload, onclick, …).
if (str_starts_with($name, 'on')) {
$element->removeAttributeNode($attribute);
continue;
}

// Only allow same-document references; strip javascript:, external
// and data: URLs from links and resource references.
if (in_array($name, self::URL_ATTRIBUTES, true) && !str_starts_with($value, '#')) {
$element->removeAttributeNode($attribute);
continue;
}

// Strip external CSS url() references from inline style attributes.
if ($name === 'style') {
$element->setAttribute('style', $this->stripCssUrls($value));
}
}
}
Comment thread
joeldj-nl marked this conversation as resolved.

/**
* Replace CSS url() references that point outside the document with 'none'.
* Fragment references (url(#…)) are preserved for gradients and masks.
*/
private function stripCssUrls(string $css): string {
return preg_replace('/url\s*\((?!\s*[\'"]?#)[^)]*\)/i', 'none', $css) ?? $css;
}
Comment on lines +160 to +162
}
Loading
Loading