Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.96% covered (success)
97.96%
48 / 49
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SvgXmlSanitizerService
97.92% covered (success)
97.92%
47 / 48
80.00% covered (warning)
80.00%
4 / 5
23
0.00% covered (danger)
0.00%
0 / 1
 sanitize
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 sanitizeFile
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 purgeDangerousNodes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 sanitizeAttributes
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 isForbiddenAttribute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
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\Core\Security\Sanitizer;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DOMDocument;
12use DOMElement;
13use DOMNode;
14use DOMXPath;
15use InvalidArgumentException;
16use Throwable;
17
18/**
19 * Enterprise SVG & XML Sanitizer Service.
20 *
21 * Implements SvgXmlSanitizerServiceInterface using secure DOMDocument parsing to purge
22 * scripts, unsafe tags, event handlers, and protocol-based XSS vectors.
23 *
24 * @package App\Core\Security\Sanitizer
25 */
26final readonly class SvgXmlSanitizerService implements SvgXmlSanitizerServiceInterface
27{
28    private const array FORBIDDEN_TAGS = [
29        'script', 'foreignobject', 'applet', 'object', 'embed',
30        'iframe', 'frame', 'meta', 'link', 'style',
31    ];
32
33    private const array FORBIDDEN_PROTOCOLS = [
34        'javascript:', 'vbscript:', 'data:text/html', 'data:text/javascript',
35    ];
36
37    /**
38     * {@inheritdoc}
39     */
40    public function sanitize(string $content): string
41    {
42        $trimmed = trim($content);
43        if ($trimmed === '') {
44            return '';
45        }
46
47        $dom = new DOMDocument();
48        $dom->preserveWhiteSpace = false;
49        $dom->formatOutput = true;
50
51        $previousUse = libxml_use_internal_errors(true);
52        $loaded = @$dom->loadXML($trimmed, LIBXML_NONET | LIBXML_NOENT);
53        libxml_clear_errors();
54        libxml_use_internal_errors($previousUse);
55
56        if (!$loaded) {
57            throw new InvalidArgumentException('Malformed or unsafe XML payload.');
58        }
59
60        $this->purgeDangerousNodes($dom);
61        $this->sanitizeAttributes($dom);
62
63        $sanitized = $dom->saveXML();
64        return is_string($sanitized) ? $sanitized : '';
65    }
66
67    /**
68     * {@inheritdoc}
69     */
70    public function sanitizeFile(string $filePath): void
71    {
72        if (!file_exists($filePath) || !is_readable($filePath) || !is_writable($filePath)) {
73            throw new InvalidArgumentException(sprintf('File %s is inaccessible.', $filePath));
74        }
75
76        $raw = (string) @file_get_contents($filePath);
77        $clean = $this->sanitize($raw);
78        @file_put_contents($filePath, $clean);
79    }
80
81    /**
82     * Removes forbidden executable tags and comment nodes.
83     */
84    private function purgeDangerousNodes(DOMDocument $dom): void
85    {
86        $xpath = new DOMXPath($dom);
87        foreach (self::FORBIDDEN_TAGS as $tag) {
88            $nodes = $xpath->query("//*[local-name()='{$tag}']");
89            if ($nodes !== false) {
90                for ($i = $nodes->length - 1; $i >= 0; $i--) {
91                    $node = $nodes->item($i);
92                    $node?->parentNode?->removeChild($node);
93                }
94            }
95        }
96    }
97
98    /**
99     * Traverses all DOM elements and strips event handlers, script URIs, and dangerous attributes.
100     */
101    private function sanitizeAttributes(DOMDocument $dom): void
102    {
103        $xpath = new DOMXPath($dom);
104        $elements = $xpath->query('//*');
105        if ($elements === false) {
106            return;
107        }
108
109        /** @var DOMNode $node */
110        foreach ($elements as $node) {
111            if (!($node instanceof DOMElement)) {
112                continue;
113            }
114
115            $attrsToRemove = [];
116            foreach ($node->attributes as $attr) {
117                $name = strtolower($attr->nodeName);
118                $value = strtolower(trim($attr->nodeValue ?? ''));
119
120                if ($this->isForbiddenAttribute($name, $value)) {
121                    $attrsToRemove[] = $attr->nodeName;
122                }
123            }
124
125            foreach ($attrsToRemove as $attrName) {
126                $node->removeAttribute($attrName);
127            }
128        }
129    }
130
131    /**
132     * Evaluates if attribute represents an XSS vector.
133     */
134    private function isForbiddenAttribute(string $name, string $value): bool
135    {
136        if (str_starts_with($name, 'on')) {
137            return true;
138        }
139
140        foreach (self::FORBIDDEN_PROTOCOLS as $proto) {
141            if (str_contains($value, $proto)) {
142                return true;
143            }
144        }
145
146        return false;
147    }
148}