Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.20% covered (success)
97.20%
104 / 107
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SvgSanitizer
97.20% covered (success)
97.20%
104 / 107
66.67% covered (warning)
66.67%
4 / 6
25
0.00% covered (danger)
0.00%
0 / 1
 sanitize
81.82% covered (success)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 isSafe
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 detectIssues
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 shouldReject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decodeAllEntities
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
3
 removeDangerousContent
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * SVG Sanitizer (Regex-based) to prevent XSS attacks
5 * Alternative implementation without ext-dom dependency
6 *
7 * Decodes all HTML/XML entities before pattern matching to prevent
8 * encoding-based bypasses (e.g., &#106;&#97;&#118;&#97;... → javascript:).
9 *
10 * This Source Code Form is subject to the terms of the Mozilla Public License,
11 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
12 * obtain one at https://mozilla.org/MPL/2.0/.
13 *
14 * @package   phpMyFAQ
15 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
16 * @copyright 2026 phpMyFAQ Team
17 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
18 * @link      https://www.phpmyfaq.de
19 * @since     2026-01-15
20 */
21
22declare(strict_types=1);
23
24namespace phpMyFAQ\Helper;
25
26class SvgSanitizer
27{
28    /**
29     * Dangerous patterns to detect and remove.
30     * These are applied AFTER entity decoding, so encoded bypasses are neutralized.
31     */
32    private const array DANGEROUS_PATTERNS = [
33        // Script tags (any variation)
34        '/<script\b[^>]*>.*?<\/script>/is',
35        '/<script\b[^>]*\/>/is',
36        '/<script\b[^>]*>/is',
37
38        // Event handlers (onclick, onload, onerror, etc.)
39        '/\s+on\w+\s*=\s*["\'][^"\']*["\']/i',
40        '/\s+on\w+\s*=\s*[^"\'\s>][^\s>]*/i',
41
42        // ForeignObject tags
43        '/<foreignObject\b[^>]*>.*?<\/foreignObject>/is',
44        '/<foreignObject\b[^>]*\/>/is',
45
46        // JavaScript URLs in href/xlink:href (after entity decoding, these are plain text)
47        '/href\s*=\s*["\'][\s]*javascript\s*:[^"\']*["\']/i',
48        '/xlink:href\s*=\s*["\'][\s]*javascript\s*:[^"\']*["\']/i',
49        '/href\s*=\s*["\'][\s]*vbscript\s*:[^"\']*["\']/i',
50        '/xlink:href\s*=\s*["\'][\s]*vbscript\s*:[^"\']*["\']/i',
51
52        // Data URLs with dangerous content types
53        '/href\s*=\s*["\'][\s]*data\s*:[^"\']*["\']/i',
54        '/xlink:href\s*=\s*["\'][\s]*data\s*:[^"\']*["\']/i',
55        '/src\s*=\s*["\'][\s]*data\s*:[^"\']*["\']/i',
56
57        // CSS expressions and dangerous style content
58        '/style\s*=\s*["\'][^"\']*expression\s*\([^"\']*["\']/i',
59        '/style\s*=\s*["\'][^"\']*javascript\s*:[^"\']*["\']/i',
60        '/style\s*=\s*["\'][^"\']*vbscript\s*:[^"\']*["\']/i',
61        '/style\s*=\s*["\'][^"\']*@import[^"\']*["\']/i',
62        '/style\s*=\s*["\'][^"\']*behavior\s*:[^"\']*["\']/i',
63        '/style\s*=\s*["\'][^"\']*-moz-binding\s*:[^"\']*["\']/i',
64
65        // CDATA sections with script content
66        '/<!\[CDATA\[.*?<script.*?\]\]>/is',
67
68        // XML processing instructions (but allow standard XML declaration)
69        '/<\?(?!xml\b)[^?]*\?>/is',
70
71        // HTML tags that shouldn't be in SVG
72        '/<(iframe|embed|object|applet|meta|link|base)\b[^>]*>/i',
73    ];
74
75    /**
76     * Dangerous element tags to strip completely.
77     * Includes animate, set, and use which can execute JavaScript in SVG context.
78     */
79    private const array DANGEROUS_ELEMENTS = [
80        'script',
81        'foreignObject',
82        'iframe',
83        'embed',
84        'object',
85        'applet',
86        'meta',
87        'link',
88        'base',
89        'animate',
90        'animateMotion',
91        'animateTransform',
92        'set',
93        'use',
94        'handler',
95        'listener',
96    ];
97
98    /**
99     * Sanitizes an SVG file by removing potentially dangerous content
100     *
101     * @param string $filePath Path to the SVG file
102     * @return bool True if sanitization was successful, false otherwise
103     */
104    public function sanitize(string $filePath): bool
105    {
106        if (!file_exists($filePath)) {
107            return false;
108        }
109
110        $content = file_get_contents($filePath);
111        if ($content === false) {
112            return false;
113        }
114
115        // Check if a file actually contains SVG content
116        if (!str_contains($content, '<svg')) {
117            return false;
118        }
119
120        // Remove dangerous patterns
121        $sanitized = $this->removeDangerousContent($content);
122
123        // Verify we still have valid SVG
124        if (!str_contains($sanitized, '<svg')) {
125            return false;
126        }
127
128        return file_put_contents($filePath, $sanitized) !== false;
129    }
130
131    /**
132     * Validates if a file is a safe SVG without dangerous content
133     *
134     * @param string $filePath Path to the SVG file
135     * @return bool True if SVG is safe, false if it contains dangerous content
136     */
137    public function isSafe(string $filePath): bool
138    {
139        if (!file_exists($filePath)) {
140            return false;
141        }
142
143        $content = file_get_contents($filePath);
144        if ($content === false) {
145            return false;
146        }
147
148        // Reject non-SVG content
149        if (!str_contains($content, '<svg')) {
150            return false;
151        }
152
153        // Decode all HTML/XML entities so encoded payloads become plaintext
154        // before regex matching. This defeats &#106;&#97;&#118;... → javascript: bypasses.
155        $decoded = $this->decodeAllEntities($content);
156
157        // Check for dangerous patterns on decoded content
158        foreach (self::DANGEROUS_PATTERNS as $pattern) {
159            if (preg_match($pattern, $decoded)) {
160                return false;
161            }
162        }
163
164        // Check for dangerous element tags on decoded content
165        foreach (self::DANGEROUS_ELEMENTS as $element) {
166            if (preg_match('/<' . preg_quote($element, delimiter: '/') . '\b/i', $decoded)) {
167                return false;
168            }
169        }
170
171        return true;
172    }
173
174    /**
175     * Validates SVG content before saving (strict mode)
176     * Returns an array of detected issues, empty array if safe
177     *
178     * @param string $content SVG content to validate
179     * @return array<string> List of security issues found
180     */
181    public function detectIssues(string $content): array
182    {
183        $issues = [];
184
185        $decoded = $this->decodeAllEntities($content);
186
187        foreach (self::DANGEROUS_PATTERNS as $pattern) {
188            $matches = [];
189            if (!preg_match($pattern, $decoded, $matches)) {
190                continue;
191            }
192
193            $issues[] = 'Dangerous pattern detected: ' . substr(string: $matches[0], offset: 0, length: 100);
194        }
195
196        foreach (self::DANGEROUS_ELEMENTS as $element) {
197            if (!preg_match('/<' . preg_quote($element, delimiter: '/') . '\b/i', $decoded)) {
198                continue;
199            }
200
201            $issues[] = 'Dangerous element found: ' . $element;
202        }
203
204        return $issues;
205    }
206
207    /**
208     * Alternative: Completely reject SVGs instead of sanitizing
209     * Use this if you want to be extra cautious
210     *
211     * @param string $filePath Path to check
212     * @return bool True if a file should be rejected
213     */
214    public function shouldReject(string $filePath): bool
215    {
216        return !$this->isSafe($filePath);
217    }
218
219    /**
220     * Decodes all HTML/XML entities (named, decimal, hex) recursively until stable.
221     * This ensures that double-encoded or nested-encoded payloads are fully decoded
222     * before pattern matching.
223     */
224    private function decodeAllEntities(string $content): string
225    {
226        $previous = '';
227        $decoded = $content;
228        $maxIterations = 10;
229
230        while ($decoded !== $previous && $maxIterations-- > 0) {
231            $previous = $decoded;
232            // Decode decimal entities (&#106; → j)
233            $decoded =
234                preg_replace_callback(
235                    '/&#(\d+);/',
236                    static fn(array $matches): string => mb_chr((int) $matches[1], encoding: 'UTF-8'),
237                    $decoded,
238                ) ?? '';
239            // Decode hex entities (&#x6A; → j)
240            $decoded =
241                preg_replace_callback(
242                    '/&#x([0-9a-fA-F]+);/',
243                    static fn(array $matches): string => mb_chr((int) hexdec($matches[1]), encoding: 'UTF-8'),
244                    $decoded,
245                ) ?? '';
246            // Decode named HTML entities (&amp; → &, &lt; → <, etc.)
247            $decoded = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, encoding: 'UTF-8');
248        }
249
250        // Safety net: if the loop exited due to iteration limit, do a final
251        // numeric/hex entity decode pass to catch any remaining entities
252        $decoded =
253            preg_replace_callback(
254                '/&#(\d+);/',
255                static fn(array $matches): string => mb_chr((int) $matches[1], encoding: 'UTF-8'),
256                $decoded,
257            ) ?? '';
258        $decoded =
259            preg_replace_callback(
260                '/&#x([0-9a-fA-F]+);/',
261                static fn(array $matches): string => mb_chr((int) hexdec($matches[1]), encoding: 'UTF-8'),
262                $decoded,
263            ) ?? '';
264
265        // Strip null bytes and control characters that could break regex matching
266        // Fail closed: a regex failure must never leak partially decoded content
267        return preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', replacement: '', subject: $decoded) ?? '';
268    }
269
270    /**
271     * Removes dangerous content from SVG string
272     *
273     * @param string $content SVG content
274     * @return string Sanitized content
275     */
276    private function removeDangerousContent(string $content): string
277    {
278        $sanitized = $content;
279
280        // First: decode all entities so encoded payloads become plaintext
281        $sanitized = $this->decodeAllEntities($sanitized);
282
283        // Second: Remove dangerous element tags with their content
284        foreach (self::DANGEROUS_ELEMENTS as $element) {
285            // Remove opening and closing tags with content
286            $sanitized =
287                preg_replace(
288                    '/<'
289                    . preg_quote($element, delimiter: '/')
290                    . '\b[^>]*>.*?<\/'
291                    . preg_quote($element, delimiter: '/')
292                    . '>/is',
293                    replacement: '',
294                    subject: $sanitized,
295                ) ?? '';
296
297            // Remove self-closing tags
298            $sanitized =
299                preg_replace(
300                    '/<' . preg_quote($element, delimiter: '/') . '\b[^>]*\/>/is',
301                    replacement: '',
302                    subject: $sanitized,
303                ) ?? '';
304
305            // Remove unclosed tags
306            $sanitized =
307                preg_replace(
308                    '/<' . preg_quote($element, delimiter: '/') . '\b[^>]*>/is',
309                    replacement: '',
310                    subject: $sanitized,
311                ) ?? '';
312        }
313
314        // Third: Remove dangerous patterns using regex
315        foreach (self::DANGEROUS_PATTERNS as $pattern) {
316            $sanitized = preg_replace($pattern, replacement: '', subject: $sanitized) ?? '';
317        }
318
319        // Fourth: Additional cleanup for remaining event handlers
320        $sanitized = preg_replace('/\s+on\w+\s*=\s*[^\s>]+/i', replacement: '', subject: $sanitized) ?? '';
321
322        // Fifth: Clean up any remaining dangerous URIs in attributes
323        $sanitized =
324            preg_replace(
325                '/(href|xlink:href|src)\s*=\s*(["\'])[\s]*(javascript|vbscript|data)\s*:[^\2]*?\2/i',
326                replacement: '',
327                subject: $sanitized,
328            ) ?? '';
329
330        // Sixth: Remove CDATA sections with script content
331        $sanitized = preg_replace('/<!\[CDATA\[.*?<script.*?\]\]>/is', replacement: '', subject: $sanitized) ?? '';
332
333        // Normalize whitespace (optional, for cleaner output)
334        $sanitized = preg_replace('/\s+/', replacement: ' ', subject: $sanitized) ?? '';
335
336        // Fail closed: a regex failure must never leak unsanitized SVG content
337        return preg_replace('/>\s+</', replacement: '><', subject: $sanitized) ?? '';
338    }
339}