Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.29% covered (success)
94.29%
99 / 105
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailHtmlSanitizer
94.23% covered (success)
94.23%
98 / 104
80.00% covered (warning)
80.00%
4 / 5
20.08
0.00% covered (danger)
0.00%
0 / 1
 sanitize
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 stripEventHandlers
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 sanitizeUrlAttributes
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 sanitizeInlineStyles
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 blockRemoteImages
81.25% covered (warning)
81.25%
26 / 32
0.00% covered (danger)
0.00%
0 / 1
4.11
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Mail\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Mail\Domain\Contract\MailHtmlSanitizerInterface;
12
13/**
14 * Enterprise Email HTML Body Sanitizer Service.
15 *
16 * Enforces OWASP ASVS 5.0 sanitization against active scripting, CSS isolation bleeding,
17 * phishing vectors, and unauthorized remote image tracking (web bugs).
18 *
19 * @package App\Modules\Mail\Application\Service
20 */
21final readonly class MailHtmlSanitizer implements MailHtmlSanitizerInterface
22{
23    /**
24     * Safe Base64-encoded SVG outline placeholder (RFC 2397 Data URI) for neutralizing blocked remote images.
25     */
26    private const string BLOCKED_IMAGE_PLACEHOLDER = 'data:image/svg+xml;base64,'
27        . 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiB'
28        . 'wcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIj48cmVjdCB4PSIxIiB5PSIxIiB3aWR0aD0iOTgiIGhlaWdod'
29        . 'D0iOTgiIGZpbGw9IiNmOGZhZmMiIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2Ut'
30        . 'ZGFzaGFycmF5PSI2LDQiIHJ4PSIzIi8+PC9zdmc+';
31
32    /**
33     * Forbidden HTML tag names stripped entirely along with their inner contents.
34     */
35    private const string DANGEROUS_TAGS_REGEX = '#<(script|style|link|base|meta|object|embed|applet|iframe|frame|'
36        . 'frameset|form|svg|math|template|noscript)[^>]*>.*?</\1>#si';
37
38    /**
39     * Forbidden single or void tag names stripped from payload.
40     */
41    private const string DANGEROUS_SINGLE_TAGS_REGEX = '#<(script|style|link|base|meta|object|embed|applet|iframe|'
42        . 'frame|frameset|form|input|button|textarea|select|svg|math|template|noscript)[^>]*>#si';
43
44    /**
45     * {@inheritdoc}
46     */
47    public function sanitize(string $rawHtml, bool $allowRemoteImages = false): array
48    {
49        $clean = trim($rawHtml);
50        if ($clean === '') {
51            return ['html' => '', 'hasBlockedImages' => false];
52        }
53
54        // 1. Strip dangerous tags and their content
55        $clean = (string) preg_replace(self::DANGEROUS_TAGS_REGEX, '', $clean);
56        $clean = (string) preg_replace(self::DANGEROUS_SINGLE_TAGS_REGEX, '', $clean);
57
58        // 2. Strip all inline event handlers (on*), including /onerror= or <img/onload=
59        $clean = $this->stripEventHandlers($clean);
60
61        // 3. Neutralize dangerous URL schemes (javascript:, vbscript:, data:text/html, etc.)
62        $clean = $this->sanitizeUrlAttributes($clean);
63
64        // 4. Sanitize inline CSS styles (anti-clickjacking and background-image blocking)
65        $hasBlockedBgImages = false;
66        $clean = $this->sanitizeInlineStyles($clean, $allowRemoteImages, $hasBlockedBgImages);
67
68        // 5. Remote image tracking detection and blocking
69        $hasBlockedImgTags = false;
70        if (!$allowRemoteImages) {
71            $clean = $this->blockRemoteImages($clean, $hasBlockedImgTags);
72        }
73
74        // 6. Force external links to open safely in new window
75        $clean = (string) preg_replace(
76            '/<a\b(?![^>]*\btarget=)/i',
77            '<a target="_blank" rel="noopener noreferrer"',
78            $clean
79        );
80
81        return [
82            'html'             => $clean,
83            'hasBlockedImages' => $hasBlockedImgTags || $hasBlockedBgImages,
84        ];
85    }
86
87    /**
88     * Strips all event handler attributes (e.g. onclick, /onerror) regardless of whitespace or separators.
89     */
90    private function stripEventHandlers(string $html): string
91    {
92        $pattern = '/(?<=[<\s\/])on[a-z\d_-]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i';
93        $prev = '';
94        while ($prev !== $html) {
95            $prev = $html;
96            $html = (string) preg_replace($pattern, '', $html);
97        }
98        return $html;
99    }
100
101    /**
102     * Inspects and neutralizes dangerous URL schemes in link and source attributes.
103     */
104    private function sanitizeUrlAttributes(string $html): string
105    {
106        $pattern = '/\b(href|src|action|formaction|poster|xlink:href)\s*=\s*(["\']?)(.*?)\2(?=[\s>]|$)/is';
107
108        return (string) preg_replace_callback(
109            $pattern,
110            static function (array $m): string {
111                $attr = $m[1];
112                $quote = $m[2] !== '' ? $m[2] : '"';
113                $rawVal = $m[3];
114
115                $decoded = html_entity_decode($rawVal, ENT_QUOTES | ENT_HTML5, 'UTF-8');
116                $normalized = strtolower(preg_replace('/[\p{Cc}\s]+/u', '', $decoded) ?? '');
117
118                if (str_starts_with($normalized, 'javascript:')
119                    || str_starts_with($normalized, 'vbscript:')
120                    || str_starts_with($normalized, 'data:text/html')
121                    || str_starts_with($normalized, 'data:image/svg')
122                    || str_starts_with($normalized, 'data:application/')) {
123                    $scheme = strtok($normalized, ':');
124                    return sprintf('%s=%sabout:blank#blocked-%s:%s', $attr, $quote, $scheme, $quote);
125                }
126
127                return sprintf('%s=%s%s%s', $attr, $quote, $rawVal, $quote);
128            },
129            $html
130        );
131    }
132
133    /**
134     * Sanitizes inline CSS styles to prevent layout hijacking and block remote background images.
135     */
136    private function sanitizeInlineStyles(string $html, bool $allowRemoteImages, bool &$hasBlockedBg): string
137    {
138        $pattern = '/\bstyle\s*=\s*(["\'])(.*?)\1/is';
139
140        return (string) preg_replace_callback(
141            $pattern,
142            function (array $m) use ($allowRemoteImages, &$hasBlockedBg): string {
143                $quote = $m[1];
144                $css = $m[2];
145
146                // Neutralize active script CSS properties
147                $cleanCss = (string) preg_replace('/(expression|behavior|-moz-binding)\s*:[^;}]*/i', '', $css);
148                $cleanCss = (string) preg_replace('/javascript\s*:/i', '', $cleanCss);
149
150                // Neutralize dangerous layout positioning (anti UI-redressing / clickjacking)
151                $cleanCss = (string) preg_replace(
152                    '/position\s*:\s*(fixed|absolute)/i',
153                    'position: relative',
154                    $cleanCss
155                );
156                $cleanCss = (string) preg_replace('/z-index\s*:\s*\d{3,}/i', 'z-index: 1', $cleanCss);
157
158                // Detect and block remote background images if disallowed
159                if (!$allowRemoteImages && preg_match('/url\s*\(\s*["\']?https?:\/\/[^"\'\)]+/i', $cleanCss)) {
160                    $hasBlockedBg = true;
161                    $cleanCss = (string) preg_replace(
162                        '/url\s*\(\s*["\']?https?:\/\/[^"\'\)]+["\']?\s*\)/i',
163                        'none',
164                        $cleanCss
165                    );
166                }
167
168                return sprintf('style=%s%s%s', $quote, trim($cleanCss), $quote);
169            },
170            $html
171        );
172    }
173
174    /**
175     * Blocks remote tracking images and legacy background attributes.
176     */
177    private function blockRemoteImages(string $html, bool &$hasBlockedImg): string
178    {
179        // 1. Block legacy background attributes (e.g. <td background="https://...">)
180        if (preg_match('/\bbackground\s*=\s*(["\']?)https?:\/\//i', $html)) {
181            $hasBlockedImg = true;
182            $html = (string) preg_replace('/\bbackground\s*=\s*(["\']?)https?:\/\/[^"\'\s>]+/i', '', $html);
183        }
184
185        // 2. Block <img> src tags pointing to remote HTTP/HTTPS origins
186        $imagePattern = '/<img\b([^>]*?)\bsrc\s*=\s*(["\'])(https?:\/\/[^"\']+)\2([^>]*?)>/i';
187        if (preg_match($imagePattern, $html)) {
188            $hasBlockedImg = true;
189            $html = (string) preg_replace_callback(
190                $imagePattern,
191                static function (array $matches): string {
192                    $before = $matches[1];
193                    $originalUrl = $matches[3];
194                    $after = $matches[4];
195
196                    $combined = $before . ' ' . $after;
197                    if (preg_match('/\bclass\s*=\s*(["\'])(.*?)\1/i', $combined, $classMatches)) {
198                        $newClass = trim($classMatches[2] . ' mail-img-blocked');
199                        $tag = (string) preg_replace(
200                            '/\bclass\s*=\s*(["\']).*?\1/i',
201                            'class="' . $newClass . '"',
202                            '<img' . $before . $after . '>'
203                        );
204                    } else {
205                        $tag = '<img' . $before . $after . ' class="mail-img-blocked">';
206                    }
207
208                    $safeUrl = htmlspecialchars($originalUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
209                    return (string) preg_replace(
210                        '/<img\b/i',
211                        '<img data-blocked-src="' . $safeUrl . '" src="' . self::BLOCKED_IMAGE_PLACEHOLDER . '"',
212                        $tag,
213                        1
214                    );
215                },
216                $html
217            );
218        }
219
220        return $html;
221    }
222}