PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
4.2.15 4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 All 136 releases
optimole-wp / vendor / enshrined / svg-sanitize / src / Sanitizer.php

Sanitizer.php in Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization trunk, at vendor/enshrined/svg-sanitize/src/Sanitizer.php

998 lines 32.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace enshrined\svgSanitize;
3
4 use enshrined\svgSanitize\data\AllowedAttributes;
5 use enshrined\svgSanitize\data\AllowedTags;
6 use enshrined\svgSanitize\data\AttributeInterface;
7 use enshrined\svgSanitize\data\TagInterface;
8 use enshrined\svgSanitize\data\XPath;
9 use enshrined\svgSanitize\ElementReference\Resolver;
10
11 /**
12 * Class Sanitizer
13 *
14 * @package enshrined\svgSanitize
15 */
16 class Sanitizer
17 {
18
19 /**
20 * @var \DOMDocument
21 */
22 protected $xmlDocument;
23
24 /**
25 * @var array
26 */
27 protected $allowedTags;
28
29 /**
30 * @var array
31 */
32 protected $allowedAttrs;
33
34 /**
35 * @var
36 */
37 protected $xmlLoaderValue;
38
39 /**
40 * @var bool
41 */
42 protected $xmlErrorHandlerPreviousValue;
43
44 /**
45 * @var bool
46 */
47 protected $minifyXML = false;
48
49 /**
50 * @var bool
51 */
52 protected $removeRemoteReferences = false;
53
54 /**
55 * @var int
56 */
57 protected $useThreshold = 1000;
58
59 /**
60 * @var bool
61 */
62 protected $removeXMLTag = false;
63
64 /**
65 * @var int
66 */
67 protected $xmlOptions = LIBXML_NOEMPTYTAG;
68
69 /**
70 * @var array
71 */
72 protected $xmlIssues = array();
73
74 /**
75 * @var Resolver
76 */
77 protected $elementReferenceResolver;
78
79 /**
80 * @var int
81 */
82 protected $useNestingLimit = 15;
83
84 /**
85 * @var bool
86 */
87 protected $allowHugeFiles = false;
88
89 /**
90 *
91 */
92 function __construct()
93 {
94 // Load default tags/attributes
95 $this->allowedAttrs = array_map('strtolower', AllowedAttributes::getAttributes());
96 $this->allowedTags = array_map('strtolower', AllowedTags::getTags());
97 }
98
99 /**
100 * Set up the DOMDocument
101 */
102 protected function resetInternal()
103 {
104 $this->xmlDocument = new \DOMDocument();
105 $this->xmlDocument->preserveWhiteSpace = false;
106 $this->xmlDocument->strictErrorChecking = false;
107 $this->xmlDocument->formatOutput = !$this->minifyXML;
108 }
109
110 /**
111 * Set XML options to use when saving XML
112 * See: DOMDocument::saveXML
113 *
114 * @param int $xmlOptions
115 */
116 public function setXMLOptions($xmlOptions)
117 {
118 $this->xmlOptions = $xmlOptions;
119 }
120
121 /**
122 * Get XML options to use when saving XML
123 * See: DOMDocument::saveXML
124 *
125 * @return int
126 */
127 public function getXMLOptions()
128 {
129 return $this->xmlOptions;
130 }
131
132 /**
133 * Get the array of allowed tags
134 *
135 * @return array
136 */
137 public function getAllowedTags()
138 {
139 return $this->allowedTags;
140 }
141
142 /**
143 * Set custom allowed tags
144 *
145 * @param TagInterface $allowedTags
146 */
147 public function setAllowedTags(TagInterface $allowedTags)
148 {
149 $this->allowedTags = array_map('strtolower', $allowedTags::getTags());
150 }
151
152 /**
153 * Get the array of allowed attributes
154 *
155 * @return array
156 */
157 public function getAllowedAttrs()
158 {
159 return $this->allowedAttrs;
160 }
161
162 /**
163 * Set custom allowed attributes
164 *
165 * @param AttributeInterface $allowedAttrs
166 */
167 public function setAllowedAttrs(AttributeInterface $allowedAttrs)
168 {
169 $this->allowedAttrs = array_map('strtolower', $allowedAttrs::getAttributes());
170 }
171
172 /**
173 * Should we remove references to remote files?
174 *
175 * @param bool $removeRemoteRefs
176 */
177 public function removeRemoteReferences($removeRemoteRefs = false)
178 {
179 $this->removeRemoteReferences = $removeRemoteRefs;
180 }
181
182 /**
183 * Get XML issues.
184 *
185 * @return array
186 */
187 public function getXmlIssues() {
188 return $this->xmlIssues;
189 }
190
191 /**
192 * Can we allow huge files?
193 *
194 * @return bool
195 */
196 public function getAllowHugeFiles() {
197 return $this->allowHugeFiles;
198 }
199
200 /**
201 * Set whether we can allow huge files.
202 *
203 * @param bool $allowHugeFiles
204 */
205 public function setAllowHugeFiles( $allowHugeFiles ) {
206 $this->allowHugeFiles = $allowHugeFiles;
207 }
208
209
210 /**
211 * Sanitize the passed string
212 *
213 * @param string $dirty
214 * @return string|false
215 */
216 public function sanitize($dirty)
217 {
218 // Don't run on an empty string
219 if (empty($dirty)) {
220 return '';
221 }
222
223 do {
224 /*
225 * recursively remove php tags because they can be hidden inside tags
226 * i.e. <?p<?php test?>hp echo . ' danger! ';?>
227 */
228 $dirty = preg_replace('/<\?(=|php)(.+?)\?>/i', '', $dirty);
229 } while (preg_match('/<\?(=|php)(.+?)\?>/i', $dirty) != 0);
230
231 // Strip any DOCTYPE/DTD before parsing. This prevents custom entity
232 // definitions (which can collide with HTML5 named character references)
233 // and DTD-defaulted attributes from ever reaching libxml.
234 $dirty = $this->removeDoctype($dirty);
235
236 $this->resetInternal();
237 $this->setUpBefore();
238
239 $loaded = $this->xmlDocument->loadXML($dirty, $this->getAllowHugeFiles() ? LIBXML_PARSEHUGE : 0);
240
241 // If we couldn't parse the XML then we go no further. Reset and return false
242 if (!$loaded) {
243 $this->xmlIssues = self::getXmlErrors();
244 $this->resetAfter();
245 return false;
246 }
247
248 // Pre-process all identified elements
249 $xPath = new XPath($this->xmlDocument);
250 $this->elementReferenceResolver = new Resolver($xPath, $this->useNestingLimit);
251 $this->elementReferenceResolver->collect();
252 $elementsToRemove = $this->elementReferenceResolver->getElementsToRemove();
253
254 // Start the cleaning process
255 $this->startClean($this->xmlDocument->childNodes, $elementsToRemove);
256
257 // Save cleaned XML to a variable
258 if ($this->removeXMLTag) {
259 $clean = $this->xmlDocument->saveXML($this->xmlDocument->documentElement, $this->xmlOptions);
260 } else {
261 $clean = $this->xmlDocument->saveXML($this->xmlDocument, $this->xmlOptions);
262 }
263
264 $this->resetAfter();
265
266 // Remove any extra whitespaces when minifying
267 if ($this->minifyXML) {
268 $clean = preg_replace('/\s+/', ' ', $clean);
269 }
270
271 // Return result
272 return $clean;
273 }
274
275 /**
276 * Remove any DOCTYPE declaration (and its internal subset) from the input
277 * string before it reaches the XML parser.
278 *
279 * The internal subset is scanned with balanced brackets so that a `>`
280 * appearing inside an entity value cannot prematurely terminate the match.
281 *
282 * @param string $dirty
283 * @return string
284 */
285 protected function removeDoctype($dirty)
286 {
287 if (stripos($dirty, '<!DOCTYPE') === false) {
288 return $dirty;
289 }
290
291 $output = '';
292 $offset = 0;
293 $length = strlen($dirty);
294
295 while (($start = stripos($dirty, '<!DOCTYPE', $offset)) !== false) {
296 $output .= substr($dirty, $offset, $start - $offset);
297 $i = $start + strlen('<!DOCTYPE');
298 $depth = 0;
299 for (; $i < $length; $i++) {
300 $char = $dirty[$i];
301
302 // A '[', ']' or '>' inside a DTD comment is not a real internal-subset
303 // delimiter and must not affect the bracket depth.
304 if ($char === '<' && substr($dirty, $i, 4) === '<!--') {
305 $commentEnd = strpos($dirty, '-->', $i + 4);
306 if ($commentEnd === false) {
307 $i = $length;
308 break;
309 }
310 $i = $commentEnd + 2;
311 continue;
312 }
313
314 // Likewise for a '[', ']' or '>' inside a quoted string.
315 if ($char === '"' || $char === "'") {
316 $stringEnd = strpos($dirty, $char, $i + 1);
317 if ($stringEnd === false) {
318 $i = $length;
319 break;
320 }
321 $i = $stringEnd;
322 continue;
323 }
324
325 if ($char === '[') {
326 $depth++;
327 } elseif ($char === ']') {
328 if ($depth > 0) {
329 $depth--;
330 }
331 } elseif ($char === '>' && $depth === 0) {
332 $i++;
333 break;
334 }
335 }
336 $offset = $i;
337 }
338
339 return $output . substr($dirty, $offset);
340 }
341
342 /**
343 * Set up libXML before we start
344 */
345 protected function setUpBefore()
346 {
347 // This function has been deprecated in PHP 8.0 because in libxml 2.9.0, external entity loading is
348 // disabled by default, so this function is no longer needed to protect against XXE attacks.
349 if (\LIBXML_VERSION < 20900 && \function_exists('libxml_disable_entity_loader')) {
350 // Turn off the entity loader
351 $this->xmlLoaderValue = libxml_disable_entity_loader(true);
352 }
353
354 // Suppress the errors because we don't really have to worry about formation before cleansing.
355 // See reset in resetAfter().
356 $this->xmlErrorHandlerPreviousValue = libxml_use_internal_errors(true);
357
358 // Reset array of altered XML
359 $this->xmlIssues = array();
360 }
361
362 /**
363 * Reset the class after use
364 */
365 protected function resetAfter()
366 {
367 // This function has been deprecated in PHP 8.0 because in libxml 2.9.0, external entity loading is
368 // disabled by default, so this function is no longer needed to protect against XXE attacks.
369 if (\LIBXML_VERSION < 20900 && \function_exists('libxml_disable_entity_loader')) {
370 // Reset the entity loader
371 libxml_disable_entity_loader($this->xmlLoaderValue);
372 }
373
374 libxml_clear_errors();
375 libxml_use_internal_errors($this->xmlErrorHandlerPreviousValue);
376 }
377
378 /**
379 * Start the cleaning with tags, then we move onto attributes and hrefs later
380 *
381 * @param \DOMNodeList $elements
382 * @param array $elementsToRemove
383 */
384 protected function startClean(\DOMNodeList $elements, array $elementsToRemove)
385 {
386 // Iterate over a static snapshot of the list. Calling item($i) on a
387 // live \DOMNodeList while stepping backwards re-walks the underlying
388 // linked list from the start on every call, which makes this loop
389 // O(n²) in the number of child nodes. The snapshot also guarantees
390 // we don't skip any sibling when we delete a node.
391 $currentElements = iterator_to_array($elements, false);
392
393 for ($i = count($currentElements) - 1; $i >= 0; $i--) {
394 /** @var \DOMElement $currentElement */
395 $currentElement = $currentElements[$i];
396
397 /**
398 * If the element has exceeded the nesting limit, we should remove it.
399 *
400 * As it's only <use> elements that cause us issues with nesting DOS attacks
401 * we should check what the element is before removing it. For now we'll only
402 * remove <use> elements.
403 */
404 if (in_array($currentElement, $elementsToRemove) && 'use' === $currentElement->nodeName) {
405 $currentElement->parentNode->removeChild($currentElement);
406 $this->xmlIssues[] = array(
407 'message' => 'Invalid \'' . $currentElement->tagName . '\'',
408 'line' => $currentElement->getLineNo(),
409 );
410 continue;
411 }
412
413 if ($currentElement instanceof \DOMElement) {
414 // If the tag isn't in the whitelist, remove it and continue with next iteration
415 if (!in_array(strtolower($currentElement->tagName), $this->allowedTags)) {
416 $currentElement->parentNode->removeChild($currentElement);
417 $this->xmlIssues[] = array(
418 'message' => 'Suspicious tag \'' . $currentElement->tagName . '\'',
419 'line' => $currentElement->getLineNo(),
420 );
421 continue;
422 }
423
424 // Strip remote @import / url() references from inline <style> text.
425 // The text content of <style> is never otherwise inspected, so remote
426 // CSS references would pass straight through.
427 if (strtolower($currentElement->tagName) === 'style' && $this->removeRemoteReferences) {
428 $currentElement->textContent = $this->stripRemoteCssReferences($currentElement->textContent);
429 }
430
431 $this->cleanHrefs( $currentElement );
432
433 $this->cleanXlinkHrefs( $currentElement );
434
435 $this->cleanAttributesOnWhitelist($currentElement);
436
437 if (strtolower($currentElement->tagName) === 'use') {
438 if ($this->isUseTagDirty($currentElement)
439 || $this->isUseTagExceedingThreshold($currentElement)
440 ) {
441 $currentElement->parentNode->removeChild($currentElement);
442 $this->xmlIssues[] = array(
443 'message' => 'Suspicious \'' . $currentElement->tagName . '\'',
444 'line' => $currentElement->getLineNo(),
445 );
446 continue;
447 }
448 }
449
450 // Strip out font elements that will break out of foreign content.
451 if (strtolower($currentElement->tagName) === 'font') {
452 $breaksOutOfForeignContent = false;
453 foreach ($currentElement->attributes as $attribute) {
454 if (in_array(strtolower($attribute->nodeName), ['face', 'color', 'size'])) {
455 $breaksOutOfForeignContent = true;
456 break;
457 }
458 }
459
460 if ($breaksOutOfForeignContent) {
461 $currentElement->parentNode->removeChild($currentElement);
462 $this->xmlIssues[] = array(
463 'message' => 'Suspicious tag \'' . $currentElement->tagName . '\'',
464 'line' => $currentElement->getLineNo(),
465 );
466 continue;
467 }
468 }
469 }
470
471 $this->cleanUnsafeNodes($currentElement);
472
473 if ($currentElement->hasChildNodes()) {
474 $this->startClean($currentElement->childNodes, $elementsToRemove);
475 }
476 }
477 }
478
479 /**
480 * Only allow attributes that are on the whitelist
481 *
482 * @param \DOMElement $element
483 */
484 protected function cleanAttributesOnWhitelist(\DOMElement $element)
485 {
486 // Work on a static snapshot: stepping backwards through the live
487 // \DOMNamedNodeMap via item($x) is O(n²) in the number of attributes.
488 $attributes = iterator_to_array($element->attributes, false);
489
490 for ($x = count($attributes) - 1; $x >= 0; $x--) {
491 // get attribute name
492 $attrName = $attributes[$x]->nodeName;
493
494 // Remove attribute if not in whitelist
495 if (!in_array(strtolower($attrName), $this->allowedAttrs) && !$this->isAriaAttribute(strtolower($attrName)) && !$this->isDataAttribute(strtolower($attrName))) {
496
497 $element->removeAttribute($attrName);
498 $this->xmlIssues[] = array(
499 'message' => 'Suspicious attribute \'' . $attrName . '\'',
500 'line' => $element->getLineNo(),
501 );
502
503 // Once removed, skip the remaining checks for this attribute so the
504 // same name can never be passed to removeAttribute() twice in one
505 // iteration (a DTD-defaulted attribute could otherwise re-materialise).
506 continue;
507 }
508
509 /**
510 * This is used for when a namespace isn't imported properly.
511 * Such as xlink:href when the xlink namespace isn't imported.
512 * We have to do this as the link is still ran in this case.
513 */
514 if (false !== stripos($attrName, 'href')) {
515 $href = $element->getAttribute($attrName);
516 if (false === $this->isHrefSafeValue($href)) {
517 $element->removeAttribute($attrName);
518 $this->xmlIssues[] = array(
519 'message' => 'Suspicious attribute \'href\'',
520 'line' => $element->getLineNo(),
521 );
522 continue;
523 }
524 }
525
526 // Do we want to strip remote references?
527 if($this->removeRemoteReferences) {
528 $attr = $element->attributes->item($x);
529 $value = ($attr !== null && isset($attr->value)) ? $attr->value : '';
530
531 // A remote url()/@import reference, or a value that is itself a remote
532 // URL (e.g. a bare href/src).
533 $isRemote = $this->hasRemoteReference($value) || $this->isRemoteUrl($value);
534
535 // The style attribute is CSS, so resolve escapes/comments and reuse the
536 // same remote-token detection used for <style> elements (this also
537 // catches image-set() and escape-obfuscated references).
538 if (!$isRemote && strtolower($attrName) === 'style') {
539 $normalized = $this->normalizeCss($value);
540 $isRemote = $this->stripRemoteCssTokens($normalized) !== $normalized;
541 }
542
543 // Remove attribute if it has a remote reference
544 if ($isRemote) {
545 $element->removeAttribute($attrName);
546 $this->xmlIssues[] = array(
547 'message' => 'Suspicious attribute \'' . $attrName . '\'',
548 'line' => $element->getLineNo(),
549 );
550 }
551 }
552 }
553 }
554
555 /**
556 * Clean the xlink:hrefs of script and data embeds
557 *
558 * @param \DOMElement $element
559 */
560 protected function cleanXlinkHrefs(\DOMElement $element)
561 {
562 foreach ($element->attributes as $attribute) {
563 // remove attributes with unexpected namespace prefix, e.g. `XLinK:href` (instead of `xlink:href`)
564 if ($attribute->prefix === '' && strtolower($attribute->nodeName) === 'xlink:href') {
565 $element->removeAttribute($attribute->nodeName);
566 $this->xmlIssues[] = array(
567 'message' => sprintf('Unexpected attribute \'%s\'', $attribute->nodeName),
568 'line' => $element->getLineNo(),
569 );
570 }
571 }
572 $this->cleanHrefAttributes($element, 'xlink');
573 }
574
575 /**
576 * Clean the hrefs of script and data embeds
577 *
578 * @param \DOMElement $element
579 */
580 protected function cleanHrefs(\DOMElement $element)
581 {
582 $this->cleanHrefAttributes($element);
583 }
584
585 protected function cleanHrefAttributes(\DOMElement $element, string $prefix = ''): void
586 {
587 $relevantAttributes = array_filter(
588 iterator_to_array($element->attributes, false),
589 static function (\DOMAttr $attr) use ($prefix) {
590 return strtolower($attr->name) === 'href' && strtolower($attr->prefix) === $prefix;
591 }
592 );
593 foreach ($relevantAttributes as $attribute) {
594 if (!$this->isHrefSafeValue($attribute->value)) {
595 $element->removeAttribute($attribute->nodeName);
596 $this->xmlIssues[] = array(
597 'message' => sprintf('Suspicious attribute \'%s\'', $attribute->nodeName),
598 'line' => $element->getLineNo(),
599 );
600 continue;
601 }
602 // in case the attribute name is `HrEf`/`xlink:HrEf`, adjust it to `href`/`xlink:href`
603 if (!in_array($attribute->nodeName, $this->allowedAttrs, true)
604 && in_array(strtolower($attribute->nodeName), $this->allowedAttrs, true)
605 ) {
606 $element->removeAttribute($attribute->nodeName);
607 $element->setAttribute(strtolower($attribute->nodeName), $attribute->value);
608 }
609 }
610 }
611
612 /**
613 * Only allow whitelisted starts to be within the href.
614 *
615 * This will stop scripts etc from being passed through, with or without attempting to hide bypasses.
616 * This stops the need for us to use a complicated script regex.
617 *
618 * @param $value
619 * @return bool
620 */
621 protected function isHrefSafeValue($value) {
622
623 // Allow empty values
624 if (empty($value)) {
625 return true;
626 }
627
628 // Allow fragment identifiers.
629 if ('#' === substr($value, 0, 1)) {
630 return true;
631 }
632
633 // Allow relative URIs.
634 if ('/' === substr($value, 0, 1)) {
635 return true;
636 }
637
638 // Allow HTTPS domains.
639 if ('https://' === substr($value, 0, 8)) {
640 return true;
641 }
642
643 // Allow HTTP domains.
644 if ('http://' === substr($value, 0, 7)) {
645 return true;
646 }
647
648 // Allow known data URIs.
649 if (in_array(substr($value, 0, 14), array(
650 'data:image/png', // PNG
651 'data:image/gif', // GIF
652 'data:image/jpg', // JPG
653 'data:image/jpe', // JPEG
654 'data:image/pjp', // PJPEG
655 ))) {
656 return true;
657 }
658
659 // Allow known short data URIs.
660 if (in_array(substr($value, 0, 12), array(
661 'data:img/png', // PNG
662 'data:img/gif', // GIF
663 'data:img/jpg', // JPG
664 'data:img/jpe', // JPEG
665 'data:img/pjp', // PJPEG
666 ))) {
667 return true;
668 }
669
670 return false;
671 }
672
673 /**
674 * Removes non-printable ASCII characters from string & trims it
675 *
676 * @param string $value
677 * @return bool
678 */
679 protected function removeNonPrintableCharacters($value)
680 {
681 return trim(preg_replace('/[^ -~]/xu','',$value));
682 }
683
684 /**
685 * Does this attribute value embed a remote reference anywhere within it?
686 *
687 * Detects a remote `url(...)` or remote `@import` regardless of quoting and
688 * regardless of where it appears in the value (e.g. amongst other CSS
689 * declarations in a `style` attribute). Only remote targets are flagged, so
690 * local (`/x`) and fragment (`#x`) references are preserved.
691 *
692 * @param $value
693 * @return bool
694 */
695 protected function hasRemoteReference($value)
696 {
697 $value = $this->removeNonPrintableCharacters($value);
698
699 if (preg_match('~url\(\s*[\'"]?\s*((?:https?|ftp|file):)?//~xi', $value)) {
700 return true;
701 }
702
703 if (preg_match('~@import\s+(?:url\(\s*)?[\'"]?\s*((?:https?|ftp|file):)?//~xi', $value)) {
704 return true;
705 }
706
707 return false;
708 }
709
710 /**
711 * Is the value itself a remote URL (a bare href/src rather than a url() wrapper)?
712 *
713 * Flags absolute (`http(s)`/`ftp`/`file`) and protocol-relative (`//`) URLs while
714 * leaving local (`/x`) and fragment (`#x`) references untouched.
715 *
716 * @param $value
717 * @return bool
718 */
719 protected function isRemoteUrl($value)
720 {
721 $value = $this->removeNonPrintableCharacters($value);
722
723 return (bool) preg_match('~^\s*(?:(?:https?|ftp|file):)?//~i', $value);
724 }
725
726 /**
727 * Strip remote references (url(), @import, image-set()) from CSS text, used for
728 * inline <style> content when removeRemoteReferences is enabled.
729 *
730 * CSS escapes and comments are resolved first so obfuscated references (e.g.
731 * `\75 rl(` or `@\69 mport`) cannot hide from the token match. This remains
732 * best-effort: a regex-based stripper cannot see through every CSS construct
733 * (the bare-string forms of image()/src() are not handled, for instance), so
734 * untrusted CSS should still be isolated at the embedding boundary.
735 *
736 * When a block does contain a stripped remote reference, its CSS escapes are
737 * normalised (decoded) in the output; any benign escapes in that same block are
738 * rewritten to their decoded equivalents (semantically identical).
739 *
740 * @param string $css
741 * @return string
742 */
743 protected function stripRemoteCssReferences($css)
744 {
745 $normalized = $this->normalizeCss($css);
746 $strippedNormalized = $this->stripRemoteCssTokens($normalized);
747
748 // If decoding escapes/comments exposed a remote reference that the raw text
749 // hides, keep the normalized (and stripped) result. Otherwise strip the
750 // original in place, leaving legitimate escaped CSS untouched.
751 if ($strippedNormalized !== $normalized && $normalized !== $css) {
752 return $strippedNormalized;
753 }
754
755 return $this->stripRemoteCssTokens($css);
756 }
757
758 /**
759 * Remove the CSS constructs that can trigger a remote fetch.
760 *
761 * @param string $css
762 * @return string
763 */
764 protected function stripRemoteCssTokens($css)
765 {
766 // Terminate on ')' when present, or on the rule/line boundary ('}', CR, LF)
767 // or end of input otherwise. A CSS tokenizer closes an unclosed url()/
768 // function token implicitly and still fetches, so requiring a closing paren
769 // would let a value that omits it slip past.
770 $css = preg_replace('~url\(\s*[\'"]?\s*(?:(?:https?|ftp|file):)?//[^)}\r\n]*\)?~i', '', $css);
771 $css = preg_replace('~@import\b[^;]*;?~i', '', $css);
772 // image-set() accepts a bare remote string with no url() token of its own.
773 $css = preg_replace('~(?:-webkit-)?image-set\s*\([^)}\r\n]*[\'"]\s*(?:(?:https?|ftp|file):)?//[^)}\r\n]*\)?~i', '', $css);
774
775 return $css;
776 }
777
778 /**
779 * Resolve CSS escapes and comments so obfuscated tokens can be matched.
780 *
781 * @param string $css
782 * @return string
783 */
784 protected function normalizeCss($css)
785 {
786 $css = $this->decodeCssEscapes($css);
787 // Replace comments with a space so they can neither glue nor split tokens.
788 $css = preg_replace('~/\*.*?\*/~s', ' ', $css);
789
790 return $css;
791 }
792
793 /**
794 * Decode CSS escape sequences (`\XX` hex escapes and `\c` literal escapes)
795 * into the characters they represent.
796 *
797 * @param string $css
798 * @return string
799 */
800 protected function decodeCssEscapes($css)
801 {
802 return preg_replace_callback(
803 '~\\\\([0-9A-Fa-f]{1,6})[ \t\r\n\f]?|\\\\(.)~s',
804 function ($matches) {
805 if ($matches[1] !== '') {
806 $codepoint = hexdec($matches[1]);
807 if ($codepoint === 0 || $codepoint > 0x10FFFF) {
808 return "\xEF\xBF\xBD"; // U+FFFD replacement character
809 }
810 return $this->codepointToUtf8($codepoint);
811 }
812 return $matches[2];
813 },
814 $css
815 );
816 }
817
818 /**
819 * Encode a Unicode code point as a UTF-8 byte sequence (avoids an mbstring
820 * dependency, which the library does not otherwise require).
821 *
822 * @param int $codepoint
823 * @return string
824 */
825 protected function codepointToUtf8($codepoint)
826 {
827 if ($codepoint < 0x80) {
828 return chr($codepoint);
829 }
830 if ($codepoint < 0x800) {
831 return chr(0xC0 | ($codepoint >> 6))
832 . chr(0x80 | ($codepoint & 0x3F));
833 }
834 if ($codepoint < 0x10000) {
835 return chr(0xE0 | ($codepoint >> 12))
836 . chr(0x80 | (($codepoint >> 6) & 0x3F))
837 . chr(0x80 | ($codepoint & 0x3F));
838 }
839 return chr(0xF0 | ($codepoint >> 18))
840 . chr(0x80 | (($codepoint >> 12) & 0x3F))
841 . chr(0x80 | (($codepoint >> 6) & 0x3F))
842 . chr(0x80 | ($codepoint & 0x3F));
843 }
844
845 /**
846 * Should we minify the output?
847 *
848 * @param bool $shouldMinify
849 */
850 public function minify($shouldMinify = false)
851 {
852 $this->minifyXML = (bool) $shouldMinify;
853 }
854
855 /**
856 * Should we remove the XML tag in the header?
857 *
858 * @param bool $removeXMLTag
859 */
860 public function removeXMLTag($removeXMLTag = false)
861 {
862 $this->removeXMLTag = (bool) $removeXMLTag;
863 }
864
865 /**
866 * Whether `<use ... xlink:href="#identifier">` elements shall be
867 * removed in case expansion would exceed this threshold.
868 *
869 * @param int $useThreshold
870 */
871 public function useThreshold($useThreshold = 1000)
872 {
873 $this->useThreshold = (int)$useThreshold;
874 }
875
876 /**
877 * Check to see if an attribute is an aria attribute or not
878 *
879 * @param $attributeName
880 *
881 * @return bool
882 */
883 protected function isAriaAttribute($attributeName)
884 {
885 return strpos($attributeName, 'aria-') === 0;
886 }
887
888 /**
889 * Check to see if an attribute is an data attribute or not
890 *
891 * @param $attributeName
892 *
893 * @return bool
894 */
895 protected function isDataAttribute($attributeName)
896 {
897 return strpos($attributeName, 'data-') === 0;
898 }
899
900 /**
901 * Make sure our use tag is only referencing internal resources
902 *
903 * @param \DOMElement $element
904 * @return bool
905 */
906 protected function isUseTagDirty(\DOMElement $element)
907 {
908 $href = Helper::getElementHref($element);
909 return $href && strpos($href, '#') !== 0;
910 }
911
912 /**
913 * Determines whether `<use ... xlink:href="#identifier">` is expanded
914 * recursively in order to create DoS scenarios. The amount of a actually
915 * used element needs to be below `$this->useThreshold`.
916 *
917 * @param \DOMElement $element
918 * @return bool
919 */
920 protected function isUseTagExceedingThreshold(\DOMElement $element)
921 {
922 if ($this->useThreshold <= 0) {
923 return false;
924 }
925 $useId = Helper::extractIdReferenceFromHref(
926 Helper::getElementHref($element)
927 );
928 if ($useId === null) {
929 return false;
930 }
931 foreach ($this->elementReferenceResolver->findByElementId($useId) as $subject) {
932 if ($subject->countUse() >= $this->useThreshold) {
933 return true;
934 }
935 }
936 return false;
937 }
938
939 /**
940 * Set the nesting limit for <use> tags.
941 *
942 * @param $limit
943 */
944 public function setUseNestingLimit($limit)
945 {
946 $this->useNestingLimit = (int) $limit;
947 }
948
949 /**
950 * Remove nodes that are either invalid or malformed.
951 *
952 * @param \DOMNode $currentElement The current element.
953 */
954 protected function cleanUnsafeNodes(\DOMNode $currentElement) {
955 // Replace CDATA node with encoded text node
956 if ($currentElement instanceof \DOMCdataSection) {
957 $textNode = $currentElement->ownerDocument->createTextNode($currentElement->nodeValue);
958 $currentElement->parentNode->replaceChild($textNode, $currentElement);
959 // If the element doesn't have a tagname, remove it and continue with next iteration
960 } elseif (!$currentElement instanceof \DOMElement && !$currentElement instanceof \DOMText) {
961 $currentElement->parentNode->removeChild($currentElement);
962 $this->xmlIssues[] = array(
963 'message' => 'Suspicious node \'' . $currentElement->nodeName . '\'',
964 'line' => $currentElement->getLineNo(),
965 );
966 return;
967 }
968
969 if ($currentElement->hasChildNodes()) {
970 // Same as in startClean(): work on a static snapshot, stepping
971 // backwards through a live \DOMNodeList via item($j) is O(n²).
972 $childNodes = iterator_to_array($currentElement->childNodes, false);
973 for ($j = count($childNodes) - 1; $j >= 0; $j--) {
974 /** @var \DOMElement $childElement */
975 $childElement = $childNodes[$j];
976 $this->cleanUnsafeNodes($childElement);
977 }
978 }
979 }
980
981 /**
982 * Retrieve array of errors
983 * @return array
984 */
985 private static function getXmlErrors()
986 {
987 $errors = [];
988 foreach (libxml_get_errors() as $error) {
989 $errors[] = [
990 'message' => trim($error->message),
991 'line' => $error->line,
992 ];
993 }
994
995 return $errors;
996 }
997 }
998