Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402] [Versions 402 and 403]

   1  <?php
   2  
   3  namespace PhpOffice\PhpSpreadsheet\Reader\Security;
   4  
   5  use PhpOffice\PhpSpreadsheet\Reader;
   6  
   7  class XmlScanner
   8  {
   9      /**
  10       * String used to identify risky xml elements.
  11       *
  12       * @var string
  13       */
  14      private $pattern;
  15  
  16      /** @var ?callable */
  17      private $callback;
  18  
  19      /** @var ?bool */
  20      private static $libxmlDisableEntityLoaderValue;
  21  
  22      /**
  23       * @var bool
  24       */
  25      private static $shutdownRegistered = false;
  26  
  27      public function __construct(string $pattern = '<!DOCTYPE')
  28      {
  29          $this->pattern = $pattern;
  30  
  31          $this->disableEntityLoaderCheck();
  32  
  33          // A fatal error will bypass the destructor, so we register a shutdown here
  34          if (!self::$shutdownRegistered) {
  35              self::$shutdownRegistered = true;
  36              register_shutdown_function([__CLASS__, 'shutdown']);
  37          }
  38      }
  39  
  40      public static function getInstance(Reader\IReader $reader): self
  41      {
  42          $pattern = ($reader instanceof Reader\Html) ? '<!ENTITY' : '<!DOCTYPE';
  43  
  44          return new self($pattern);
  45      }
  46  
  47      /**
  48       * @codeCoverageIgnore
  49       */
  50      public static function threadSafeLibxmlDisableEntityLoaderAvailability(): bool
  51      {
  52          if (PHP_MAJOR_VERSION === 7) {
  53              switch (PHP_MINOR_VERSION) {
  54                  case 2:
  55                      return PHP_RELEASE_VERSION >= 1;
  56                  case 1:
  57                      return PHP_RELEASE_VERSION >= 13;
  58                  case 0:
  59                      return PHP_RELEASE_VERSION >= 27;
  60              }
  61  
  62              return true;
  63          }
  64  
  65          return false;
  66      }
  67  
  68      /**
  69       * @codeCoverageIgnore
  70       */
  71      private function disableEntityLoaderCheck(): void
  72      {
  73          if (\PHP_VERSION_ID < 80000) {
  74              $libxmlDisableEntityLoaderValue = libxml_disable_entity_loader(true);
  75  
  76              if (self::$libxmlDisableEntityLoaderValue === null) {
  77                  self::$libxmlDisableEntityLoaderValue = $libxmlDisableEntityLoaderValue;
  78              }
  79          }
  80      }
  81  
  82      /**
  83       * @codeCoverageIgnore
  84       */
  85      public static function shutdown(): void
  86      {
  87          if (self::$libxmlDisableEntityLoaderValue !== null && \PHP_VERSION_ID < 80000) {
  88              libxml_disable_entity_loader(self::$libxmlDisableEntityLoaderValue);
  89              self::$libxmlDisableEntityLoaderValue = null;
  90          }
  91      }
  92  
  93      public function __destruct()
  94      {
  95          self::shutdown();
  96      }
  97  
  98      public function setAdditionalCallback(callable $callback): void
  99      {
 100          $this->callback = $callback;
 101      }
 102  
 103      /** @param mixed $arg */
 104      private static function forceString($arg): string
 105      {
 106          return is_string($arg) ? $arg : '';
 107      }
 108  
 109      /**
 110       * @param string $xml
 111       *
 112       * @return string
 113       */
 114      private function toUtf8($xml)
 115      {
 116          $pattern = '/encoding="(.*?)"/';
 117          $result = preg_match($pattern, $xml, $matches);
 118          $charset = strtoupper($result ? $matches[1] : 'UTF-8');
 119  
 120          if ($charset !== 'UTF-8') {
 121              $xml = self::forceString(mb_convert_encoding($xml, 'UTF-8', $charset));
 122  
 123              $result = preg_match($pattern, $xml, $matches);
 124              $charset = strtoupper($result ? $matches[1] : 'UTF-8');
 125              if ($charset !== 'UTF-8') {
 126                  throw new Reader\Exception('Suspicious Double-encoded XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
 127              }
 128          }
 129  
 130          return $xml;
 131      }
 132  
 133      /**
 134       * Scan the XML for use of <!ENTITY to prevent XXE/XEE attacks.
 135       *
 136       * @param false|string $xml
 137       *
 138       * @return string
 139       */
 140      public function scan($xml)
 141      {
 142          $xml = "$xml";
 143          $this->disableEntityLoaderCheck();
 144  
 145          $xml = $this->toUtf8($xml);
 146  
 147          // Don't rely purely on libxml_disable_entity_loader()
 148          $pattern = '/\\0?' . implode('\\0?', /** @scrutinizer ignore-type */ str_split($this->pattern)) . '\\0?/';
 149  
 150          if (preg_match($pattern, $xml)) {
 151              throw new Reader\Exception('Detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
 152          }
 153  
 154          if ($this->callback !== null && is_callable($this->callback)) {
 155              $xml = call_user_func($this->callback, $xml);
 156          }
 157  
 158          return $xml;
 159      }
 160  
 161      /**
 162       * Scan theXML for use of <!ENTITY to prevent XXE/XEE attacks.
 163       *
 164       * @param string $filestream
 165       *
 166       * @return string
 167       */
 168      public function scanFile($filestream)
 169      {
 170          return $this->scan(file_get_contents($filestream));
 171      }
 172  }