diff --git a/composer.json b/composer.json index cf0bfeae2..becfe08d0 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "phpmailer/phpmailer": "^7.0", "claviska/simpleimage": "^4.2", "mashape/unirest-php": "^3.0", - "vlucas/phpdotenv": "^5.6" + "vlucas/phpdotenv": "^5.6", + "symfony/html-sanitizer": "^7.4" }, "suggest": { "pear/net_ldap2": "Required for the LDAP authentication plugin. Install with: composer require pear/net_ldap2" diff --git a/composer.lock b/composer.lock index 8aefdbce0..c657538c9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5606652514db35c20b306d1d32e7c8c8", + "content-hash": "194a663c07c9661ca014bdd9237ae203", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1454,6 +1454,188 @@ }, "time": "2025-11-25T22:17:17+00:00" }, + { + "name": "league/uri", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-15T06:54:53+00:00" + }, { "name": "mashape/unirest-php", "version": "v3.0.4", @@ -1505,6 +1687,73 @@ }, "time": "2016-08-11T17:49:21+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "mibe/feedwriter", "version": "v1.1.2", @@ -4125,6 +4374,80 @@ ], "time": "2026-01-26T15:07:59+00:00" }, + { + "name": "symfony/html-sanitizer", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "e4df2abd9391ce3263baa1aea9e993f6da74a3c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/e4df2abd9391ce3263baa1aea9e993f6da74a3c7", + "reference": "e4df2abd9391ce3263baa1aea9e993f6da74a3c7", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "masterminds/html5": "^2.7.2", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:15:18+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.33.0", diff --git a/lib/Common/Security/EmailHtmlSanitizer.php b/lib/Common/Security/EmailHtmlSanitizer.php new file mode 100644 index 000000000..a27ec31d2 --- /dev/null +++ b/lib/Common/Security/EmailHtmlSanitizer.php @@ -0,0 +1,46 @@ +sanitize($decoded); + } + + private static function GetSanitizer(): HtmlSanitizer + { + if (self::$sanitizer === null) { + $config = (new HtmlSanitizerConfig()) + ->allowElement('p') + ->allowElement('br') + ->allowElement('strong') + ->allowElement('b') + ->allowElement('em') + ->allowElement('i') + ->allowElement('u') + ->allowElement('ul') + ->allowElement('ol') + ->allowElement('li') + ->allowElement('a', ['href', 'title']) + ->allowLinkSchemes(['http', 'https', 'mailto']) + ->allowRelativeLinks(true) + ->forceAttribute('a', 'rel', 'noopener noreferrer'); + + self::$sanitizer = new HtmlSanitizer($config); + } + + return self::$sanitizer; + } +} diff --git a/tests/Common/Security/EmailHtmlSanitizerTest.php b/tests/Common/Security/EmailHtmlSanitizerTest.php new file mode 100644 index 000000000..7a3d8bafb --- /dev/null +++ b/tests/Common/Security/EmailHtmlSanitizerTest.php @@ -0,0 +1,37 @@ +Test underline bold

'; + + $actual = EmailHtmlSanitizer::Sanitize($input); + + $this->assertSame($input, $actual); + } + + public function testRemovesDangerousContent(): void + { + $input = '

Click

x'; + + $actual = EmailHtmlSanitizer::Sanitize($input); + + $this->assertStringNotContainsString('assertStringNotContainsString('onclick=', $actual); + $this->assertStringNotContainsString('javascript:', $actual); + } + + public function testDecodesLegacyEncodedHtmlBeforeSanitizing(): void + { + $input = '<p> Test <u>sdfsdf</u></p>'; + + $actual = EmailHtmlSanitizer::Sanitize($input); + + $this->assertSame('

Test sdfsdf

', $actual); + } +}