CVE-2022-2400: External Control of File Name or Path (CWE-73)
value: External Control of File Name or Path in GitHub repository dompdf/dompdf prior to 2.0.0.
CWE description: The product allows user input to control or influence paths or file names that are used in filesystem operations.
CVSS v3 base score: 5.3
Published: 2022-07-18T15:15Z
Repository: https://github.com/dompdf/dompdf
Language: PHP
Files changed: src/Css/Stylesheet.php, src/Dompdf.php, src/FontMetrics.php, src/FrameDecorator/Image.php, src/Helpers.php, src/Image/Cache.php, src/Options.php, src/Renderer/AbstractRenderer.php, tests/Css/StyleTest.php, tests/HelpersTest.php, tests/OptionsTest.php
Commit date: 2022-06-14 10:35:24 -0400
Commit message: Update resource URI validation and handling

URI scheme (protocol) validation rules are now specified through the Options class. By default file and http(s) URIs are allowed and validation rules defined. Validation rules for PHAR URIs are defined but the scheme is not enabled by default.

Resource retrieval has been updated to use file_get_contents for schemes other than http(s).

fixes #621
fixes #2826

in lieu of #1903

Diff: diff --git a/src/Css/Stylesheet.php b/src/Css/Stylesheet.php
index 18b210fe..2720388d 100644
--- a/src/Css/Stylesheet.php
+++ b/src/Css/Stylesheet.php
@@ -323,50 +323,30 @@ class Stylesheet
 
         if (strpos($file, "data:") === 0) {
             $parsed = Helpers::parse_data_uri($file);
             $css = $parsed["data"];
         } else {
-            $parsed_url = Helpers::explode_url($file);
-
-            [$this->_protocol, $this->_base_host, $this->_base_path, $filename] = $parsed_url;
+            $options = $this->_dompdf->getOptions();
 
-            $file = Helpers::build_url($this->_protocol, $this->_base_host, $this->_base_path, $filename);
+            $parsed_url = Helpers::explode_url($file);
+            $protocol = $parsed_url["protocol"];
 
-            $options = $this->_dompdf->getOptions();
-            // Download the remote file
-            if (!$options->isRemoteEnabled() && ($this->_protocol !== "" && $this->_protocol !== "file://")) {
-                Helpers::record_warnings(E_USER_WARNING, "Remote CSS resource '$file' referenced, but remote file download is disabled.", __FILE__, __LINE__);
-                return;
-            }
-            if ($this->_protocol === "" || $this->_protocol === "file://") {
-                $realfile = realpath($file);
-
-                $rootDir = realpath($options->getRootDir());
-                if (strpos($realfile, $rootDir) !== 0) {
-                    $chroot = $options->getChroot();
-                    $chrootValid = false;
-                    foreach ($chroot as $chrootPath) {
-                        $chrootPath = realpath($chrootPath);
-                        if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
-                            $chrootValid = true;
-                            break;
-                        }
-                    }
-                    if ($chrootValid !== true) {
-                        Helpers::record_warnings(E_USER_WARNING, "Permission denied on $file. The file could not be found under the paths specified by Options::chroot.", __FILE__, __LINE__);
+            if ($file !== $this->getDefaultStylesheet()) {
+                $allowed_protocols = $options->getAllowedProtocols();
+                if (!array_key_exists($protocol, $allowed_protocols)) {
+                    Helpers::record_warnings(E_USER_WARNING, "Permission denied on $file. The communication protocol is not supported.", __FILE__, __LINE__);
+                    return;
+                }
+                foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
+                    [$result, $message] = $rule($file);
+                    if (!$result) {
+                        Helpers::record_warnings(E_USER_WARNING, "Error loading $file: $message", __FILE__, __LINE__);
                         return;
                     }
                 }
-
-                if (!$realfile) {
-                    Helpers::record_warnings(E_USER_WARNING, "File '$realfile' not found.", __FILE__, __LINE__);
-                    return;
-                }
-
-                $file = $realfile;
             }
-            
+
             [$css, $http_response_header] = Helpers::getFileContent($file, $this->_dompdf->getHttpContext());
 
             $good_mime_type = true;
 
             // See http://the-stickman.com/web-development/php/getting-http-response-headers-when-using-file_get_contents/
@@ -377,15 +357,16 @@ class Stylesheet
                     ) {
                         $good_mime_type = false;
                     }
                 }
             }
-
             if (!$good_mime_type || $css === null) {
                 Helpers::record_warnings(E_USER_WARNING, "Unable to load css file $file", __FILE__, __LINE__);
                 return;
             }
+
+            [$this->_protocol, $this->_base_host, $this->_base_path] = $parsed_url;
         }
 
         $this->_parse_css($css);
     }
 
@@ -1419,24 +1400,20 @@ class Stylesheet
             $path = "none"; //Don't resolve no image -> otherwise would prefix path and no longer recognize as none
         } else {
             $val = preg_replace("/url\(\s*['\"]?([^'\")]+)['\"]?\s*\)/", "\\1", trim($val));
 
             // Resolve the url now in the context of the current stylesheet
-            $parsed_url = Helpers::explode_url($val);
             $path = Helpers::build_url($this->_protocol,
                 $this->_base_host,
                 $this->_base_path,
                 $val);
-            if (($parsed_url["protocol"] === "" || $parsed_url["protocol"] === "file://") && ($this->_protocol === "" || $this->_protocol === "file://")) {
-                $path = realpath($path);
-                // If realpath returns FALSE then specifically state that there is no background image
-                if ($path === false) {
-                    $path = "none";
-                }
+            if ($path === null) {
+                $path = "none";
             }
         }
         if ($DEBUGCSS) {
+            $parsed_url = Helpers::explode_url($path);
             print "<pre>[_image\n";
             print_r($parsed_url);
             print $this->_protocol . "\n" . $this->_base_path . "\n" . $path . "\n";
             print "_image]</pre>";
         }
@@ -1481,13 +1458,13 @@ class Stylesheet
             // If the protocol is php, assume that we will import using file://
             // $url = Helpers::build_url($protocol === "php://" ? "file://" : $protocol, $host, $path, $url);
             // Above does not work for subfolders and absolute urls.
             // Todo: As above, do we need to replace php or file to an empty protocol for local files?
 
-            $url = $this->resolve_url($url);
-
-            $this->load_css_file($url);
+            if (($url = $this->resolve_url($url)) !== "none") {
+                $this->load_css_file($url);
+            }
 
             // Restore the current base url
             $this->_protocol = $protocol;
             $this->_base_host = $host;
             $this->_base_path = $path;
@@ -1673,11 +1650,11 @@ class Stylesheet
      */
     public function getDefaultStylesheet()
     {
         $options = $this->_dompdf->getOptions();
         $rootDir = realpath($options->getRootDir());
-        return $rootDir . self::DEFAULT_STYLESHEET;
+        return Helpers::build_url("file://", "", $rootDir, $rootDir . self::DEFAULT_STYLESHEET);
     }
 
     /**
      * @param FontMetrics $fontMetrics
      * @return $this
diff --git a/src/Dompdf.php b/src/Dompdf.php
index 73b1a39b..7e657af2 100644
--- a/src/Dompdf.php
+++ b/src/Dompdf.php
@@ -194,20 +194,10 @@ class Dompdf
      *
      * @var bool
      */
     private $quirksmode = false;
 
-    /**
-    * Protocol whitelist
-    *
-    * Protocols and PHP wrappers allowed in URLs. Full support is not
-    * guaranteed for the protocols/wrappers contained in this array.
-    *
-    * @var array
-    */
-    private $allowedProtocols = ["", "file://", "http://", "https://"];
-
     /**
     * Local file extension whitelist
     *
     * File extensions supported by dompdf for local files.
     *
@@ -269,12 +259,15 @@ class Dompdf
         } else {
             $this->setOptions(new Options());
         }
 
         $versionFile = realpath(__DIR__ . '/../VERSION');
-        if (file_exists($versionFile) && ($version = trim(file_get_contents($versionFile))) !== false && $version !== '$Format:<%h>$') {
-            $this->version = sprintf('dompdf %s', $version);
+        if (($version = file_get_contents($versionFile)) !== false) {
+            $version = trim($version);
+            if ($version !== '$Format:<%h>$') {
+                $this->version = sprintf('dompdf %s', $version);
+            }
         }
 
         $this->setPhpConfig();
 
         $this->paperSize = $this->options->getDefaultPaperSize();
@@ -350,47 +343,29 @@ class Dompdf
 
         if (!$this->protocol && !$this->baseHost && !$this->basePath) {
             [$this->protocol, $this->baseHost, $this->basePath] = Helpers::explode_url($file);
         }
         $protocol = strtolower($this->protocol);
-        
         $uri = Helpers::build_url($this->protocol, $this->baseHost, $this->basePath, $file);
 
-        if (!in_array($protocol, $this->allowedProtocols, true)) {
+        $allowed_protocols = $this->options->getAllowedProtocols();
+        if (!array_key_exists($protocol, $allowed_protocols)) {
             throw new Exception("Permission denied on $file. The communication protocol is not supported.");
         }
 
-        if (!$this->options->isRemoteEnabled() && ($protocol !== "" && $protocol !== "file://")) {
-            throw new Exception("Remote file requested, but remote file download is disabled.");
-        }
-
-        if ($protocol === "" || $protocol === "file://") {
-            $realfile = realpath($uri);
-
-            $chroot = $this->options->getChroot();
-            $chrootValid = false;
-            foreach ($chroot as $chrootPath) {
-                $chrootPath = realpath($chrootPath);
-                if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
-                    $chrootValid = true;
-                    break;
-                }
-            }
-            if ($chrootValid !== true) {
-                throw new Exception("Permission denied on $file. The file could not be found under the paths specified by Options::chroot.");
-            }
-
-            $ext = strtolower(pathinfo($realfile, PATHINFO_EXTENSION));
+        if ($protocol === "file://") {
+            $ext = strtolower(pathinfo($uri, PATHINFO_EXTENSION));
             if (!in_array($ext, $this->allowedLocalFileExtensions)) {
-                throw new Exception("Permission denied on $file. This file extension is forbidden");
+                throw new Exception("Permission denied on $file: The file extension is forbidden.");
             }
+        }
 
-            if (!$realfile) {
-                throw new Exception("File '$file' not found.");
+        foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
+            [$result, $message] = $rule($uri);
+            if (!$result) {
+                throw new Exception("Error loading $file: $message");
             }
-
-            $uri = $realfile;
         }
 
         [$contents, $http_response_header] = Helpers::getFileContent($uri, $this->options->getHttpContext());
         if ($contents === null) {
             throw new Exception("File '$file' not found.");
@@ -602,11 +577,13 @@ class Dompdf
                         }
 
                         $url = $tag->getAttribute("href");
                         $url = Helpers::build_url($this->protocol, $this->baseHost, $this->basePath, $url);
 
-                        $this->css->load_css_file($url, Stylesheet::ORIG_AUTHOR);
+                        if ($url !== null) {
+                            $this->css->load_css_file($url, Stylesheet::ORIG_AUTHOR);
+                        }
                     }
                     break;
 
                 // load <style> tags
                 case "style":
diff --git a/src/FontMetrics.php b/src/FontMetrics.php
index 1d0a9c47..83d0c99c 100644
--- a/src/FontMetrics.php
+++ b/src/FontMetrics.php
@@ -212,41 +212,22 @@ class FontMetrics
 
         $entry[$styleString] = $localFile;
 
         // Download the remote file
         [$protocol] = Helpers::explode_url($remoteFile);
-        if (!$this->options->isRemoteEnabled() && ($protocol !== "" && $protocol !== "file://")) {
-            Helpers::record_warnings(E_USER_WARNING, "Remote font resource $remoteFile referenced, but remote file download is disabled.", __FILE__, __LINE__);
-            return false;
+        $allowed_protocols = $this->options->getAllowedProtocols();
+        if (!array_key_exists($protocol, $allowed_protocols)) {
+            Helpers::record_warnings(E_USER_WARNING, "Permission denied on $remoteFile. The communication protocol is not supported.", __FILE__, __LINE__);
         }
-        if ($protocol === "" || $protocol === "file://") {
-            $realfile = realpath($remoteFile);
-
-            $rootDir = realpath($this->options->getRootDir());
-            if (strpos($realfile, $rootDir) !== 0) {
-                $chroot = $this->options->getChroot();
-                $chrootValid = false;
-                foreach ($chroot as $chrootPath) {
-                    $chrootPath = realpath($chrootPath);
-                    if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
-                        $chrootValid = true;
-                        break;
-                    }
-                }
-                if ($chrootValid !== true) {
-                    Helpers::record_warnings(E_USER_WARNING, "Permission denied on $remoteFile. The file could not be found under the paths specified by Options::chroot.", __FILE__, __LINE__);
-                    return false;
-                }
-            }
 
-            if (!$realfile) {
-                Helpers::record_warnings(E_USER_WARNING, "File '$realfile' not found.", __FILE__, __LINE__);
-                return false;
+        foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
+            [$result, $message] = $rule($remoteFile);
+            if ($result !== true) {
+                Helpers::record_warnings(E_USER_WARNING, "Error loading $remoteFile: $message", __FILE__, __LINE__);
             }
-
-            $remoteFile = $realfile;
         }
+
         list($remoteFileContent, $http_response_header) = @Helpers::getFileContent($remoteFile, $context);
         if ($remoteFileContent === null) {
             return false;
         }
 
diff --git a/src/FrameDecorator/Image.php b/src/FrameDecorator/Image.php
index 61df2bc8..c52aae80 100644
--- a/src/FrameDecorator/Image.php
+++ b/src/FrameDecorator/Image.php
@@ -55,11 +55,11 @@ class Image extends AbstractFrameDecorator
         list($this->_image_url, /*$type*/, $this->_image_msg) = Cache::resolve_url(
             $url,
             $dompdf->getProtocol(),
             $dompdf->getBaseHost(),
             $dompdf->getBasePath(),
-            $dompdf
+            $dompdf->getOptions()
         );
 
         if (Cache::is_broken($this->_image_url) &&
             $alt = $frame->get_node()->getAttribute("alt")
         ) {
diff --git a/src/Helpers.php b/src/Helpers.php
index 6b56c58c..ece05c56 100644
--- a/src/Helpers.php
+++ b/src/Helpers.php
@@ -55,45 +55,71 @@ class Helpers
      * is appended (o.k. also for Windows)
      */
     public static function build_url($protocol, $host, $base_path, $url)
     {
         $protocol = mb_strtolower($protocol);
+        if (empty($protocol)) {
+            $protocol = "file://";
+        }
         if ($url === "") {
-            //return $protocol . $host . rtrim($base_path, "/\\") . "/";
-            return $protocol . $host . $base_path;
+            return null;
         }
 
+        $url_lc = mb_strtolower($url);
+
         // Is the url already fully qualified, a Data URI, or a reference to a named anchor?
         // File-protocol URLs may require additional processing (e.g. for URLs with a relative path)
-        if ((mb_strpos($url, "://") !== false && substr($url, 0, 7) !== "file://") || mb_substr($url, 0, 1) === "#" || mb_strpos($url, "data:") === 0 || mb_strpos($url, "mailto:") === 0 || mb_strpos($url, "tel:") === 0) {
+        if (
+            (
+                mb_strpos($url_lc, "://") !== false
+                && !in_array(substr($url_lc, 0, 7), ["file://", "phar://"], true)
+            )
+            || mb_substr($url_lc, 0, 1) === "#"
+            || mb_strpos($url_lc, "data:") === 0
+            || mb_strpos($url_lc, "mailto:") === 0
+            || mb_strpos($url_lc, "tel:") === 0
+        ) {
             return $url;
         }
 
-        if (strpos($url, "file://") === 0) {
+        $res = "";
+        if (strpos($url_lc, "file://") === 0) {
             $url = substr($url, 7);
-            $protocol = "";
+            $protocol = "file://";
+        } elseif (strpos($url_lc, "phar://") === 0) {
+            $res = substr($url, strpos($url_lc, ".phar")+5);
+            $url = substr($url, 7, strpos($url_lc, ".phar")-2);
+            $protocol = "phar://";
         }
 
         $ret = "";
-        if ($protocol !== "file://") {
-            $ret = $protocol;
-        }
 
-        if (!in_array(mb_strtolower($protocol), ["http://", "https://", "ftp://", "ftps://"], true)) {
+        $is_local_path = in_array($protocol, ["file://", "phar://"], true);
+
+        if ($is_local_path) {
             //On Windows local file, an abs path can begin also with a '\' or a drive letter and colon
             //drive: followed by a relative path would be a drive specific default folder.
             //not known in php app code, treat as abs path
             //($url[1] !== ':' || ($url[2]!=='\\' && $url[2]!=='/'))
             if ($url[0] !== '/' && (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN' || (mb_strlen($url) > 1 && $url[0] !== '\\' && $url[1] !== ':'))) {
                 // For rel path and local access we ignore the host, and run the path through realpath()
                 $ret .= realpath($base_path) . '/';
             }
             $ret .= $url;
             $ret = preg_replace('/\?(.*)$/', "", $ret);
+
+            $filepath = realpath($ret);
+            if ($filepath === false) {
+                return null;
+            }
+
+            $ret = "$protocol$filepath$res";
+
             return $ret;
         }
 
+        $ret = $protocol;
         // Protocol relative urls (e.g. "//example.org/style.css")
         if (strpos($url, '//') === 0) {
             $ret .= substr($url, 2);
             //remote urls with backslash in html/css are not really correct, but lets be genereous
         } elseif ($url[0] === '/' || $url[0] === '\\') {
@@ -429,18 +455,18 @@ class Helpers
     {
         $protocol = "";
         $host = "";
         $path = "";
         $file = "";
+        $res = "";
 
         $arr = parse_url($url);
         if ( isset($arr["scheme"]) ) {
             $arr["scheme"] = mb_strtolower($arr["scheme"]);
         }
 
-        // Exclude windows drive letters...
-        if (isset($arr["scheme"]) && $arr["scheme"] !== "file" && strlen($arr["scheme"]) > 1) {
+        if (isset($arr["scheme"]) && $arr["scheme"] !== "file" && $arr["scheme"] !== "phar" && strlen($arr["scheme"]) > 1) {
             $protocol = $arr["scheme"] . "://";
 
             if (isset($arr["user"])) {
                 $host .= $arr["user"];
 
@@ -478,46 +504,36 @@ class Helpers
                 $file .= "#" . $arr["fragment"];
             }
 
         } else {
 
-            $i = mb_stripos($url, "file://");
-            if ($i !== false) {
-                $url = mb_substr($url, $i + 7);
-            }
-
-            $protocol = ""; // "file://"; ? why doesn't this work... It's because of
-            // network filenames like //COMPU/SHARENAME
-
+            $protocol = "";
             $host = ""; // localhost, really
-            $file = basename($url);
-
-            $path = dirname($url);
-
-            // Check that the path exists
-            if ($path !== false) {
-                $path .= '/';
 
+            $i = mb_stripos($url, "://");
+            if ($i !== false) {
+                $protocol = mb_strtolower(mb_substr($url, 0, $i + 3));
+                $url = mb_substr($url, $i + 3);
             } else {
-                // generate a url to access the file if no real path found.
-                $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://';
-
-                $host = isset($_SERVER["HTTP_HOST"]) ? $_SERVER["HTTP_HOST"] : php_uname("n");
+                $protocol = "file://";
+            }
 
-                if (substr($arr["path"], 0, 1) === '/') {
-                    $path = dirname($arr["path"]);
-                } else {
-                    $path = '/' . rtrim(dirname($_SERVER["SCRIPT_NAME"]), '/') . '/' . $arr["path"];
-                }
+            if ($protocol === "phar://") {
+                $res = substr($url, stripos($url, ".phar")+5);
+                $url = substr($url, 7, stripos($url, ".phar")-2);
             }
+
+            $file = basename($url);
+            $path = dirname($url) . "/";
         }
 
         $ret = [$protocol, $host, $path, $file,
             "protocol" => $protocol,
             "host" => $host,
             "path" => $path,
-            "file" => $file];
+            "file" => $file,
+            "resource" => $res];
         return $ret;
     }
 
     /**
      * Print debug messages
@@ -876,16 +892,17 @@ class Helpers
     public static function getFileContent($uri, $context = null, $offset = 0, $maxlen = null)
     {
         $content = null;
         $headers = null;
         [$protocol] = Helpers::explode_url($uri);
-        $is_local_path = ($protocol === "" || $protocol === "file://");
+        $is_local_path = in_array(strtolower($protocol), ["", "file://", "phar://"], true);
+        $can_use_curl = in_array(strtolower($protocol), ["http://", "https://"], true);
 
         set_error_handler([self::class, 'record_warnings']);
 
         try {
-            if ($is_local_path || ini_get('allow_url_fopen')) {
+            if ($is_local_path || ini_get('allow_url_fopen') || !$can_use_curl) {
                 if ($is_local_path === false) {
                     $uri = Helpers::encodeURI($uri);
                 }
                 if (isset($maxlen)) {
                     $result = file_get_contents($uri, false, $context, $offset, $maxlen);
@@ -897,11 +914,11 @@ class Helpers
                 }
                 if (isset($http_response_header)) {
                     $headers = $http_response_header;
                 }
 
-            } elseif (function_exists('curl_exec')) {
+            } elseif ($can_use_curl && function_exists('curl_exec')) {
                 $curl = curl_init($uri);
 
                 curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
                 curl_setopt($curl, CURLOPT_HEADER, true);
                 if ($offset > 0) {
diff --git a/src/Image/Cache.php b/src/Image/Cache.php
index a6ef0fe5..7da66a1e 100644
--- a/src/Image/Cache.php
+++ b/src/Image/Cache.php
@@ -7,11 +7,11 @@
  * @author  Fabien Ménager <fabien.menager@gmail.com>
  * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
  */
 namespace Dompdf\Image;
 
-use Dompdf\Dompdf;
+use Dompdf\Options;
 use Dompdf\Helpers;
 use Dompdf\Exception\ImageException;
 
 /**
  * Static class that resolves image urls and downloads and caches
@@ -41,148 +41,102 @@ class Cache
      */
     public static $broken_image = "data:image/svg+xml;charset=utf8,%3C?xml version='1.0'?%3E%3Csvg width='64' height='64' xmlns='http://www.w3.org/2000/svg'%3E%3Cg%3E%3Crect stroke='%23666666' id='svg_1' height='60.499994' width='60.166667' y='1.666669' x='1.999998' stroke-width='1.5' fill='none'/%3E%3Cline stroke-linecap='null' stroke-linejoin='null' id='svg_3' y2='59.333253' x2='59.749916' y1='4.333415' x1='4.250079' stroke-width='1.5' stroke='%23999999' fill='none'/%3E%3Cline stroke-linecap='null' stroke-linejoin='null' id='svg_4' y2='59.999665' x2='4.062838' y1='3.750342' x1='60.062164' stroke-width='1.5' stroke='%23999999' fill='none'/%3E%3C/g%3E%3C/svg%3E";
 
     public static $error_message = "Image not found or type unknown";
     
-    /**
-     * Current dompdf instance
-     *
-     * @var Dompdf
-     */
-    protected static $_dompdf;
-
     /**
      * Resolve and fetch an image for use.
      *
      * @param string $url       The url of the image
      * @param string $protocol  Default protocol if none specified in $url
      * @param string $host      Default host if none specified in $url
      * @param string $base_path Default path if none specified in $url
-     * @param Dompdf $dompdf    The Dompdf instance
+     * @param Options $options  An instance of Dompdf\Options
      *
-     * @throws ImageException
-     * @return array             An array with two elements: The local path to the image and the image extension
+     * @return array            An array with three elements: The local path to the image, the image
+     *                          extension, and an error message if the image could not be cached
      */
-    static function resolve_url($url, $protocol, $host, $base_path, Dompdf $dompdf)
+    static function resolve_url($url, $protocol, $host, $base_path, Options $options)
     {
-        self::$_dompdf = $dompdf;
-        
-        $protocol = mb_strtolower($protocol);
-        $parsed_url = Helpers::explode_url($url);
+        $tempfile = null;
+        $resolved_url = null;
+        $type = null;
         $message = null;
-
-        $remote = ($protocol && $protocol !== "file://") || ($parsed_url['protocol'] !== "");
-
-        $data_uri = strpos($parsed_url['protocol'], "data:") === 0;
-        $full_url = null;
-        $enable_remote = $dompdf->getOptions()->getIsRemoteEnabled();
-        $tempfile = false;
-
+        
         try {
+            $full_url = Helpers::build_url($protocol, $host, $base_path, $url);
 
-            // Remote not allowed and is not DataURI
-            if (!$enable_remote && $remote && !$data_uri) {
-                throw new ImageException("Remote file access is disabled.", E_WARNING);
+            if ($full_url === null) {
+                throw new ImageException("Unable to parse image URL $url.", E_WARNING);
             }
-            
-            // remote allowed or DataURI
-            if (($enable_remote && $remote) || $data_uri) {
-                // Download remote files to a temporary directory
-                $full_url = Helpers::build_url($protocol, $host, $base_path, $url);
 
-                // From cache
-                if (isset(self::$_cache[$full_url])) {
-                    $resolved_url = self::$_cache[$full_url];
-                } // From remote
-                else {
-                    $tmp_dir = $dompdf->getOptions()->getTempDir();
-                    if (($resolved_url = @tempnam($tmp_dir, "ca_dompdf_img_")) === false) {
-                        throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);
+            $parsed_url = Helpers::explode_url($full_url);
+            $protocol = strtolower($parsed_url["protocol"]);
+            $is_data_uri = strpos($protocol, "data:") === 0;
+            
+            if (!$is_data_uri) {
+                $allowed_protocols = $options->getAllowedProtocols();
+                if (!array_key_exists($protocol, $allowed_protocols)) {
+                    throw new ImageException("Permission denied on $url. The communication protocol is not supported.", E_WARNING);
+                }
+                foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
+                    [$result, $message] = $rule($full_url);
+                    if (!$result) {
+                        throw new ImageException("Error loading $url: $message", E_WARNING);
                     }
-                    $tempfile = $resolved_url;
-                    $image = null;
+                }
+            }
 
-                    if ($data_uri) {
-                        if ($parsed_data_uri = Helpers::parse_data_uri($url)) {
-                            $image = $parsed_data_uri['data'];
-                        }
-                    } else {
-                        list($image, $http_response_header) = Helpers::getFileContent($full_url, $dompdf->getHttpContext());
-                    }
+            if ($protocol === "file://") {
+                $resolved_url = $full_url;
+            } elseif (isset(self::$_cache[$full_url])) {
+                $resolved_url = self::$_cache[$full_url];
+            } else {
+                $tmp_dir = $options->getTempDir();
+                if (($resolved_url = @tempnam($tmp_dir, "ca_dompdf_img_")) === false) {
+                    throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);
+                }
+                $tempfile = $resolved_url;
 
-                    // Image not found or invalid
-                    if ($image === null) {
-                        $msg = ($data_uri ? "Data-URI could not be parsed" : "Image not found");
-                        throw new ImageException($msg, E_WARNING);
-                    } // Image found, put in cache and process
-                    else {
-                        //e.g. fetch.php?media=url.jpg&cache=1
-                        //- Image file name might be one of the dynamic parts of the url, don't strip off!
-                        //- a remote url does not need to have a file extension at all
-                        //- local cached file does not have a matching file extension
-                        //Therefore get image type from the content
-                        if (@file_put_contents($resolved_url, $image) === false) {
-                            throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);
-                        }
+                $image = null;
+                if ($is_data_uri) {
+                    if (($parsed_data_uri = Helpers::parse_data_uri($url)) !== false) {
+                        $image = $parsed_data_uri["data"];
                     }
+                } else {
+                    list($image, $http_response_header) = Helpers::getFileContent($full_url, $options->getHttpContext());
                 }
-            } // Not remote, local image
-            else {
-                $resolved_url = Helpers::build_url($protocol, $host, $base_path, $url);
 
-                if ($protocol === "" || $protocol === "file://") {
-                    $realfile = realpath($resolved_url);
-        
-                    $rootDir = realpath($dompdf->getOptions()->getRootDir());
-                    if (strpos($realfile, $rootDir) !== 0) {
-                        $chroot = $dompdf->getOptions()->getChroot();
-                        $chrootValid = false;
-                        foreach ($chroot as $chrootPath) {
-                            $chrootPath = realpath($chrootPath);
-                            if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
-                                $chrootValid = true;
-                                break;
-                            }
-                        }
-                        if ($chrootValid !== true) {
-                            throw new ImageException("Permission denied on $resolved_url. The file could not be found under the paths specified by Options::chroot.", E_WARNING);
-                        }
-                    }
-        
-                    if (!$realfile) {
-                        throw new ImageException("File '$realfile' not found.", E_WARNING);
-                    }
-        
-                    $resolved_url = $realfile;
+                // Image not found or invalid
+                if ($image === null) {
+                    $msg = ($is_data_uri ? "Data-URI could not be parsed" : "Image not found");
+                    throw new ImageException($msg, E_WARNING);
+                }
+
+                if (@file_put_contents($resolved_url, $image) === false) {
+                    throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);
                 }
+
+                self::$_cache[$full_url] = $resolved_url;
             }
 
             // Check if the local file is readable
             if (!is_readable($resolved_url) || !filesize($resolved_url)) {
                 throw new ImageException("Image not readable or empty", E_WARNING);
-            } // Check is the file is an image
-            else {
-                list($width, $height, $type) = Helpers::dompdf_getimagesize($resolved_url, $dompdf->getHttpContext());
+            }
 
-                // Known image type
-                if ($width && $height && in_array($type, ["gif", "png", "jpeg", "bmp", "svg","webp"], true)) {
-                    //Don't put replacement image into cache - otherwise it will be deleted on cache cleanup.
-                    //Only execute on successful caching of remote image.
-                    if ($enable_remote && $remote || $data_uri) {
-                        self::$_cache[$full_url] = $resolved_url;
-                    }
-                } // Unknown image type
-                else {
-                    throw new ImageException("Image type unknown", E_WARNING);
-                }
+            list($width, $height, $type) = Helpers::dompdf_getimagesize($resolved_url, $options->getHttpContext());
+
+            if (($width && $height && in_array($type, ["gif", "png", "jpeg", "bmp", "svg","webp"], true)) === false) {
+                throw new ImageException("Image type unknown", E_WARNING);
             }
         } catch (ImageException $e) {
             if ($tempfile) {
                 unlink($tempfile);
             }
             $resolved_url = self::$broken_image;
-            $type = "png";
+            $type = "svg";
             $message = self::$error_message;
             Helpers::record_warnings($e->getCode(), $e->getMessage() . " \n $url", $e->getFile(), $e->getLine());
             self::$_cache[$full_url] = $resolved_url;
         }
 
@@ -227,11 +181,13 @@ class Cache
                 continue;
             }
             if ($debugPng) {
                 print "[clear unlink $file]";
             }
-            unlink($file);
+            if (file_exists($file)) {
+                unlink($file);
+            }
         }
 
         foreach (self::$tempImages as $versions) {
             foreach ($versions as $file) {
                 if ($file === self::$broken_image) {
diff --git a/src/Options.php b/src/Options.php
index 69c43bd9..d3a4da35 100644
--- a/src/Options.php
+++ b/src/Options.php
@@ -60,10 +60,24 @@ class Options
      *
      * @var array
      */
     private $chroot;
 
+    /**
+    * Protocol whitelist
+    *
+    * Protocols and PHP wrappers allowed in URIs. Full support is not
+    * guaranteed for the protocols/wrappers specified by this array.
+    *
+    * @var array
+    */
+    private $allowedProtocols = [
+        "file://" => ["rules" => []],
+        "http://" => ["rules" => []],
+        "https://" => ["rules" => []]
+    ];
+
     /**
      * @var string
      */
     private $logOutputFile;
 
@@ -297,21 +311,26 @@ class Options
         $this->setTempDir(sys_get_temp_dir());
         $this->setFontDir($rootDir . "/lib/fonts");
         $this->setFontCache($this->getFontDir());
 
         $ver = "";
-        $versionFile = realpath(__DIR__ . "/../VERSION");
-        if (file_exists($versionFile) && ($version = trim(file_get_contents($versionFile))) !== false && $version !== '$Format:<%h>$') {
-            $ver = "/$version";
+        $versionFile = realpath(__DIR__ . '/../VERSION');
+        if (($version = file_get_contents($versionFile)) !== false) {
+            $version = trim($version);
+            if ($version !== '$Format:<%h>$') {
+                $ver = "/$version";
+            }
         }
         $this->setHttpContext([
             "http" => [
                 "follow_location" => false,
                 "user_agent" => "Dompdf$ver https://github.com/dompdf/dompdf"
             ]
         ]);
 
+        $this->setAllowedProtocols(["file://", "http://", "https://"]);
+
         if (null !== $attributes) {
             $this->set($attributes);
         }
     }
 
@@ -332,10 +351,12 @@ class Options
                 $this->setFontDir($value);
             } elseif ($key === 'fontCache' || $key === 'font_cache') {
                 $this->setFontCache($value);
             } elseif ($key === 'chroot') {
                 $this->setChroot($value);
+            } elseif ($key === 'allowedProtocols') {
+                $this->setAllowedProtocols($value);
             } elseif ($key === 'logOutputFile' || $key === 'log_output_file') {
                 $this->setLogOutputFile($value);
             } elseif ($key === 'defaultMediaType' || $key === 'default_media_type') {
                 $this->setDefaultMediaType($value);
             } elseif ($key === 'defaultPaperSize' || $key === 'default_paper_size') {
@@ -397,10 +418,12 @@ class Options
             return $this->getFontDir();
         } elseif ($key === 'fontCache' || $key === 'font_cache') {
             return $this->getFontCache();
         } elseif ($key === 'chroot') {
             return $this->getChroot();
+        } elseif ($key === 'allowedProtocols') {
+            return $this->getAllowedProtocols();
         } elseif ($key === 'logOutputFile' || $key === 'log_output_file') {
             return $this->getLogOutputFile();
         } elseif ($key === 'defaultMediaType' || $key === 'default_media_type') {
             return $this->getDefaultMediaType();
         } elseif ($key === 'defaultPaperSize' || $key === 'default_paper_size') {
@@ -497,10 +520,71 @@ class Options
             $this->chroot = $chroot;
         }
         return $this;
     }
 
+    /**
+     * @return array
+     */
+    public function getAllowedProtocols()
+    {
+        return $this->allowedProtocols;
+    }
+
+    /**
+     * @param array $allowedProtocols The protocols to allow as an array (["protocol://" => ["rules" => [callable]]], ...) or a string list of the protocols
+     * @return $this
+     */
+    public function setAllowedProtocols(array $allowedProtocols)
+    {
+        $protocols = [];
+        foreach ($allowedProtocols as $protocol => $config) {
+            if (is_string($protocol)) {
+                $protocols[$protocol] = [];
+                if (is_array($config)) {
+                    $protocols[$protocol] = $config;
+                }
+            } elseif (is_string($config)) {
+                $protocols[$config] = [];
+            }
+        }
+        $this->allowedProtocols = [];
+        foreach ($protocols as $protocol => $config) {
+            $this->addAllowedProtocol($protocol, ...($config["rules"] ?? []));
+        }
+        return $this;
+    }
+
+    /**
+     * Adds a new protocol to the allowed protocols collection
+     *
+     * @param string $protocol The scheme to add (e.g. "http://")
+     * @param callable $rule A callable that validates the protocol
+     * @return $this
+     */
+    public function addAllowedProtocol(string $protocol, callable ...$rules)
+    {
+        $protocol = strtolower($protocol);
+        if (empty($rules)) {
+            $rules = [];
+            switch ($protocol) {
+                case "file://":
+                    $rules[] = [$this, "validateLocalUri"];
+                    break;
+                case "http://":
+                case "https://":
+                    $rules[] = [$this, "validateRemoteUri"];
+                    break;
+                case "phar://":
+                    $rules[] = [$this, "validatePharUri"];
+                    break;
+            }
+        }
+        $this->allowedProtocols[$protocol] = ["rules" => $rules];
+        return $this;
+    }
+
     /**
      * @return array
      */
     public function getChroot()
     {
@@ -1009,6 +1093,58 @@ class Options
      */
     public function getHttpContext()
     {
         return $this->httpContext;
     }
+
+    public function validateLocalUri(string $uri)
+    {
+        if ($uri === null || strlen($uri) === 0) {
+            return [false, "The URI must not be empty."];
+        }
+
+        $realfile = realpath(str_replace("file://", "", $uri));
+
+        $dirs = $this->chroot;
+        $dirs[] = $this->rootDir;
+        $chrootValid = false;
+        foreach ($dirs as $chrootPath) {
+            $chrootPath = realpath($chrootPath);
+            if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
+                $chrootValid = true;
+                break;
+            }
+        }
+        if ($chrootValid !== true) {
+            return [false, "Permission denied. The file could not be found under the paths specified by Options::chroot."];
+        }
+
+        if (!$realfile) {
+            return [false, "File not found."];
+        }
+
+        return [true, null];
+    }
+
+    public function validatePharUri(string $uri)
+    {
+        if ($uri === null || strlen($uri) === 0) {
+            return [false, "The URI must not be empty."];
+        }
+
+        $file = substr(substr($uri, 0, strpos($uri, ".phar") + 5), 7);
+        return $this->validateLocalUri($file);
+    }
+
+    public function validateRemoteUri(string $uri)
+    {
+        if ($uri === null || strlen($uri) === 0) {
+            return [false, "The URI must not be empty."];
+        }
+
+        if (!$this->isRemoteEnabled) {
+            return [false, "Remote file requested, but remote file download is disabled."];
+        }
+
+        return [true, null];
+    }
 }
diff --git a/src/Renderer/AbstractRenderer.php b/src/Renderer/AbstractRenderer.php
index 4ba61c61..27dc365b 100644
--- a/src/Renderer/AbstractRenderer.php
+++ b/src/Renderer/AbstractRenderer.php
@@ -95,11 +95,11 @@ abstract class AbstractRenderer
         list($img, $type, /*$msg*/) = Cache::resolve_url(
             $url,
             $sheet->get_protocol(),
             $sheet->get_host(),
             $sheet->get_base_path(),
-            $this->_dompdf
+            $this->_dompdf->getOptions()
         );
 
         // Bail if the image is no good
         if (Cache::is_broken($img)) {
             return;
diff --git a/tests/Css/StyleTest.php b/tests/Css/StyleTest.php
index b09d1bd9..45929fcb 100644
--- a/tests/Css/StyleTest.php
+++ b/tests/Css/StyleTest.php
@@ -51,21 +51,21 @@ class StyleTest extends TestCase
 
     public function cssImageNoBaseHrefProvider(): array
     {
         $basePath = realpath(__DIR__ . "/..");
         return [
-            "local absolute" => ["url($basePath/_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
-            "local relative" => ["url(../_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]
+            "local absolute" => ["url($basePath/_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
+            "local relative" => ["url(../_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]
         ];
     }
 
     public function cssImageWithBaseHrefProvider(): array
     {
         $basePath = realpath(__DIR__ . "/..");
         return [
-            "local absolute" => ["url($basePath/_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
-            "local relative" => ["url(../_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]
+            "local absolute" => ["url($basePath/_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
+            "local relative" => ["url(../_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]
         ];
     }
 
     public function cssImageWithStylesheetBaseHrefProvider(): array
     {
diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php
index aa2352d4..e69f2143 100644
--- a/tests/HelpersTest.php
+++ b/tests/HelpersTest.php
@@ -99,6 +99,13 @@ AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO
         $this->assertSame($expected, Helpers::lengthEqual($a, $b));
         $this->assertSame($expected, Helpers::lengthEqual($b, $a));
         $this->assertSame($expected, Helpers::lengthEqual(-$a, -$b));
         $this->assertSame($expected, Helpers::lengthEqual(-$b, -$a));
     }
+
+    
+    public function testCustomProtocolParsing(): void
+    {
+        $uri = "mock://path/to/resource";
+        $this->assertSame($uri, Helpers::build_url("", "", "", $uri));
+    }
 }
diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php
index 2f051558..5147ddee 100644
--- a/tests/OptionsTest.php
+++ b/tests/OptionsTest.php
@@ -93,6 +93,46 @@ class OptionsTest extends TestCase
         $this->assertIsResource($option->getHttpContext());
 
         $option->setChroot(['test11']);
         $this->assertEquals(['test11'], $option->getChroot());
     }
+
+    public function testAllowedProtocols()
+    {
+        $options = new Options(["isRemoteEnabled" => false]);
+        $options->setAllowedProtocols(["http://"]);
+        $allowedProtocols = $options->getAllowedProtocols();
+        $this->assertIsArray($allowedProtocols);
+        $this->assertEquals(1, count($allowedProtocols));
+        $this->assertArrayHasKey("http://", $allowedProtocols);
+        $this->assertIsArray($allowedProtocols["http://"]);
+        $this->assertArrayHasKey("rules", $allowedProtocols["http://"]);
+        $this->assertIsArray($allowedProtocols["http://"]["rules"]);
+        $this->assertEquals(1, count($allowedProtocols["http://"]["rules"]));
+        $this->assertEquals([$options, "validateRemoteUri"], $allowedProtocols["http://"]["rules"][0]);
+
+        [$validation_result] = $allowedProtocols["http://"]["rules"][0]("http://example.com/");
+        $this->assertFalse($validation_result);
+
+        
+        $mock_protocol = [
+            "mock://" => [
+                "rules" => [
+                    function ($uri) { return [true, null]; }
+                ]
+            ]
+        ];
+        $options->setAllowedProtocols($mock_protocol);
+        $allowedProtocols = $options->getAllowedProtocols();
+        $this->assertIsArray($allowedProtocols);
+        $this->assertEquals(1, count($allowedProtocols));
+        $this->assertArrayHasKey("mock://", $allowedProtocols);
+        $this->assertIsArray($allowedProtocols["mock://"]);
+        $this->assertArrayHasKey("rules", $allowedProtocols["mock://"]);
+        $this->assertIsArray($allowedProtocols["mock://"]["rules"]);
+        $this->assertEquals(1, count($allowedProtocols["mock://"]["rules"]));
+        $this->assertEquals($mock_protocol["mock://"]["rules"][0], $allowedProtocols["mock://"]["rules"][0]);
+
+        [$validation_result] = $allowedProtocols["mock://"]["rules"][0]("mock://example.com/");
+        $this->assertTrue($validation_result);
+    }
 }

Vulnerable code:             $parsed_url = Helpers::explode_url($file);

            [$this->_protocol, $this->_base_host, $this->_base_path, $filename] = $parsed_url;
            $file = Helpers::build_url($this->_protocol, $this->_base_host, $this->_base_path, $filename);
            $options = $this->_dompdf->getOptions();
            // Download the remote file
            if (!$options->isRemoteEnabled() && ($this->_protocol !== "" && $this->_protocol !== "file://")) {
                Helpers::record_warnings(E_USER_WARNING, "Remote CSS resource '$file' referenced, but remote file download is disabled.", __FILE__, __LINE__);
                return;
            }
            if ($this->_protocol === "" || $this->_protocol === "file://") {
                $realfile = realpath($file);

                $rootDir = realpath($options->getRootDir());
                if (strpos($realfile, $rootDir) !== 0) {
                    $chroot = $options->getChroot();
                    $chrootValid = false;
                    foreach ($chroot as $chrootPath) {
                        $chrootPath = realpath($chrootPath);
                        if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
                            $chrootValid = true;
                            break;
                        }
                    }
                    if ($chrootValid !== true) {
                        Helpers::record_warnings(E_USER_WARNING, "Permission denied on $file. The file could not be found under the paths specified by Options::chroot.", __FILE__, __LINE__);

                if (!$realfile) {
                    Helpers::record_warnings(E_USER_WARNING, "File '$realfile' not found.", __FILE__, __LINE__);
                    return;
                }

                $file = $realfile;
            

            $parsed_url = Helpers::explode_url($val);
            if (($parsed_url["protocol"] === "" || $parsed_url["protocol"] === "file://") && ($this->_protocol === "" || $this->_protocol === "file://")) {
                $path = realpath($path);
                // If realpath returns FALSE then specifically state that there is no background image
                if ($path === false) {
                    $path = "none";
                }
            $url = $this->resolve_url($url);

            $this->load_css_file($url);
        return $rootDir . self::DEFAULT_STYLESHEET;
    /**
    * Protocol whitelist
    *
    * Protocols and PHP wrappers allowed in URLs. Full support is not
    * guaranteed for the protocols/wrappers contained in this array.
    *
    * @var array
    */
    private $allowedProtocols = ["", "file://", "http://", "https://"];

        if (file_exists($versionFile) && ($version = trim(file_get_contents($versionFile))) !== false && $version !== '$Format:<%h>$') {
            $this->version = sprintf('dompdf %s', $version);
        
        if (!in_array($protocol, $this->allowedProtocols, true)) {
        if (!$this->options->isRemoteEnabled() && ($protocol !== "" && $protocol !== "file://")) {
            throw new Exception("Remote file requested, but remote file download is disabled.");
        }

        if ($protocol === "" || $protocol === "file://") {
            $realfile = realpath($uri);

            $chroot = $this->options->getChroot();
            $chrootValid = false;
            foreach ($chroot as $chrootPath) {
                $chrootPath = realpath($chrootPath);
                if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
                    $chrootValid = true;
                    break;
                }
            }
            if ($chrootValid !== true) {
                throw new Exception("Permission denied on $file. The file could not be found under the paths specified by Options::chroot.");
            }

            $ext = strtolower(pathinfo($realfile, PATHINFO_EXTENSION));
                throw new Exception("Permission denied on $file. This file extension is forbidden");
            if (!$realfile) {
                throw new Exception("File '$file' not found.");

            $uri = $realfile;
                        $this->css->load_css_file($url, Stylesheet::ORIG_AUTHOR);
        if (!$this->options->isRemoteEnabled() && ($protocol !== "" && $protocol !== "file://")) {
            Helpers::record_warnings(E_USER_WARNING, "Remote font resource $remoteFile referenced, but remote file download is disabled.", __FILE__, __LINE__);
            return false;
        if ($protocol === "" || $protocol === "file://") {
            $realfile = realpath($remoteFile);

            $rootDir = realpath($this->options->getRootDir());
            if (strpos($realfile, $rootDir) !== 0) {
                $chroot = $this->options->getChroot();
                $chrootValid = false;
                foreach ($chroot as $chrootPath) {
                    $chrootPath = realpath($chrootPath);
                    if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
                        $chrootValid = true;
                        break;
                    }
                }
                if ($chrootValid !== true) {
                    Helpers::record_warnings(E_USER_WARNING, "Permission denied on $remoteFile. The file could not be found under the paths specified by Options::chroot.", __FILE__, __LINE__);
                    return false;
                }
            }
            if (!$realfile) {
                Helpers::record_warnings(E_USER_WARNING, "File '$realfile' not found.", __FILE__, __LINE__);
                return false;

            $remoteFile = $realfile;
            $dompdf
            //return $protocol . $host . rtrim($base_path, "/\\") . "/";
            return $protocol . $host . $base_path;
        if ((mb_strpos($url, "://") !== false && substr($url, 0, 7) !== "file://") || mb_substr($url, 0, 1) === "#" || mb_strpos($url, "data:") === 0 || mb_strpos($url, "mailto:") === 0 || mb_strpos($url, "tel:") === 0) {
        if (strpos($url, "file://") === 0) {
            $protocol = "";
        if ($protocol !== "file://") {
            $ret = $protocol;
        }
        if (!in_array(mb_strtolower($protocol), ["http://", "https://", "ftp://", "ftps://"], true)) {
        // Exclude windows drive letters...
        if (isset($arr["scheme"]) && $arr["scheme"] !== "file" && strlen($arr["scheme"]) > 1) {
            $i = mb_stripos($url, "file://");
            if ($i !== false) {
                $url = mb_substr($url, $i + 7);
            }

            $protocol = ""; // "file://"; ? why doesn't this work... It's because of
            // network filenames like //COMPU/SHARENAME

            $file = basename($url);

            $path = dirname($url);

            // Check that the path exists
            if ($path !== false) {
                $path .= '/';
                // generate a url to access the file if no real path found.
                $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://';

                $host = isset($_SERVER["HTTP_HOST"]) ? $_SERVER["HTTP_HOST"] : php_uname("n");
                if (substr($arr["path"], 0, 1) === '/') {
                    $path = dirname($arr["path"]);
                } else {
                    $path = '/' . rtrim(dirname($_SERVER["SCRIPT_NAME"]), '/') . '/' . $arr["path"];
                }
            "file" => $file];
        $is_local_path = ($protocol === "" || $protocol === "file://");
            if ($is_local_path || ini_get('allow_url_fopen')) {
            } elseif (function_exists('curl_exec')) {
use Dompdf\Dompdf;
    /**
     * Current dompdf instance
     *
     * @var Dompdf
     */
    protected static $_dompdf;

     * @param Dompdf $dompdf    The Dompdf instance
     * @throws ImageException
     * @return array             An array with two elements: The local path to the image and the image extension
    static function resolve_url($url, $protocol, $host, $base_path, Dompdf $dompdf)
        self::$_dompdf = $dompdf;
        
        $protocol = mb_strtolower($protocol);
        $parsed_url = Helpers::explode_url($url);

        $remote = ($protocol && $protocol !== "file://") || ($parsed_url['protocol'] !== "");

        $data_uri = strpos($parsed_url['protocol'], "data:") === 0;
        $full_url = null;
        $enable_remote = $dompdf->getOptions()->getIsRemoteEnabled();
        $tempfile = false;

            // Remote not allowed and is not DataURI
            if (!$enable_remote && $remote && !$data_uri) {
                throw new ImageException("Remote file access is disabled.", E_WARNING);
            
            // remote allowed or DataURI
            if (($enable_remote && $remote) || $data_uri) {
                // Download remote files to a temporary directory
                $full_url = Helpers::build_url($protocol, $host, $base_path, $url);
                // From cache
                if (isset(self::$_cache[$full_url])) {
                    $resolved_url = self::$_cache[$full_url];
                } // From remote
                else {
                    $tmp_dir = $dompdf->getOptions()->getTempDir();
                    if (($resolved_url = @tempnam($tmp_dir, "ca_dompdf_img_")) === false) {
                        throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);
                    $tempfile = $resolved_url;
                    $image = null;
                    if ($data_uri) {
                        if ($parsed_data_uri = Helpers::parse_data_uri($url)) {
                            $image = $parsed_data_uri['data'];
                        }
                    } else {
                        list($image, $http_response_header) = Helpers::getFileContent($full_url, $dompdf->getHttpContext());
                    }
                    // Image not found or invalid
                    if ($image === null) {
                        $msg = ($data_uri ? "Data-URI could not be parsed" : "Image not found");
                        throw new ImageException($msg, E_WARNING);
                    } // Image found, put in cache and process
                    else {
                        //e.g. fetch.php?media=url.jpg&cache=1
                        //- Image file name might be one of the dynamic parts of the url, don't strip off!
                        //- a remote url does not need to have a file extension at all
                        //- local cached file does not have a matching file extension
                        //Therefore get image type from the content
                        if (@file_put_contents($resolved_url, $image) === false) {
                            throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);
                        }
            } // Not remote, local image
            else {
                $resolved_url = Helpers::build_url($protocol, $host, $base_path, $url);
                if ($protocol === "" || $protocol === "file://") {
                    $realfile = realpath($resolved_url);
        
                    $rootDir = realpath($dompdf->getOptions()->getRootDir());
                    if (strpos($realfile, $rootDir) !== 0) {
                        $chroot = $dompdf->getOptions()->getChroot();
                        $chrootValid = false;
                        foreach ($chroot as $chrootPath) {
                            $chrootPath = realpath($chrootPath);
                            if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
                                $chrootValid = true;
                                break;
                            }
                        }
                        if ($chrootValid !== true) {
                            throw new ImageException("Permission denied on $resolved_url. The file could not be found under the paths specified by Options::chroot.", E_WARNING);
                        }
                    }
        
                    if (!$realfile) {
                        throw new ImageException("File '$realfile' not found.", E_WARNING);
                    }
        
                    $resolved_url = $realfile;
            } // Check is the file is an image
            else {
                list($width, $height, $type) = Helpers::dompdf_getimagesize($resolved_url, $dompdf->getHttpContext());
                // Known image type
                if ($width && $height && in_array($type, ["gif", "png", "jpeg", "bmp", "svg","webp"], true)) {
                    //Don't put replacement image into cache - otherwise it will be deleted on cache cleanup.
                    //Only execute on successful caching of remote image.
                    if ($enable_remote && $remote || $data_uri) {
                        self::$_cache[$full_url] = $resolved_url;
                    }
                } // Unknown image type
                else {
                    throw new ImageException("Image type unknown", E_WARNING);
                }
            $type = "png";
            unlink($file);
        $versionFile = realpath(__DIR__ . "/../VERSION");
        if (file_exists($versionFile) && ($version = trim(file_get_contents($versionFile))) !== false && $version !== '$Format:<%h>$') {
            $ver = "/$version";
            $this->_dompdf
            "local absolute" => ["url($basePath/_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
            "local relative" => ["url(../_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]
            "local absolute" => ["url($basePath/_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
            "local relative" => ["url(../_files/jamaica.jpg)", $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]
Fixed code:             $options = $this->_dompdf->getOptions();
            $parsed_url = Helpers::explode_url($file);
            $protocol = $parsed_url["protocol"];
            if ($file !== $this->getDefaultStylesheet()) {
                $allowed_protocols = $options->getAllowedProtocols();
                if (!array_key_exists($protocol, $allowed_protocols)) {
                    Helpers::record_warnings(E_USER_WARNING, "Permission denied on $file. The communication protocol is not supported.", __FILE__, __LINE__);
                    return;
                }
                foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
                    [$result, $message] = $rule($file);
                    if (!$result) {
                        Helpers::record_warnings(E_USER_WARNING, "Error loading $file: $message", __FILE__, __LINE__);


            [$this->_protocol, $this->_base_host, $this->_base_path] = $parsed_url;
            if ($path === null) {
                $path = "none";
            $parsed_url = Helpers::explode_url($path);
            if (($url = $this->resolve_url($url)) !== "none") {
                $this->load_css_file($url);
            }
        return Helpers::build_url("file://", "", $rootDir, $rootDir . self::DEFAULT_STYLESHEET);
        if (($version = file_get_contents($versionFile)) !== false) {
            $version = trim($version);
            if ($version !== '$Format:<%h>$') {
                $this->version = sprintf('dompdf %s', $version);
            }
        $allowed_protocols = $this->options->getAllowedProtocols();
        if (!array_key_exists($protocol, $allowed_protocols)) {
        if ($protocol === "file://") {
            $ext = strtolower(pathinfo($uri, PATHINFO_EXTENSION));
                throw new Exception("Permission denied on $file: The file extension is forbidden.");
        }
        foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
            [$result, $message] = $rule($uri);
            if (!$result) {
                throw new Exception("Error loading $file: $message");
                        if ($url !== null) {
                            $this->css->load_css_file($url, Stylesheet::ORIG_AUTHOR);
                        }
        $allowed_protocols = $this->options->getAllowedProtocols();
        if (!array_key_exists($protocol, $allowed_protocols)) {
            Helpers::record_warnings(E_USER_WARNING, "Permission denied on $remoteFile. The communication protocol is not supported.", __FILE__, __LINE__);
        foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
            [$result, $message] = $rule($remoteFile);
            if ($result !== true) {
                Helpers::record_warnings(E_USER_WARNING, "Error loading $remoteFile: $message", __FILE__, __LINE__);

            $dompdf->getOptions()
        if (empty($protocol)) {
            $protocol = "file://";
        }
            return null;
        $url_lc = mb_strtolower($url);

        if (
            (
                mb_strpos($url_lc, "://") !== false
                && !in_array(substr($url_lc, 0, 7), ["file://", "phar://"], true)
            )
            || mb_substr($url_lc, 0, 1) === "#"
            || mb_strpos($url_lc, "data:") === 0
            || mb_strpos($url_lc, "mailto:") === 0
            || mb_strpos($url_lc, "tel:") === 0
        ) {
        $res = "";
        if (strpos($url_lc, "file://") === 0) {
            $protocol = "file://";
        } elseif (strpos($url_lc, "phar://") === 0) {
            $res = substr($url, strpos($url_lc, ".phar")+5);
            $url = substr($url, 7, strpos($url_lc, ".phar")-2);
            $protocol = "phar://";
        $is_local_path = in_array($protocol, ["file://", "phar://"], true);

        if ($is_local_path) {

            $filepath = realpath($ret);
            if ($filepath === false) {
                return null;
            }

            $ret = "$protocol$filepath$res";

        $ret = $protocol;
        $res = "";
        if (isset($arr["scheme"]) && $arr["scheme"] !== "file" && $arr["scheme"] !== "phar" && strlen($arr["scheme"]) > 1) {
            $protocol = "";
            $i = mb_stripos($url, "://");
            if ($i !== false) {
                $protocol = mb_strtolower(mb_substr($url, 0, $i + 3));
                $url = mb_substr($url, $i + 3);
                $protocol = "file://";
            }
            if ($protocol === "phar://") {
                $res = substr($url, stripos($url, ".phar")+5);
                $url = substr($url, 7, stripos($url, ".phar")-2);

            $file = basename($url);
            $path = dirname($url) . "/";
            "file" => $file,
            "resource" => $res];
        $is_local_path = in_array(strtolower($protocol), ["", "file://", "phar://"], true);
        $can_use_curl = in_array(strtolower($protocol), ["http://", "https://"], true);
            if ($is_local_path || ini_get('allow_url_fopen') || !$can_use_curl) {
            } elseif ($can_use_curl && function_exists('curl_exec')) {
use Dompdf\Options;
     * @param Options $options  An instance of Dompdf\Options
     * @return array            An array with three elements: The local path to the image, the image
     *                          extension, and an error message if the image could not be cached
    static function resolve_url($url, $protocol, $host, $base_path, Options $options)
        $tempfile = null;
        $resolved_url = null;
        $type = null;
        
            $full_url = Helpers::build_url($protocol, $host, $base_path, $url);
            if ($full_url === null) {
                throw new ImageException("Unable to parse image URL $url.", E_WARNING);
            $parsed_url = Helpers::explode_url($full_url);
            $protocol = strtolower($parsed_url["protocol"]);
            $is_data_uri = strpos($protocol, "data:") === 0;
            
            if (!$is_data_uri) {
                $allowed_protocols = $options->getAllowedProtocols();
                if (!array_key_exists($protocol, $allowed_protocols)) {
                    throw new ImageException("Permission denied on $url. The communication protocol is not supported.", E_WARNING);
                }
                foreach ($allowed_protocols[$protocol]["rules"] as $rule) {
                    [$result, $message] = $rule($full_url);
                    if (!$result) {
                        throw new ImageException("Error loading $url: $message", E_WARNING);
                }
            }
            if ($protocol === "file://") {
                $resolved_url = $full_url;
            } elseif (isset(self::$_cache[$full_url])) {
                $resolved_url = self::$_cache[$full_url];
            } else {
                $tmp_dir = $options->getTempDir();
                if (($resolved_url = @tempnam($tmp_dir, "ca_dompdf_img_")) === false) {
                    throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);
                }
                $tempfile = $resolved_url;
                $image = null;
                if ($is_data_uri) {
                    if (($parsed_data_uri = Helpers::parse_data_uri($url)) !== false) {
                        $image = $parsed_data_uri["data"];
                } else {
                    list($image, $http_response_header) = Helpers::getFileContent($full_url, $options->getHttpContext());
                // Image not found or invalid
                if ($image === null) {
                    $msg = ($is_data_uri ? "Data-URI could not be parsed" : "Image not found");
                    throw new ImageException($msg, E_WARNING);
                }

                if (@file_put_contents($resolved_url, $image) === false) {
                    throw new ImageException("Unable to create temporary image in " . $tmp_dir, E_WARNING);

                self::$_cache[$full_url] = $resolved_url;
            }
            list($width, $height, $type) = Helpers::dompdf_getimagesize($resolved_url, $options->getHttpContext());

            if (($width && $height && in_array($type, ["gif", "png", "jpeg", "bmp", "svg","webp"], true)) === false) {
                throw new ImageException("Image type unknown", E_WARNING);
            $type = "svg";
            if (file_exists($file)) {
                unlink($file);
            }
    /**
    * Protocol whitelist
    *
    * Protocols and PHP wrappers allowed in URIs. Full support is not
    * guaranteed for the protocols/wrappers specified by this array.
    *
    * @var array
    */
    private $allowedProtocols = [
        "file://" => ["rules" => []],
        "http://" => ["rules" => []],
        "https://" => ["rules" => []]
    ];

        $versionFile = realpath(__DIR__ . '/../VERSION');
        if (($version = file_get_contents($versionFile)) !== false) {
            $version = trim($version);
            if ($version !== '$Format:<%h>$') {
                $ver = "/$version";
            }
        $this->setAllowedProtocols(["file://", "http://", "https://"]);

            } elseif ($key === 'allowedProtocols') {
                $this->setAllowedProtocols($value);
        } elseif ($key === 'allowedProtocols') {
            return $this->getAllowedProtocols();
    /**
     * @return array
     */
    public function getAllowedProtocols()
    {
        return $this->allowedProtocols;
    }

    /**
     * @param array $allowedProtocols The protocols to allow as an array (["protocol://" => ["rules" => [callable]]], ...) or a string list of the protocols
     * @return $this
     */
    public function setAllowedProtocols(array $allowedProtocols)
    {
        $protocols = [];
        foreach ($allowedProtocols as $protocol => $config) {
            if (is_string($protocol)) {
                $protocols[$protocol] = [];
                if (is_array($config)) {
                    $protocols[$protocol] = $config;
                }
            } elseif (is_string($config)) {
                $protocols[$config] = [];
            }
        }
        $this->allowedProtocols = [];
        foreach ($protocols as $protocol => $config) {
            $this->addAllowedProtocol($protocol, ...($config["rules"] ?? []));
        }
        return $this;
    }

    /**
     * Adds a new protocol to the allowed protocols collection
     *
     * @param string $protocol The scheme to add (e.g. "http://")
     * @param callable $rule A callable that validates the protocol
     * @return $this
     */
    public function addAllowedProtocol(string $protocol, callable ...$rules)
    {
        $protocol = strtolower($protocol);
        if (empty($rules)) {
            $rules = [];
            switch ($protocol) {
                case "file://":
                    $rules[] = [$this, "validateLocalUri"];
                    break;
                case "http://":
                case "https://":
                    $rules[] = [$this, "validateRemoteUri"];
                    break;
                case "phar://":
                    $rules[] = [$this, "validatePharUri"];
                    break;
            }
        }
        $this->allowedProtocols[$protocol] = ["rules" => $rules];
        return $this;
    }


    public function validateLocalUri(string $uri)
    {
        if ($uri === null || strlen($uri) === 0) {
            return [false, "The URI must not be empty."];
        }

        $realfile = realpath(str_replace("file://", "", $uri));

        $dirs = $this->chroot;
        $dirs[] = $this->rootDir;
        $chrootValid = false;
        foreach ($dirs as $chrootPath) {
            $chrootPath = realpath($chrootPath);
            if ($chrootPath !== false && strpos($realfile, $chrootPath) === 0) {
                $chrootValid = true;
                break;
            }
        }
        if ($chrootValid !== true) {
            return [false, "Permission denied. The file could not be found under the paths specified by Options::chroot."];
        }

        if (!$realfile) {
            return [false, "File not found."];
        }

        return [true, null];
    }

    public function validatePharUri(string $uri)
    {
        if ($uri === null || strlen($uri) === 0) {
            return [false, "The URI must not be empty."];
        }

        $file = substr(substr($uri, 0, strpos($uri, ".phar") + 5), 7);
        return $this->validateLocalUri($file);
    }

    public function validateRemoteUri(string $uri)
    {
        if ($uri === null || strlen($uri) === 0) {
            return [false, "The URI must not be empty."];
        }

        if (!$this->isRemoteEnabled) {
            return [false, "Remote file requested, but remote file download is disabled."];
        }

        return [true, null];
    }
            $this->_dompdf->getOptions()
            "local absolute" => ["url($basePath/_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
            "local relative" => ["url(../_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]
            "local absolute" => ["url($basePath/_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"],
            "local relative" => ["url(../_files/jamaica.jpg)", "file://" . $basePath . DIRECTORY_SEPARATOR . "_files" . DIRECTORY_SEPARATOR . "jamaica.jpg"]

    
    public function testCustomProtocolParsing(): void
    {
        $uri = "mock://path/to/resource";
        $this->assertSame($uri, Helpers::build_url("", "", "", $uri));
    }

    public function testAllowedProtocols()
    {
        $options = new Options(["isRemoteEnabled" => false]);
        $options->setAllowedProtocols(["http://"]);
        $allowedProtocols = $options->getAllowedProtocols();
        $this->assertIsArray($allowedProtocols);
        $this->assertEquals(1, count($allowedProtocols));
        $this->assertArrayHasKey("http://", $allowedProtocols);
        $this->assertIsArray($allowedProtocols["http://"]);
        $this->assertArrayHasKey("rules", $allowedProtocols["http://"]);
        $this->assertIsArray($allowedProtocols["http://"]["rules"]);
        $this->assertEquals(1, count($allowedProtocols["http://"]["rules"]));
        $this->assertEquals([$options, "validateRemoteUri"], $allowedProtocols["http://"]["rules"][0]);

        [$validation_result] = $allowedProtocols["http://"]["rules"][0]("http://example.com/");
        $this->assertFalse($validation_result);

        
        $mock_protocol = [
            "mock://" => [
                "rules" => [
                    function ($uri) { return [true, null]; }
                ]
            ]
        ];
        $options->setAllowedProtocols($mock_protocol);
        $allowedProtocols = $options->getAllowedProtocols();
        $this->assertIsArray($allowedProtocols);
        $this->assertEquals(1, count($allowedProtocols));
        $this->assertArrayHasKey("mock://", $allowedProtocols);
        $this->assertIsArray($allowedProtocols["mock://"]);
        $this->assertArrayHasKey("rules", $allowedProtocols["mock://"]);
        $this->assertIsArray($allowedProtocols["mock://"]["rules"]);
        $this->assertEquals(1, count($allowedProtocols["mock://"]["rules"]));
        $this->assertEquals($mock_protocol["mock://"]["rules"][0], $allowedProtocols["mock://"]["rules"][0]);

        [$validation_result] = $allowedProtocols["mock://"]["rules"][0]("mock://example.com/");
        $this->assertTrue($validation_result);
    }