Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
139 / 139
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
Svg
100.00% covered (success)
100.00%
139 / 139
100.00% covered (success)
100.00%
15 / 15
84
100.00% covered (success)
100.00%
1 / 1
 sanitize
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 parse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 copy
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 isLegalComment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 moveLegalComments
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 confine
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 animatesRoot
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 create
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 allowed
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 animates
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 attribute
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
14
 link
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 stylesheet
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 css
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
12
 compact
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Util;
6
7use Dom\Attr;
8use Dom\Comment;
9use Dom\Element;
10use Dom\Node;
11use Dom\Text;
12use Dom\XMLDocument;
13use Dom\XPath;
14use DOMException;
15use ValueError;
16
17/**
18 * Rebuilds SVG markup from an allowlist, so that it can be served from or
19 * inlined into the site origin without running script or loading remote
20 * resources.
21 *
22 * The input is parsed strictly as XML and only allowed nodes are copied
23 * into a new document. Whatever is not copied explicitly (doctype,
24 * processing instructions, comments other than legal comments, entity
25 * references, other namespaces) cannot survive. The serializer escapes
26 * text, so the result is also safe inside HTML.
27 *
28 * @internal
29 */
30final class Svg
31{
32    private const string SVG = 'http://www.w3.org/2000/svg';
33    private const string XLINK = 'http://www.w3.org/1999/xlink';
34    private const string XML = 'http://www.w3.org/XML/1998/namespace';
35    private const string XMLNS = 'http://www.w3.org/2000/xmlns/';
36
37    /**
38     * Elements of the SVG namespace that are kept; any other element is
39     * dropped with its subtree. Left out on purpose: `script`,
40     * `foreignObject` (HTML content), `feImage` (loads documents), SVG
41     * fonts (`font` also ends foreign content when inlined into HTML),
42     * `cursor`, `color-profile`, `tref`, `metadata`, and deprecated or
43     * unsupported elements.
44     */
45    private const array ELEMENTS = [
46        'a',
47        'animate',
48        'animateMotion',
49        'animateTransform',
50        'circle',
51        'clipPath',
52        'defs',
53        'desc',
54        'ellipse',
55        'feBlend',
56        'feColorMatrix',
57        'feComponentTransfer',
58        'feComposite',
59        'feConvolveMatrix',
60        'feDiffuseLighting',
61        'feDisplacementMap',
62        'feDistantLight',
63        'feDropShadow',
64        'feFlood',
65        'feFuncA',
66        'feFuncB',
67        'feFuncG',
68        'feFuncR',
69        'feGaussianBlur',
70        'feMerge',
71        'feMergeNode',
72        'feMorphology',
73        'feOffset',
74        'fePointLight',
75        'feSpecularLighting',
76        'feSpotLight',
77        'feTile',
78        'feTurbulence',
79        'filter',
80        'g',
81        'image',
82        'line',
83        'linearGradient',
84        'marker',
85        'mask',
86        'mpath',
87        'path',
88        'pattern',
89        'polygon',
90        'polyline',
91        'radialGradient',
92        'rect',
93        'set',
94        'stop',
95        'style',
96        'svg',
97        'switch',
98        'symbol',
99        'text',
100        'textPath',
101        'title',
102        'tspan',
103        'use',
104        'view',
105    ];
106
107    private const array ANIMATIONS = ['animate', 'animateMotion', 'animateTransform', 'set'];
108
109    /**
110     * Attributes without namespace that are kept. Event handlers are not
111     * listed and therefore never kept. `href` is checked as a link and
112     * `style` as CSS; values of the others are checked as CSS values
113     * unless listed in PLAIN.
114     */
115    private const array ATTRIBUTES = [
116        // Core, structure and conditional processing
117        'baseProfile',
118        'class',
119        'href',
120        'id',
121        'lang',
122        'preserveAspectRatio',
123        'requiredExtensions',
124        'requiredFeatures',
125        'role',
126        'style',
127        'systemLanguage',
128        'transform',
129        'version',
130        'viewBox',
131        // Geometry
132        'cx',
133        'cy',
134        'd',
135        'height',
136        'pathLength',
137        'points',
138        'r',
139        'rx',
140        'ry',
141        'width',
142        'x',
143        'x1',
144        'x2',
145        'y',
146        'y1',
147        'y2',
148        // Markers, patterns, gradients, clipping and masking
149        'clipPathUnits',
150        'fr',
151        'fx',
152        'fy',
153        'gradientTransform',
154        'gradientUnits',
155        'markerHeight',
156        'markerUnits',
157        'markerWidth',
158        'maskContentUnits',
159        'maskUnits',
160        'offset',
161        'orient',
162        'patternContentUnits',
163        'patternTransform',
164        'patternUnits',
165        'refX',
166        'refY',
167        'spreadMethod',
168        // Text
169        'dx',
170        'dy',
171        'lengthAdjust',
172        'method',
173        'path',
174        'rotate',
175        'side',
176        'spacing',
177        'startOffset',
178        'textLength',
179        // Filters
180        'amplitude',
181        'azimuth',
182        'baseFrequency',
183        'bias',
184        'diffuseConstant',
185        'divisor',
186        'edgeMode',
187        'elevation',
188        'exponent',
189        'filterUnits',
190        'in',
191        'in2',
192        'intercept',
193        'k1',
194        'k2',
195        'k3',
196        'k4',
197        'kernelMatrix',
198        'kernelUnitLength',
199        'limitingConeAngle',
200        'mode',
201        'numOctaves',
202        'operator',
203        'order',
204        'pointsAtX',
205        'pointsAtY',
206        'pointsAtZ',
207        'preserveAlpha',
208        'primitiveUnits',
209        'radius',
210        'result',
211        'scale',
212        'seed',
213        'slope',
214        'specularConstant',
215        'specularExponent',
216        'stdDeviation',
217        'stitchTiles',
218        'surfaceScale',
219        'tableValues',
220        'targetX',
221        'targetY',
222        'type',
223        'values',
224        'xChannelSelector',
225        'yChannelSelector',
226        'z',
227        // Animation
228        'accumulate',
229        'additive',
230        'attributeName',
231        'attributeType',
232        'begin',
233        'by',
234        'calcMode',
235        'dur',
236        'end',
237        'from',
238        'keyPoints',
239        'keySplines',
240        'keyTimes',
241        'max',
242        'min',
243        'repeatCount',
244        'repeatDur',
245        'restart',
246        'to',
247        // Presentation attributes
248        'alignment-baseline',
249        'baseline-shift',
250        'clip',
251        'clip-path',
252        'clip-rule',
253        'color',
254        'color-interpolation',
255        'color-interpolation-filters',
256        'color-rendering',
257        'direction',
258        'display',
259        'dominant-baseline',
260        'enable-background',
261        'fill',
262        'fill-opacity',
263        'fill-rule',
264        'filter',
265        'flood-color',
266        'flood-opacity',
267        'font-family',
268        'font-size',
269        'font-size-adjust',
270        'font-stretch',
271        'font-style',
272        'font-variant',
273        'font-weight',
274        'glyph-orientation-horizontal',
275        'glyph-orientation-vertical',
276        'image-rendering',
277        'letter-spacing',
278        'lighting-color',
279        'marker-end',
280        'marker-mid',
281        'marker-start',
282        'mask',
283        'mask-type',
284        'opacity',
285        'overflow',
286        'paint-order',
287        'pointer-events',
288        'shape-rendering',
289        'stop-color',
290        'stop-opacity',
291        'stroke',
292        'stroke-dasharray',
293        'stroke-dashoffset',
294        'stroke-linecap',
295        'stroke-linejoin',
296        'stroke-miterlimit',
297        'stroke-opacity',
298        'stroke-width',
299        'text-anchor',
300        'text-decoration',
301        'text-overflow',
302        'text-rendering',
303        'transform-origin',
304        'unicode-bidi',
305        'vector-effect',
306        'visibility',
307        'white-space',
308        'word-spacing',
309        'writing-mode',
310    ];
311
312    /** Attributes whose values are neither CSS nor URLs, kept as they are. */
313    private const array PLAIN = ['attributeName', 'class', 'id', 'lang', 'role'];
314
315    /** Elements whose content is kept as text only. */
316    private const array TEXT_ONLY = ['desc', 'title'];
317
318    /** Root attributes that let an inlined drawing leave its box. */
319    private const array ESCAPING = ['filter', 'overflow', 'style', 'transform'];
320
321    /** Animating these would bypass the link and CSS checks. */
322    private const array UNANIMATABLE = ['href', 'style'];
323
324    /** CSS functions that cannot load anything; `url()` is checked separately. */
325    private const array FUNCTIONS = [
326        'blur',
327        'brightness',
328        'calc',
329        'circle',
330        'clamp',
331        'color',
332        'color-mix',
333        'contrast',
334        'cubic-bezier',
335        'drop-shadow',
336        'ellipse',
337        'format',
338        'grayscale',
339        'hsl',
340        'hsla',
341        'hue-rotate',
342        'hwb',
343        'inset',
344        'invert',
345        'lab',
346        'lch',
347        'linear',
348        'local',
349        'matrix',
350        'matrix3d',
351        'max',
352        'min',
353        'oklab',
354        'oklch',
355        'opacity',
356        'perspective',
357        'polygon',
358        'rgb',
359        'rgba',
360        'rotate',
361        'rotate3d',
362        'rotatex',
363        'rotatey',
364        'rotatez',
365        'saturate',
366        'scale',
367        'scale3d',
368        'scalex',
369        'scaley',
370        'scalez',
371        'sepia',
372        'skew',
373        'skewx',
374        'skewy',
375        'steps',
376        'translate',
377        'translate3d',
378        'translatex',
379        'translatey',
380        'translatez',
381        'url',
382        'var',
383    ];
384
385    private const array AT_RULES = ['-webkit-keyframes', 'charset', 'font-face', 'keyframes', 'media', 'supports'];
386
387    private const string IMAGE_DATA = '~^data:image/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/]*={0,2}$~i';
388    private const string CSS_DATA = '~^data:(?:image/(?:png|jpe?g|gif|webp)|font/[a-z0-9.+-]+|application/(?:x-)?font-[a-z0-9.+-]+);base64,[a-z0-9+/]*={0,2}$~i';
389
390    /**
391     * Returns the cleaned markup without XML declaration, or null when the
392     * input is rejected as a whole: empty, not well-formed XML, a root
393     * other than `svg` in the SVG namespace, or a doctype with an internal
394     * subset (entity declarations).
395     *
396     * Pass `inline: true` for markup that is placed into an HTML page
397     * rather than served as a file or embedded with `<img>`; see confine().
398     */
399    public static function sanitize(string $svg, bool $inline = false): ?string
400    {
401        $source = self::parse($svg);
402        $root = $source?->documentElement;
403
404        if (
405            $root === null
406            || $root->namespaceURI !== self::SVG
407            || $root->localName !== 'svg'
408            || trim((string) $source?->doctype?->internalSubset) !== ''
409        ) {
410            return null;
411        }
412
413        $document = XMLDocument::createEmpty();
414        $copy = self::copy($root, $document);
415        $document->append($copy);
416        self::moveLegalComments($source, $document, $copy);
417
418        if ($inline) {
419            self::confine($document, $copy);
420        }
421
422        foreach ($document->getElementsByTagNameNS(self::SVG, '*') as $element) {
423            if ($element->hasAttributeNS(self::XLINK, 'href')) {
424                // Declared once on the root instead of on every linking element.
425                $copy->setAttributeNS(self::XMLNS, 'xmlns:xlink', self::XLINK);
426
427                break;
428            }
429        }
430
431        $markup = $document->saveXml($copy);
432
433        return $markup === false ? null : $markup;
434    }
435
436    private static function parse(string $svg): ?XMLDocument
437    {
438        if (trim($svg) === '') {
439            return null;
440        }
441
442        // Parse errors are collected instead of reported; the flag and the
443        // error buffer are restored for whatever runs next in this process.
444        $internalErrors = libxml_use_internal_errors(true);
445
446        try {
447            // Without LIBXML_NOENT and LIBXML_DTDLOAD no entity is substituted
448            // and no external DTD is read; LIBXML_NONET forbids network access.
449            return XMLDocument::createFromString($svg, LIBXML_NONET);
450        } catch (DOMException|ValueError) {
451            return null;
452        } finally {
453            libxml_clear_errors();
454            libxml_use_internal_errors($internalErrors);
455        }
456    }
457
458    private static function copy(Element $source, XMLDocument $document): Element
459    {
460        $target = self::create($source, $document);
461
462        foreach ($source->childNodes as $child) {
463            if ($child instanceof Text) {
464                // Also CDATA sections, which are written back as escaped text.
465                $target->append($document->createTextNode($child->data));
466
467                continue;
468            }
469
470            if (self::isLegalComment($child)) {
471                $target->append($document->createComment($child->data));
472
473                continue;
474            }
475
476            // Inside inline SVG, `title` and `desc` switch the HTML parser back
477            // to HTML; text only leaves nothing it could read differently.
478            if (
479                !$child instanceof Element
480                || in_array($source->localName, self::TEXT_ONLY, true)
481                || !self::allowed($child)
482            ) {
483                continue;
484            }
485
486            $copy = $child->localName === 'style'
487                ? self::stylesheet($child, $document)
488                : self::copy($child, $document);
489
490            if ($copy !== null) {
491                $target->append($copy);
492            }
493        }
494
495        return $target;
496    }
497
498    /**
499     * Legal comments, `<!--! â€¦ -->`, carry copyright and license notices,
500     * and licenses such as CC BY may require them to stay with the file;
501     * Font Awesome writes its notice this way, and SVGO keeps such comments
502     * for that reason. Removing them could breach the license, so they are
503     * the only comments kept.
504     *
505     * A comment is inert in an SVG file. Inlined into HTML, one that starts
506     * with `!` and contains no `--` cannot be closed early by the HTML
507     * parser either. The XML parser already rejects `--` in comments; the
508     * explicit check keeps that guarantee visible. Inline markup drops all
509     * comments anyway, see confine().
510     */
511    /** @phpstan-assert-if-true Comment $node */
512    private static function isLegalComment(Node $node): bool
513    {
514        return $node instanceof Comment && str_starts_with($node->data, '!') && !str_contains($node->data, '--');
515    }
516
517    /** The copy holds only the root element, so notices around it move inside. */
518    private static function moveLegalComments(XMLDocument $source, XMLDocument $document, Element $copy): void
519    {
520        $comments = [];
521
522        foreach ($source->childNodes as $node) {
523            if ($node === $source->documentElement) {
524                $copy->prepend(...$comments);
525                $comments = [];
526            } elseif (self::isLegalComment($node)) {
527                $comments[] = $document->createComment($node->data);
528            }
529        }
530
531        $copy->append(...$comments);
532    }
533
534    /**
535     * Inline SVG shares the page's CSS and layout: a `<style>` element
536     * applies to the whole page, and the root could be moved, enlarged or
537     * filtered over surrounding content. Drawing inside its own box stays.
538     * Comments, legal ones included, have no use there and are removed.
539     */
540    private static function confine(XMLDocument $document, Element $root): void
541    {
542        foreach (self::ESCAPING as $name) {
543            $root->removeAttributeNS(null, $name);
544        }
545
546        foreach (iterator_to_array(new XPath($document)->query('//comment()')) as $comment) {
547            $comment->parentNode?->removeChild($comment);
548        }
549
550        $id = $root->getAttributeNS(null, 'id');
551
552        // The collection is live; removing while iterating it skips elements.
553        foreach (iterator_to_array($root->getElementsByTagNameNS(self::SVG, '*')) as $element) {
554            if ($element->localName === 'style' || self::animatesRoot($element, $root, $id)) {
555                $element->remove();
556            }
557        }
558    }
559
560    /** Animations target their parent, or the element their link points to. */
561    private static function animatesRoot(Element $element, Element $root, ?string $id): bool
562    {
563        if (!in_array($element->localName, self::ANIMATIONS, true)) {
564            return false;
565        }
566
567        $href = $element->getAttributeNS(null, 'href') ?? $element->getAttributeNS(self::XLINK, 'href');
568
569        return $href === null ? $element->parentNode === $root : $id !== null && $href === "#{$id}";
570    }
571
572    /** The element and its allowed attributes, without children. */
573    private static function create(Element $source, XMLDocument $document): Element
574    {
575        $target = $document->createElementNS(self::SVG, $source->localName);
576
577        foreach ($source->attributes as $attr) {
578            self::attribute($attr, $target);
579        }
580
581        return $target;
582    }
583
584    private static function allowed(Element $element): bool
585    {
586        return (
587            $element->namespaceURI === self::SVG
588                && in_array($element->localName, self::ELEMENTS, true)
589                && self::animates($element)
590        );
591    }
592
593    /**
594     * An animation may only target attributes that are kept anyway and
595     * whose values are checked on the element itself; its `values`, `from`,
596     * `to` and `by` go through the CSS check. A prefixed name like
597     * `xlink:href` is not in the list.
598     */
599    private static function animates(Element $element): bool
600    {
601        if (
602            !in_array($element->localName, self::ANIMATIONS, true)
603            || !$element->hasAttributeNS(null, 'attributeName')
604        ) {
605            return true;
606        }
607
608        $name = trim((string) $element->getAttributeNS(null, 'attributeName'));
609
610        return in_array($name, self::ATTRIBUTES, true) && !in_array($name, self::UNANIMATABLE, true);
611    }
612
613    private static function attribute(Attr $attr, Element $target): void
614    {
615        $namespace = $attr->namespaceURI;
616        $name = $attr->localName;
617        $value = $attr->value;
618
619        if ($name === 'href' && ($namespace === null || $namespace === self::XLINK)) {
620            $url = self::link($target->localName, $value);
621
622            if ($url !== null) {
623                $target->setAttributeNS($namespace, $namespace === null ? 'href' : 'xlink:href', $url);
624            }
625
626            return;
627        }
628
629        if ($namespace === self::XML && ($name === 'space' || $name === 'lang')) {
630            $target->setAttributeNS(self::XML, "xml:{$name}", $value);
631
632            return;
633        }
634
635        if ($namespace !== null) {
636            return;
637        }
638
639        if (in_array($name, self::PLAIN, true) || str_starts_with($name, 'aria-')) {
640            $target->setAttributeNS(null, $name, $value);
641
642            return;
643        }
644
645        if (in_array($name, self::ATTRIBUTES, true) && self::css($value)) {
646            $target->setAttributeNS(null, $name, $value);
647        }
648    }
649
650    /**
651     * Links point into the document, except for `<a>`, which may also
652     * navigate to web and mail addresses, and `<image>`, which may embed a
653     * raster image. The value is compared and written without the
654     * whitespace and control characters browsers ignore in URLs.
655     */
656    private static function link(string $element, string $value): ?string
657    {
658        $url = self::compact($value);
659
660        $allowed = match ($element) {
661            'a' => str_starts_with($url, '#') || preg_match('~^(?:https?|mailto):~i', $url) === 1,
662            'image' => preg_match(self::IMAGE_DATA, $url) === 1,
663            default => str_starts_with($url, '#'),
664        };
665
666        return $allowed ? $url : null;
667    }
668
669    /** A `<style>` element is kept only as a whole, with its text as the only child. */
670    private static function stylesheet(Element $source, XMLDocument $document): ?Element
671    {
672        $type = $source->getAttributeNS(null, 'type');
673        $css = (string) $source->textContent;
674
675        if ($type !== null && strtolower(trim($type)) !== 'text/css' || !self::css($css)) {
676            return null;
677        }
678
679        $target = self::create($source, $document);
680        $target->append($document->createTextNode($css));
681
682        return $target;
683    }
684
685    /**
686     * Accepts a stylesheet, declaration list or CSS value when it cannot
687     * load anything: only harmless functions and at-rules, and `url()`
688     * only for local references and embedded raster images or fonts.
689     * Strings and comments are not parsed; text in them that looks like a
690     * function or at-rule leads to a rejection, which errs on the safe side.
691     */
692    private static function css(string $css): bool
693    {
694        // Without backslashes CSS has no escapes, so the plain-text checks
695        // below see the same function and at-rule names a browser sees.
696        if (str_contains($css, '\\')) {
697            return false;
698        }
699
700        $css = preg_replace('~/\*.*?\*/~s', ' ', $css);
701
702        if ($css === null || str_contains($css, '/*')) {
703            return false;
704        }
705
706        preg_match_all('~@([\w-]+)~', $css, $atRules);
707
708        foreach ($atRules[1] as $atRule) {
709            if (!in_array(strtolower($atRule), self::AT_RULES, true)) {
710                return false;
711            }
712        }
713
714        preg_match_all('~([\w\x80-\xff-]+)\(~', $css, $functions);
715
716        foreach ($functions[1] as $function) {
717            if (!in_array(strtolower($function), self::FUNCTIONS, true)) {
718                return false;
719            }
720        }
721
722        $found = preg_match_all(
723            '~url\(\s*(?:"([^"]*)"|\'([^\']*)\'|([^"\'()\s]*))\s*\)~i',
724            $css,
725            $urls,
726            PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL,
727        );
728
729        // Every `url(` must be one of the well-formed calls checked here.
730        if ($found !== substr_count(strtolower($css), 'url(')) {
731            return false;
732        }
733
734        foreach ($urls as $url) {
735            $target = self::compact((string) ($url[1] ?? $url[2] ?? $url[3]));
736
737            if (!str_starts_with($target, '#') && preg_match(self::CSS_DATA, $target) !== 1) {
738                return false;
739            }
740        }
741
742        return true;
743    }
744
745    private static function compact(string $value): string
746    {
747        return (string) preg_replace('~[\x00-\x20]+~', '', $value);
748    }
749}