CVE-2022-2400 (published 2022-07-18T15:15Z)

External Control of File Name or Path in GitHub repository dompdf/dompdf prior to 2.0.0.

CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-73
cwe_name: External Control of File Name or Path
description: The product allows user input to control or influence paths or file names that are used in filesystem operations.
extended_description: 
            This could allow an attacker to access or modify system files or other files that are critical to the application.
            Path manipulation errors occur when the following two conditions are met:
            
               1. An attacker can specify a path used in an operation on the filesystem.
               2. By specifying the resource, the attacker gains a capability that would not otherwise be permitted.
            
            For example, the program may give the attacker the ability to overwrite the specified file or run with a configuration controlled by the attacker.
         
         

Fix information:
repo_url: https://github.com/dompdf/dompdf
commit_details: repo_url: https://github.com/dompdf/dompdf
author: Brian Sweeney
author_date: 2022-04-18 15:17:23-04:00
msg: 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
repository: repo_url: https://github.com/dompdf/dompdf
repo_name: dompdf/dompdf
description: HTML to PDF converter for PHP
repo_language: PHP
owner: dompdf
file_changes: filename: Stylesheet.php
change_type: ModificationType.MODIFY
diff: @@ -325,46 +325,26 @@ class Stylesheet
             $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;
@@ -379,11 +359,12 @@ class Stylesheet
                     }
                 }
             }
-
             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);
@@ -1421,20 +1402,16 @@ class Stylesheet
             $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";
@@ -1483,9 +1460,9 @@ class Stylesheet
             // 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;
@@ -1675,7 +1652,7 @@ class Stylesheet
     {
         $options = $this->_dompdf->getOptions();
         $rootDir = realpath($options->getRootDir());
-        return $rootDir . self::DEFAULT_STYLESHEET;
+        return Helpers::build_url("file://", "", $rootDir, $rootDir . self::DEFAULT_STYLESHEET);
     }
 
     /**

programming_language: PHP
filename: Dompdf.php
change_type: ModificationType.MODIFY
diff: @@ -196,16 +196,6 @@ class Dompdf
      */
     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
     *
@@ -271,8 +261,11 @@ class Dompdf
         }
 
         $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();
@@ -352,43 +345,25 @@ class Dompdf
             [$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());
@@ -604,7 +579,9 @@ 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;
 

programming_language: PHP
filename: FontMetrics.php
change_type: ModificationType.MODIFY
diff: @@ -214,37 +214,18 @@ class FontMetrics
 
         // 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;

programming_language: PHP
filename: Image.php
change_type: ModificationType.MODIFY
diff: @@ -57,7 +57,7 @@ class Image extends AbstractFrameDecorator
             $dompdf->getProtocol(),
             $dompdf->getBaseHost(),
             $dompdf->getBasePath(),
-            $dompdf
+            $dompdf->getOptions()
         );
 
         if (Cache::is_broken($this->_image_url) &&

programming_language: PHP
filename: Helpers.php
change_type: ModificationType.MODIFY
diff: @@ -57,28 +57,45 @@ class Helpers
     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
@@ -89,9 +106,18 @@ class Helpers
             }
             $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);
@@ -431,14 +457,14 @@ class Helpers
         $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"])) {
@@ -480,42 +506,32 @@ class Helpers
 
         } 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;
     }
 
@@ -878,12 +894,13 @@ class Helpers
         $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);
                 }
@@ -899,7 +916,7 @@ class Helpers
                     $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);

programming_language: PHP
filename: Cache.php
change_type: ModificationType.MODIFY
diff: @@ -9,7 +9,7 @@
  */
 namespace Dompdf\Image;
 
-use Dompdf\Dompdf;
+use Dompdf\Options;
 use Dompdf\Helpers;
 use Dompdf\Exception\ImageException;
 
@@ -43,13 +43,6 @@ class Cache
 
     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.
      *
@@ -57,130 +50,91 @@ class Cache
      * @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;
@@ -229,7 +183,9 @@ class Cache
             if ($debugPng) {
                 print "[clear unlink $file]";
             }
-            unlink($file);
+            if (file_exists($file)) {
+                unlink($file);
+            }
         }
 
         foreach (self::$tempImages as $versions) {

programming_language: PHP
filename: Options.php
change_type: ModificationType.MODIFY
diff: @@ -62,6 +62,20 @@ class Options
      */
     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
      */
@@ -299,9 +313,12 @@ class Options
         $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" => [
@@ -310,6 +327,8 @@ class Options
             ]
         ]);
 
+        $this->setAllowedProtocols(["file://", "http://", "https://"]);
+
         if (null !== $attributes) {
             $this->set($attributes);
         }
@@ -334,6 +353,8 @@ class Options
                 $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') {
@@ -399,6 +420,8 @@ class Options
             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') {
@@ -499,6 +522,67 @@ class Options
         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
      */
@@ -1011,4 +1095,56 @@ class Options
     {
         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];
+    }
 }

programming_language: PHP
filename: AbstractRenderer.php
change_type: ModificationType.MODIFY
diff: @@ -97,7 +97,7 @@ abstract class AbstractRenderer
             $sheet->get_protocol(),
             $sheet->get_host(),
             $sheet->get_base_path(),
-            $this->_dompdf
+            $this->_dompdf->getOptions()
         );
 
         // Bail if the image is no good

programming_language: PHP
filename: StyleTest.php
change_type: ModificationType.MODIFY
diff: @@ -53,8 +53,8 @@ class StyleTest extends TestCase
     {
         $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"]
         ];
     }
 
@@ -62,8 +62,8 @@ class StyleTest extends TestCase
     {
         $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"]
         ];
     }
 

programming_language: PHP
filename: HelpersTest.php
change_type: ModificationType.MODIFY
diff: @@ -101,4 +101,11 @@ AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO
         $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));
+    }
 }

programming_language: PHP
filename: OptionsTest.php
change_type: ModificationType.MODIFY
diff: @@ -95,4 +95,44 @@ class OptionsTest extends TestCase
         $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);
+    }
 }

programming_language: PHP